@unblocklabs/unblock-memory 0.3.27 → 0.3.29
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/agent-transcript.d.ts +8 -0
- package/dist/src/agent-transcript.js +29 -0
- 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/date-option.d.ts +1 -0
- package/dist/src/date-option.js +8 -0
- 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 +61 -57
- package/dist/src/memory-whisperer.d.ts +2 -1
- package/dist/src/memory-whisperer.js +241 -114
- 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-episodes.js +4 -11
- package/dist/src/response-judge.d.ts +1 -1
- package/dist/src/response-judge.js +18 -15
- package/dist/src/response-runtime.js +1 -8
- 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/session-sync.js +8 -22
- package/dist/src/skill-whisperer.d.ts +1 -2
- package/dist/src/skill-whisperer.js +77 -72
- 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 +6 -34
- package/dist/src/training-judge.js +3 -3
- package/dist/src/training-queries.js +5 -4
- package/dist/src/training-runtime.js +1 -8
- 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-context.d.ts +1 -0
- package/dist/src/whisperer-context.js +9 -0
- 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/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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { memoryConversation } from "./whisperer-context.js";
|
|
2
|
+
import { judgeTypeSafeMemories } from "./typesafe.js";
|
|
3
|
+
import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
|
|
4
|
+
import { buildSkillWhispererQuery, memoryConversation } from "./whisperer-context.js";
|
|
5
5
|
import { complementaryIndices, reviewMemoryRedundancy } from "./typesafe-review.js";
|
|
6
|
+
import { MlxQueryGenerator, queryConversation } from "./mlx-query.js";
|
|
7
|
+
import { judgeTrainingInput, TRAINING_GATE_THRESHOLD } from "./training-gate.js";
|
|
6
8
|
const MAX_EXCERPT_CHARS = 1200;
|
|
7
9
|
function fingerprint(text) {
|
|
8
10
|
return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
|
|
@@ -11,7 +13,12 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
11
13
|
if (!config.enabled || !typesafe.enabled)
|
|
12
14
|
return;
|
|
13
15
|
const sessions = new Map();
|
|
14
|
-
|
|
16
|
+
const generator = config.mlx ? MlxQueryGenerator.shared(config.mlx) : undefined;
|
|
17
|
+
if (generator)
|
|
18
|
+
api.on("gateway_start", () => {
|
|
19
|
+
void generator.start().catch(() => api.logger.warn("unblock-memory MLX worker unavailable; query fallback active"));
|
|
20
|
+
});
|
|
21
|
+
const beforePrompt = async (event, context) => {
|
|
15
22
|
const { agentId, runId, sessionId, sessionKey } = context;
|
|
16
23
|
const scope = sessionId || sessionKey;
|
|
17
24
|
if (context.trigger !== "user" || !agentId || !runId || !scope || !event.prompt.trim())
|
|
@@ -22,9 +29,15 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
22
29
|
const key = JSON.stringify([agentId, scope]);
|
|
23
30
|
const previous = sessions.get(key);
|
|
24
31
|
if (previous?.runId === runId)
|
|
25
|
-
return;
|
|
32
|
+
return previous.result;
|
|
26
33
|
const started = performance.now();
|
|
27
34
|
const measurement = { outcome: "skipped", elapsedMs: 0 };
|
|
35
|
+
let stage = "credentials", reason = "completed";
|
|
36
|
+
let recallProbability, queryCount;
|
|
37
|
+
let requestsSucceeded = 0, requestsFailed = 0;
|
|
38
|
+
const log = (level, event, fields) => api.logger[level]("unblock-memory memory_whisperer " + JSON.stringify({ event, agentId, runId, sessionId, stage, ...fields }));
|
|
39
|
+
const failureFields = (error) => error instanceof TypeSafeRequestError
|
|
40
|
+
? { errorCode: error.code, httpStatus: error.status } : { errorCode: "unexpected" };
|
|
28
41
|
previous?.controller.abort();
|
|
29
42
|
const state = {
|
|
30
43
|
agentId, sessionId, sessionKey, runId, turn: (previous?.turn ?? 0) + 1,
|
|
@@ -37,6 +50,8 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
37
50
|
}
|
|
38
51
|
const { signal } = state.controller;
|
|
39
52
|
let timedOut = false;
|
|
53
|
+
let recallRejected = false;
|
|
54
|
+
let gateFailed = false;
|
|
40
55
|
const timer = setTimeout(() => { timedOut = true; state.controller.abort(); }, config.timeoutMs);
|
|
41
56
|
let onAbort = () => { };
|
|
42
57
|
const aborted = new Promise(resolve => {
|
|
@@ -48,123 +63,233 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
48
63
|
if (signal.aborted)
|
|
49
64
|
return;
|
|
50
65
|
if (!apiKey) {
|
|
66
|
+
reason = "missing_key";
|
|
51
67
|
diagnostics?.record(agentId, "memory", "missing_key");
|
|
52
68
|
return;
|
|
53
69
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
70
|
+
stage = "input";
|
|
71
|
+
const conversation = generator ? queryConversation(event.prompt, event.messages, config.historyMessages) : undefined;
|
|
72
|
+
const gateStarted = performance.now();
|
|
73
|
+
const gateSignal = AbortSignal.any([signal, AbortSignal.timeout(typesafe.timeoutMs)]);
|
|
74
|
+
// Speculation is intentional: neither model generation nor QMD waits for the recall judgment.
|
|
75
|
+
const gate = conversation ? judgeTrainingInput(conversation, apiKey, gateSignal)
|
|
76
|
+
.then(result => {
|
|
77
|
+
if (signal.aborted)
|
|
78
|
+
return false;
|
|
79
|
+
measurement.gateMs = performance.now() - gateStarted;
|
|
80
|
+
recallProbability = result.probability;
|
|
81
|
+
if (result.probability >= TRAINING_GATE_THRESHOLD)
|
|
82
|
+
return true;
|
|
83
|
+
recallRejected = true;
|
|
84
|
+
diagnostics?.record(agentId, "memory", "recall_not_needed");
|
|
85
|
+
state.controller.abort();
|
|
86
|
+
return false;
|
|
87
|
+
}).catch(error => {
|
|
88
|
+
if (!signal.aborted) {
|
|
89
|
+
measurement.gateMs = performance.now() - gateStarted;
|
|
90
|
+
log("warn", "recall_failed", { stage: "recall", elapsedMs: measurement.gateMs, timeoutMs: typesafe.timeoutMs,
|
|
91
|
+
...failureFields(error instanceof TypeSafeRequestError ? error :
|
|
92
|
+
new TypeSafeRequestError("Recall judgment failed", "invalid_response")) });
|
|
93
|
+
gateFailed = true;
|
|
94
|
+
diagnostics?.record(agentId, "memory", "failed");
|
|
95
|
+
state.controller.abort();
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}) : Promise.resolve(true);
|
|
99
|
+
const retrieveAndJudge = async () => {
|
|
100
|
+
let queries;
|
|
101
|
+
if (generator && conversation) {
|
|
102
|
+
stage = "generation";
|
|
103
|
+
const generationStarted = performance.now();
|
|
104
|
+
try {
|
|
105
|
+
queries = await generator.generate(conversation, signal);
|
|
106
|
+
if (signal.aborted)
|
|
107
|
+
return;
|
|
108
|
+
queryCount = queries.length;
|
|
109
|
+
diagnostics?.record(agentId, "memory", "queries_generated");
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (signal.aborted)
|
|
113
|
+
return;
|
|
114
|
+
log("warn", "query_fallback", { ...failureFields(error), elapsedMs: performance.now() - generationStarted });
|
|
115
|
+
diagnostics?.record(agentId, "memory", "query_fallback");
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
measurement.generationMs = performance.now() - generationStarted;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
stage = "retrieval";
|
|
122
|
+
const { manager } = await runtime.getMemorySearchManager({ cfg: api.config, agentId });
|
|
123
|
+
if (signal.aborted)
|
|
124
|
+
return;
|
|
125
|
+
if (!manager) {
|
|
126
|
+
reason = "unavailable";
|
|
127
|
+
diagnostics?.record(agentId, "memory", "unavailable");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const retrievalStarted = performance.now();
|
|
131
|
+
const hits = queries && manager.searchWhisperer ? await manager.searchWhisperer(queries, { corpora, signal, maxSnippetChars: MAX_EXCERPT_CHARS }) : await manager.search(buildSkillWhispererQuery(event.prompt, event.messages, config.historyMessages), { corpora, maxResults: 8, minScore: -1, signal, maxSnippetChars: MAX_EXCERPT_CHARS });
|
|
132
|
+
if (signal.aborted)
|
|
133
|
+
return;
|
|
134
|
+
measurement.retrievalMs = performance.now() - retrievalStarted;
|
|
135
|
+
measurement.candidates = hits.length;
|
|
136
|
+
const candidates = [];
|
|
137
|
+
for (const hit of hits) {
|
|
138
|
+
// Enforce scope again before sending anything to the external judge.
|
|
139
|
+
if (!corpora.includes(hit.corpus))
|
|
140
|
+
continue;
|
|
141
|
+
const excerpt = hit.snippet.trim();
|
|
142
|
+
// Retrieval bounds context around a complete match. Never replace it
|
|
143
|
+
// with a prefix if a manager returns an oversized result.
|
|
144
|
+
if (excerpt.length > MAX_EXCERPT_CHARS)
|
|
145
|
+
continue;
|
|
146
|
+
const id = fingerprint(excerpt);
|
|
147
|
+
if (!excerpt || state.recent.has(id) || candidates.some(candidate => candidate.id === id ||
|
|
148
|
+
(candidate.hit.path === hit.path && candidate.hit.startLine <= hit.endLine &&
|
|
149
|
+
hit.startLine <= candidate.hit.endLine)))
|
|
150
|
+
continue;
|
|
151
|
+
candidates.push({ hit, excerpt, id });
|
|
152
|
+
if (!queries && candidates.length === 8)
|
|
153
|
+
break;
|
|
117
154
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
155
|
+
measurement.eligible = candidates.length;
|
|
156
|
+
measurement.outcome = "empty";
|
|
157
|
+
if (!candidates.length) {
|
|
158
|
+
reason = "no_candidates";
|
|
159
|
+
diagnostics?.record(agentId, "memory", "no_candidates");
|
|
160
|
+
return;
|
|
122
161
|
}
|
|
162
|
+
stage = "judgment";
|
|
163
|
+
const judgeStarted = performance.now();
|
|
164
|
+
const judgeConversation = conversation ? { ...conversation, truncated: event.messages.length > conversation.history.length }
|
|
165
|
+
: memoryConversation(event.prompt, event.messages);
|
|
166
|
+
// Every eligible passage gets its own request; no request sees another candidate.
|
|
167
|
+
const judged = (await Promise.all(candidates.map(async (candidate, candidateIndex) => {
|
|
168
|
+
const requestStarted = performance.now();
|
|
169
|
+
const fields = { stage: "judgment", candidateIndex,
|
|
170
|
+
timeoutMs: typesafe.timeoutMs };
|
|
171
|
+
try {
|
|
172
|
+
const { hit, excerpt } = candidate;
|
|
173
|
+
const [probability] = await judgeTypeSafeMemories({
|
|
174
|
+
apiKey, timeoutMs: typesafe.timeoutMs, signal,
|
|
175
|
+
conversation: judgeConversation,
|
|
176
|
+
candidates: [{
|
|
177
|
+
excerpt, corpus: hit.corpus, ...(hit.messageTimestamp ? { messageTimestamp: hit.messageTimestamp } : {}),
|
|
178
|
+
}],
|
|
179
|
+
});
|
|
180
|
+
if (signal.aborted)
|
|
181
|
+
return [];
|
|
182
|
+
requestsSucceeded++;
|
|
183
|
+
log("info", "candidate_completed", { ...fields, elapsedMs: performance.now() - requestStarted });
|
|
184
|
+
// Failed candidates have no score, not zero. Keep each score with its own source.
|
|
185
|
+
return [{ ...candidate, probability }];
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (signal.aborted)
|
|
189
|
+
return [];
|
|
190
|
+
requestsFailed++;
|
|
191
|
+
diagnostics?.record(agentId, "memory", "judge_candidate_failed");
|
|
192
|
+
log("warn", "candidate_failed", { ...fields, ...failureFields(error), elapsedMs: performance.now() - requestStarted });
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}))).flat();
|
|
196
|
+
if (signal.aborted || sessions.get(key) !== state)
|
|
197
|
+
return;
|
|
198
|
+
measurement.judgeMs = performance.now() - judgeStarted;
|
|
199
|
+
if (!await gate || signal.aborted || sessions.get(key) !== state)
|
|
200
|
+
return;
|
|
201
|
+
if (!judged.length) {
|
|
202
|
+
reason = "all_candidates_failed";
|
|
203
|
+
measurement.outcome = "failed";
|
|
204
|
+
diagnostics?.record(agentId, "memory", "failed");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const ranked = judged
|
|
208
|
+
.filter(candidate => candidate.probability >= config.minUsefulness)
|
|
209
|
+
.sort((a, b) => b.probability - a.probability)
|
|
210
|
+
.slice(0, 4);
|
|
211
|
+
let selected = ranked.slice(0, config.maxHints);
|
|
212
|
+
if (!selected.length) {
|
|
213
|
+
reason = "rejected";
|
|
214
|
+
diagnostics?.record(agentId, "memory", "rejected");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (config.complementaryHints && config.maxHints > 1 && ranked.length > 1) {
|
|
218
|
+
stage = "redundancy";
|
|
219
|
+
try {
|
|
220
|
+
const pairs = await reviewMemoryRedundancy({ apiKey, timeoutMs: typesafe.timeoutMs, signal,
|
|
221
|
+
excerpts: ranked.map(candidate => candidate.excerpt) });
|
|
222
|
+
selected = complementaryIndices(ranked.length, pairs, config.maxHints).map(index => ranked[index]);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
// Preserve the original useful candidates when the optional refinement is unavailable.
|
|
226
|
+
if (!signal.aborted) {
|
|
227
|
+
diagnostics?.record(agentId, "memory", "redundancy_unavailable");
|
|
228
|
+
log("warn", "redundancy_unavailable", failureFields(error));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (signal.aborted || sessions.get(key) !== state)
|
|
233
|
+
return;
|
|
234
|
+
stage = "render";
|
|
235
|
+
const hints = selected.map(({ hit, excerpt }) => ({
|
|
236
|
+
source: hit.path, lines: `${hit.startLine}-${hit.endLine}`, body: excerpt,
|
|
237
|
+
}));
|
|
238
|
+
// Bound the complete injected payload, including source metadata.
|
|
239
|
+
// Preserve exact JSON values while keeping source text inside its prompt tags.
|
|
240
|
+
const rendered = JSON.stringify(hints).replace(/</gu, "\\u003c").replace(/>/gu, "\\u003e");
|
|
241
|
+
if (rendered.length > 5000) {
|
|
242
|
+
reason = "payload_limit";
|
|
243
|
+
diagnostics?.record(agentId, "memory", "payload_limit");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (const candidate of selected)
|
|
247
|
+
state.recent.set(candidate.id, state.turn);
|
|
248
|
+
diagnostics?.record(agentId, "memory", "emitted");
|
|
249
|
+
const appendContext = `<memory>\n${rendered}\n</memory>`;
|
|
250
|
+
measurement.outcome = "ok";
|
|
251
|
+
reason = "emitted";
|
|
252
|
+
measurement.results = selected.length;
|
|
253
|
+
measurement.contextChars = appendContext.length;
|
|
254
|
+
return { appendContext };
|
|
255
|
+
};
|
|
256
|
+
const [, result] = await Promise.all([gate, retrieveAndJudge()]);
|
|
257
|
+
return result;
|
|
258
|
+
};
|
|
259
|
+
// Prompt rebuilds need the same context, not another retrieval or an empty hook result.
|
|
260
|
+
state.result = (async () => {
|
|
261
|
+
try {
|
|
262
|
+
return await Promise.race([run(), aborted]);
|
|
123
263
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// Bound the complete injected payload, including source metadata.
|
|
132
|
-
const rendered = JSON.stringify(hints);
|
|
133
|
-
if (rendered.length > 5000) {
|
|
134
|
-
diagnostics?.record(agentId, "memory", "payload_limit");
|
|
264
|
+
catch (error) {
|
|
265
|
+
reason = "stage_failed";
|
|
266
|
+
measurement.outcome = "failed";
|
|
267
|
+
if (!signal.aborted) {
|
|
268
|
+
diagnostics?.record(agentId, "memory", "failed");
|
|
269
|
+
log("warn", "stage_failed", failureFields(error));
|
|
270
|
+
}
|
|
135
271
|
return;
|
|
136
272
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
finally {
|
|
160
|
-
diagnostics?.measureMemory(agentId, { ...measurement, elapsedMs: performance.now() - started,
|
|
161
|
-
...(signal.aborted ? { outcome: timedOut ? "timed_out" : "cancelled" } : {}) });
|
|
162
|
-
if (signal.aborted)
|
|
163
|
-
diagnostics?.record(agentId, "memory", timedOut ? "timed_out" : "cancelled");
|
|
164
|
-
clearTimeout(timer);
|
|
165
|
-
signal.removeEventListener("abort", onAbort);
|
|
166
|
-
}
|
|
167
|
-
});
|
|
273
|
+
finally {
|
|
274
|
+
measurement.elapsedMs = performance.now() - started;
|
|
275
|
+
if (signal.aborted) {
|
|
276
|
+
measurement.outcome = gateFailed ? "failed" : recallRejected ? "skipped" : timedOut ? "timed_out" : "cancelled";
|
|
277
|
+
reason = gateFailed ? "recall_failed" : recallRejected ? "recall_not_needed" : timedOut ? "deadline" : "cancelled";
|
|
278
|
+
}
|
|
279
|
+
diagnostics?.measureMemory(agentId, measurement);
|
|
280
|
+
log("info", "completed", { ...measurement, reason, recallProbability, queryCount, requestsSucceeded, requestsFailed,
|
|
281
|
+
judgedCandidates: requestsSucceeded, partial: requestsSucceeded > 0 && requestsFailed > 0, timeoutMs: config.timeoutMs,
|
|
282
|
+
requestTimeoutMs: typesafe.timeoutMs });
|
|
283
|
+
if (signal.aborted && !recallRejected && !gateFailed)
|
|
284
|
+
diagnostics?.record(agentId, "memory", timedOut ? "timed_out" : "cancelled");
|
|
285
|
+
clearTimeout(timer);
|
|
286
|
+
signal.removeEventListener("abort", onAbort);
|
|
287
|
+
// Stop sibling provider/worker work after an error or an empty early result too.
|
|
288
|
+
state.controller.abort();
|
|
289
|
+
}
|
|
290
|
+
})();
|
|
291
|
+
return state.result;
|
|
292
|
+
};
|
|
168
293
|
api.on("session_end", (event, context) => {
|
|
169
294
|
for (const [key, state] of sessions) {
|
|
170
295
|
if (context.agentId && state.agentId !== context.agentId)
|
|
@@ -177,8 +302,10 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
177
302
|
}
|
|
178
303
|
});
|
|
179
304
|
api.on("gateway_stop", () => {
|
|
305
|
+
generator?.close();
|
|
180
306
|
for (const state of sessions.values())
|
|
181
307
|
state.controller.abort();
|
|
182
308
|
sessions.clear();
|
|
183
309
|
});
|
|
310
|
+
return beforePrompt;
|
|
184
311
|
}
|
|
@@ -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;
|