@unblocklabs/unblock-memory 0.3.27 → 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.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/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
|
@@ -59,6 +59,6 @@ export async function reviewClusterIngestion(params) {
|
|
|
59
59
|
});
|
|
60
60
|
return { status: "ok", runId: snapshot.runId, clusterId: params.clusterId, members, recurring,
|
|
61
61
|
sampled: sample.length, considered: snapshot.considered, clusterSize: snapshot.clusterSize,
|
|
62
|
-
policy: "jev-1.13.0:cluster-defects-
|
|
62
|
+
policy: "jev-1.13.0:cluster-defects-v2-isolated", scope: "Center/edge sample of approved complete chunks only. Recurring labels are hypotheses, not proof of a shared cause or permission to change any member. Unreviewed members remain unknown." };
|
|
63
63
|
});
|
|
64
64
|
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -69,6 +69,10 @@ export type UnblockMemoryConfig = {
|
|
|
69
69
|
maxHints: number;
|
|
70
70
|
cooldownTurns: number;
|
|
71
71
|
timeoutMs: number;
|
|
72
|
+
mlx?: {
|
|
73
|
+
pythonPath: string;
|
|
74
|
+
modelPath: string;
|
|
75
|
+
};
|
|
72
76
|
};
|
|
73
77
|
};
|
|
74
78
|
export declare const DEFAULT_PEOPLE_CONFIG: UnblockMemoryConfig["people"];
|
package/dist/src/config.js
CHANGED
|
@@ -91,7 +91,19 @@ function resolveMemoryWhisperer(value, corpora) {
|
|
|
91
91
|
throw new Error("unblock-memory memoryWhisperer must be an object");
|
|
92
92
|
}
|
|
93
93
|
const config = value;
|
|
94
|
-
assertOnlyKeys(config, Object.keys(DEFAULT_MEMORY_WHISPERER), "memoryWhisperer");
|
|
94
|
+
assertOnlyKeys(config, [...Object.keys(DEFAULT_MEMORY_WHISPERER), "mlx"], "memoryWhisperer");
|
|
95
|
+
let mlx;
|
|
96
|
+
if (config.mlx !== undefined) {
|
|
97
|
+
if (!config.mlx || typeof config.mlx !== "object" || Array.isArray(config.mlx))
|
|
98
|
+
throw new Error("memoryWhisperer.mlx must be an object");
|
|
99
|
+
const value = config.mlx;
|
|
100
|
+
assertOnlyKeys(value, ["pythonPath", "modelPath"], "memoryWhisperer.mlx");
|
|
101
|
+
if (typeof value.pythonPath !== "string" || !isAbsolute(value.pythonPath) ||
|
|
102
|
+
typeof value.modelPath !== "string" || !isAbsolute(value.modelPath)) {
|
|
103
|
+
throw new Error("memoryWhisperer.mlx requires absolute pythonPath and modelPath");
|
|
104
|
+
}
|
|
105
|
+
mlx = { pythonPath: value.pythonPath, modelPath: value.modelPath };
|
|
106
|
+
}
|
|
95
107
|
const enabled = config.enabled ?? false;
|
|
96
108
|
if (typeof enabled !== "boolean")
|
|
97
109
|
throw new Error("unblock-memory memoryWhisperer.enabled must be a boolean");
|
|
@@ -120,6 +132,7 @@ function resolveMemoryWhisperer(value, corpora) {
|
|
|
120
132
|
enabled, complementaryHints, corpora: [...new Set(selected)], historyMessages, cooldownTurns, minUsefulness,
|
|
121
133
|
maxHints: positiveInteger(config.maxHints, 2, "memoryWhisperer.maxHints", 2),
|
|
122
134
|
timeoutMs: positiveInteger(config.timeoutMs, 3000, "memoryWhisperer.timeoutMs", 10_000),
|
|
135
|
+
...(mlx ? { mlx } : {}),
|
|
123
136
|
};
|
|
124
137
|
}
|
|
125
138
|
function assertOnlyKeys(value, allowed, label) {
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { RetrievalTelemetry } from "./retrieval-telemetry.js";
|
|
2
2
|
type Whisperer = "skill" | "memory";
|
|
3
|
-
type Outcome = "missing_key" | "typesafe_disabled" | "no_candidates" | "rejected" | "cooldown" | "emitted" | "failed" | "timed_out" | "cancelled" | "unavailable" | "payload_limit" | "redundancy_unavailable";
|
|
3
|
+
type Outcome = "missing_key" | "typesafe_disabled" | "no_candidates" | "rejected" | "cooldown" | "emitted" | "failed" | "timed_out" | "cancelled" | "unavailable" | "payload_limit" | "redundancy_unavailable" | "recall_not_needed" | "queries_generated" | "query_fallback" | "judge_candidate_failed";
|
|
4
4
|
/** Process-local, content-free and bounded. Agent IDs are keys, never included in snapshots. */
|
|
5
5
|
export declare class WhispererDiagnostics {
|
|
6
6
|
#private;
|
|
7
|
+
static shared(): WhispererDiagnostics;
|
|
7
8
|
record(agentId: string, whisperer: Whisperer, outcome: Outcome): void;
|
|
8
9
|
measureMemory(agentId: string, observation: Parameters<RetrievalTelemetry["record"]>[1]): void;
|
|
9
10
|
snapshot(agentId: string): {
|
|
10
11
|
skill: {
|
|
12
|
+
cancelled?: number | undefined;
|
|
11
13
|
unavailable?: number | undefined;
|
|
12
14
|
rejected?: number | undefined;
|
|
13
15
|
failed?: number | undefined;
|
|
14
|
-
cancelled?: number | undefined;
|
|
15
16
|
timed_out?: number | undefined;
|
|
16
17
|
missing_key?: number | undefined;
|
|
17
18
|
typesafe_disabled?: number | undefined;
|
|
@@ -20,12 +21,16 @@ export declare class WhispererDiagnostics {
|
|
|
20
21
|
emitted?: number | undefined;
|
|
21
22
|
payload_limit?: number | undefined;
|
|
22
23
|
redundancy_unavailable?: number | undefined;
|
|
24
|
+
recall_not_needed?: number | undefined;
|
|
25
|
+
queries_generated?: number | undefined;
|
|
26
|
+
query_fallback?: number | undefined;
|
|
27
|
+
judge_candidate_failed?: number | undefined;
|
|
23
28
|
};
|
|
24
29
|
memory: {
|
|
30
|
+
cancelled?: number | undefined;
|
|
25
31
|
unavailable?: number | undefined;
|
|
26
32
|
rejected?: number | undefined;
|
|
27
33
|
failed?: number | undefined;
|
|
28
|
-
cancelled?: number | undefined;
|
|
29
34
|
timed_out?: number | undefined;
|
|
30
35
|
missing_key?: number | undefined;
|
|
31
36
|
typesafe_disabled?: number | undefined;
|
|
@@ -34,6 +39,10 @@ export declare class WhispererDiagnostics {
|
|
|
34
39
|
emitted?: number | undefined;
|
|
35
40
|
payload_limit?: number | undefined;
|
|
36
41
|
redundancy_unavailable?: number | undefined;
|
|
42
|
+
recall_not_needed?: number | undefined;
|
|
43
|
+
queries_generated?: number | undefined;
|
|
44
|
+
query_fallback?: number | undefined;
|
|
45
|
+
judge_candidate_failed?: number | undefined;
|
|
37
46
|
};
|
|
38
47
|
telemetry: {
|
|
39
48
|
scope: string;
|
|
@@ -41,11 +50,11 @@ export declare class WhispererDiagnostics {
|
|
|
41
50
|
[k: string]: {
|
|
42
51
|
calls: number;
|
|
43
52
|
outcomes: {
|
|
53
|
+
cancelled?: number | undefined;
|
|
44
54
|
ok?: number | undefined;
|
|
45
55
|
skipped?: number | undefined;
|
|
46
56
|
failed?: number | undefined;
|
|
47
57
|
empty?: number | undefined;
|
|
48
|
-
cancelled?: number | undefined;
|
|
49
58
|
timed_out?: number | undefined;
|
|
50
59
|
};
|
|
51
60
|
measurements: {
|
package/dist/src/diagnostics.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { RetrievalTelemetry } from "./retrieval-telemetry.js";
|
|
2
2
|
/** Process-local, content-free and bounded. Agent IDs are keys, never included in snapshots. */
|
|
3
3
|
export class WhispererDiagnostics {
|
|
4
|
+
static shared() {
|
|
5
|
+
const scope = globalThis;
|
|
6
|
+
const symbol = Symbol.for("unblock-memory.whisperer-diagnostics.v1");
|
|
7
|
+
return (scope[symbol] ??= new WhispererDiagnostics());
|
|
8
|
+
}
|
|
4
9
|
#agents = new Map();
|
|
5
10
|
#entry(agentId) {
|
|
6
11
|
let entry = this.#agents.get(agentId);
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -57,11 +57,11 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
57
57
|
[k: string]: {
|
|
58
58
|
calls: number;
|
|
59
59
|
outcomes: {
|
|
60
|
+
cancelled?: number | undefined;
|
|
60
61
|
ok?: number | undefined;
|
|
61
62
|
skipped?: number | undefined;
|
|
62
63
|
failed?: number | undefined;
|
|
63
64
|
empty?: number | undefined;
|
|
64
|
-
cancelled?: number | undefined;
|
|
65
65
|
timed_out?: number | undefined;
|
|
66
66
|
};
|
|
67
67
|
measurements: {
|
|
@@ -220,6 +220,8 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
220
220
|
};
|
|
221
221
|
}): MaintenanceTask | undefined;
|
|
222
222
|
search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
223
|
+
/** Whisperer-only discovery: exact trained recipe, no query-conditioned reranker or merged cap. */
|
|
224
|
+
searchWhisperer(queries: readonly string[], opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
|
|
223
225
|
searchSkills(query: string, minScore: number, limit: number): Promise<SkillSearchCandidate[]>;
|
|
224
226
|
readFile(params: {
|
|
225
227
|
relPath: string;
|
package/dist/src/manager.js
CHANGED
|
@@ -16,6 +16,7 @@ import { reviewIndexedClaim } from "./evidence-review.js";
|
|
|
16
16
|
import { reviewClusterIngestion } from "./cluster-review.js";
|
|
17
17
|
import { abortable } from "./abortable.js";
|
|
18
18
|
import { RetrievalTelemetry } from "./retrieval-telemetry.js";
|
|
19
|
+
import { trainingCandidates } from "./training-candidates.js";
|
|
19
20
|
const DEFAULT_READ_LINES = 120;
|
|
20
21
|
const MAX_READ_CHARS = 12_000;
|
|
21
22
|
const WATCH_DEBOUNCE_MS = 250;
|
|
@@ -857,9 +858,39 @@ export class QmdMemoryManager {
|
|
|
857
858
|
expand: false,
|
|
858
859
|
});
|
|
859
860
|
opts?.signal?.throwIfAborted();
|
|
861
|
+
return this.#renderSearchHits(hits, store, opts);
|
|
862
|
+
}
|
|
863
|
+
/** Whisperer-only discovery: exact trained recipe, no query-conditioned reranker or merged cap. */
|
|
864
|
+
async searchWhisperer(queries, opts) {
|
|
865
|
+
return this.#enqueue(async () => {
|
|
866
|
+
opts.signal?.throwIfAborted();
|
|
867
|
+
const collections = this.#collectionNames(opts.corpora);
|
|
868
|
+
if (!collections.length)
|
|
869
|
+
return [];
|
|
870
|
+
if (this.#sessions && collections.includes(this.#sessions.collection))
|
|
871
|
+
await this.#refreshSessionMetadata();
|
|
872
|
+
const store = await this.#getAnalysisStore();
|
|
873
|
+
const hits = new Map();
|
|
874
|
+
// Serialize native QMD work. Cancellation prevents further queries/collections.
|
|
875
|
+
for (const query of queries)
|
|
876
|
+
for (const collection of collections) {
|
|
877
|
+
opts.signal?.throwIfAborted();
|
|
878
|
+
for (const hit of await trainingCandidates(store, query, collection, query, opts.signal)) {
|
|
879
|
+
const key = JSON.stringify([hit.file, hit.bestChunkPos, hit.bestChunk]);
|
|
880
|
+
if (!hits.has(key))
|
|
881
|
+
hits.set(key, { file: hit.file, body: hit.body, bestChunk: hit.bestChunk,
|
|
882
|
+
chunkPos: hit.bestChunkPos, chunkLen: hit.bestChunk.length, displayPath: hit.file, score: hit.score });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
opts.signal?.throwIfAborted();
|
|
886
|
+
return this.#renderSearchHits([...hits.values()], store, opts);
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
async #renderSearchHits(hits, store, opts) {
|
|
860
890
|
const tokenizer = store.internal?.llm;
|
|
861
891
|
const results = [];
|
|
862
892
|
for (const hit of hits) {
|
|
893
|
+
opts?.signal?.throwIfAborted();
|
|
863
894
|
// Proactive hints must retain the entire matched chunk, even when expanded
|
|
864
895
|
// turn/message context exceeds their budget. Ordinary search is unchanged.
|
|
865
896
|
if (hit.bestChunk.length > (opts?.maxSnippetChars ?? Infinity))
|
|
@@ -9,8 +9,9 @@ type MemoryWhispererRuntime = {
|
|
|
9
9
|
}): Promise<{
|
|
10
10
|
manager: {
|
|
11
11
|
search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
12
|
+
searchWhisperer?(queries: readonly string[], opts: Pick<CorpusSearchOptions, "corpora" | "signal" | "maxSnippetChars">): Promise<CorpusMemorySearchResult[]>;
|
|
12
13
|
} | null;
|
|
13
14
|
}>;
|
|
14
15
|
};
|
|
15
|
-
export declare function registerMemoryWhisperer(api: OpenClawPluginApi, runtime: MemoryWhispererRuntime, config: UnblockMemoryConfig["memoryWhisperer"], typesafe: UnblockMemoryConfig["typesafe"], diagnostics?: WhispererDiagnostics):
|
|
16
|
+
export declare function registerMemoryWhisperer(api: OpenClawPluginApi, runtime: MemoryWhispererRuntime, config: UnblockMemoryConfig["memoryWhisperer"], typesafe: UnblockMemoryConfig["typesafe"], diagnostics?: WhispererDiagnostics): Parameters<typeof api.on<"before_prompt_build">>[1] | undefined;
|
|
16
17
|
export {};
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { buildSkillWhispererQuery } from "./skill-whisperer.js";
|
|
3
|
-
import { judgeTypeSafeMemories
|
|
3
|
+
import { judgeTypeSafeMemories } from "./typesafe.js";
|
|
4
|
+
import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
|
|
4
5
|
import { memoryConversation } from "./whisperer-context.js";
|
|
5
6
|
import { complementaryIndices, reviewMemoryRedundancy } from "./typesafe-review.js";
|
|
7
|
+
import { MlxQueryGenerator, queryConversation } from "./mlx-query.js";
|
|
8
|
+
import { judgeTrainingInput, TRAINING_GATE_THRESHOLD } from "./training-gate.js";
|
|
6
9
|
const MAX_EXCERPT_CHARS = 1200;
|
|
7
10
|
function fingerprint(text) {
|
|
8
11
|
return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
|
|
@@ -11,7 +14,12 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
11
14
|
if (!config.enabled || !typesafe.enabled)
|
|
12
15
|
return;
|
|
13
16
|
const sessions = new Map();
|
|
14
|
-
|
|
17
|
+
const generator = config.mlx ? MlxQueryGenerator.shared(config.mlx) : undefined;
|
|
18
|
+
if (generator)
|
|
19
|
+
api.on("gateway_start", () => {
|
|
20
|
+
void generator.start().catch(() => api.logger.warn("unblock-memory MLX worker unavailable; query fallback active"));
|
|
21
|
+
});
|
|
22
|
+
const beforePrompt = async (event, context) => {
|
|
15
23
|
const { agentId, runId, sessionId, sessionKey } = context;
|
|
16
24
|
const scope = sessionId || sessionKey;
|
|
17
25
|
if (context.trigger !== "user" || !agentId || !runId || !scope || !event.prompt.trim())
|
|
@@ -22,9 +30,15 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
22
30
|
const key = JSON.stringify([agentId, scope]);
|
|
23
31
|
const previous = sessions.get(key);
|
|
24
32
|
if (previous?.runId === runId)
|
|
25
|
-
return;
|
|
33
|
+
return previous.result;
|
|
26
34
|
const started = performance.now();
|
|
27
35
|
const measurement = { outcome: "skipped", elapsedMs: 0 };
|
|
36
|
+
let stage = "credentials", reason = "completed";
|
|
37
|
+
let recallProbability, queryCount;
|
|
38
|
+
let requestsSucceeded = 0, requestsFailed = 0;
|
|
39
|
+
const log = (level, event, fields) => api.logger[level]("unblock-memory memory_whisperer " + JSON.stringify({ event, agentId, runId, sessionId, stage, ...fields }));
|
|
40
|
+
const failureFields = (error) => error instanceof TypeSafeRequestError
|
|
41
|
+
? { errorCode: error.code, httpStatus: error.status } : { errorCode: "unexpected" };
|
|
28
42
|
previous?.controller.abort();
|
|
29
43
|
const state = {
|
|
30
44
|
agentId, sessionId, sessionKey, runId, turn: (previous?.turn ?? 0) + 1,
|
|
@@ -37,6 +51,8 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
37
51
|
}
|
|
38
52
|
const { signal } = state.controller;
|
|
39
53
|
let timedOut = false;
|
|
54
|
+
let recallRejected = false;
|
|
55
|
+
let gateFailed = false;
|
|
40
56
|
const timer = setTimeout(() => { timedOut = true; state.controller.abort(); }, config.timeoutMs);
|
|
41
57
|
let onAbort = () => { };
|
|
42
58
|
const aborted = new Promise(resolve => {
|
|
@@ -48,123 +64,233 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
48
64
|
if (signal.aborted)
|
|
49
65
|
return;
|
|
50
66
|
if (!apiKey) {
|
|
67
|
+
reason = "missing_key";
|
|
51
68
|
diagnostics?.record(agentId, "memory", "missing_key");
|
|
52
69
|
return;
|
|
53
70
|
}
|
|
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
|
-
|
|
71
|
+
stage = "input";
|
|
72
|
+
const conversation = generator ? queryConversation(event.prompt, event.messages, config.historyMessages) : undefined;
|
|
73
|
+
const gateStarted = performance.now();
|
|
74
|
+
const gateSignal = AbortSignal.any([signal, AbortSignal.timeout(typesafe.timeoutMs)]);
|
|
75
|
+
// Speculation is intentional: neither model generation nor QMD waits for the recall judgment.
|
|
76
|
+
const gate = conversation ? judgeTrainingInput(conversation, apiKey, gateSignal)
|
|
77
|
+
.then(result => {
|
|
78
|
+
if (signal.aborted)
|
|
79
|
+
return false;
|
|
80
|
+
measurement.gateMs = performance.now() - gateStarted;
|
|
81
|
+
recallProbability = result.probability;
|
|
82
|
+
if (result.probability >= TRAINING_GATE_THRESHOLD)
|
|
83
|
+
return true;
|
|
84
|
+
recallRejected = true;
|
|
85
|
+
diagnostics?.record(agentId, "memory", "recall_not_needed");
|
|
86
|
+
state.controller.abort();
|
|
87
|
+
return false;
|
|
88
|
+
}).catch(error => {
|
|
89
|
+
if (!signal.aborted) {
|
|
90
|
+
measurement.gateMs = performance.now() - gateStarted;
|
|
91
|
+
log("warn", "recall_failed", { stage: "recall", elapsedMs: measurement.gateMs, timeoutMs: typesafe.timeoutMs,
|
|
92
|
+
...failureFields(error instanceof TypeSafeRequestError ? error :
|
|
93
|
+
new TypeSafeRequestError("Recall judgment failed", "invalid_response")) });
|
|
94
|
+
gateFailed = true;
|
|
95
|
+
diagnostics?.record(agentId, "memory", "failed");
|
|
96
|
+
state.controller.abort();
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}) : Promise.resolve(true);
|
|
100
|
+
const retrieveAndJudge = async () => {
|
|
101
|
+
let queries;
|
|
102
|
+
if (generator && conversation) {
|
|
103
|
+
stage = "generation";
|
|
104
|
+
const generationStarted = performance.now();
|
|
105
|
+
try {
|
|
106
|
+
queries = await generator.generate(conversation, signal);
|
|
107
|
+
if (signal.aborted)
|
|
108
|
+
return;
|
|
109
|
+
queryCount = queries.length;
|
|
110
|
+
diagnostics?.record(agentId, "memory", "queries_generated");
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (signal.aborted)
|
|
114
|
+
return;
|
|
115
|
+
log("warn", "query_fallback", { ...failureFields(error), elapsedMs: performance.now() - generationStarted });
|
|
116
|
+
diagnostics?.record(agentId, "memory", "query_fallback");
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
measurement.generationMs = performance.now() - generationStarted;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
stage = "retrieval";
|
|
123
|
+
const { manager } = await runtime.getMemorySearchManager({ cfg: api.config, agentId });
|
|
124
|
+
if (signal.aborted)
|
|
125
|
+
return;
|
|
126
|
+
if (!manager) {
|
|
127
|
+
reason = "unavailable";
|
|
128
|
+
diagnostics?.record(agentId, "memory", "unavailable");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const retrievalStarted = performance.now();
|
|
132
|
+
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 });
|
|
133
|
+
if (signal.aborted)
|
|
134
|
+
return;
|
|
135
|
+
measurement.retrievalMs = performance.now() - retrievalStarted;
|
|
136
|
+
measurement.candidates = hits.length;
|
|
137
|
+
const candidates = [];
|
|
138
|
+
for (const hit of hits) {
|
|
139
|
+
// Enforce scope again before sending anything to the external judge.
|
|
140
|
+
if (!corpora.includes(hit.corpus))
|
|
141
|
+
continue;
|
|
142
|
+
const excerpt = hit.snippet.trim();
|
|
143
|
+
// Retrieval bounds context around a complete match. Never replace it
|
|
144
|
+
// with a prefix if a manager returns an oversized result.
|
|
145
|
+
if (excerpt.length > MAX_EXCERPT_CHARS)
|
|
146
|
+
continue;
|
|
147
|
+
const id = fingerprint(excerpt);
|
|
148
|
+
if (!excerpt || state.recent.has(id) || candidates.some(candidate => candidate.id === id ||
|
|
149
|
+
(candidate.hit.path === hit.path && candidate.hit.startLine <= hit.endLine &&
|
|
150
|
+
hit.startLine <= candidate.hit.endLine)))
|
|
151
|
+
continue;
|
|
152
|
+
candidates.push({ hit, excerpt, id });
|
|
153
|
+
if (!queries && candidates.length === 8)
|
|
154
|
+
break;
|
|
117
155
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
156
|
+
measurement.eligible = candidates.length;
|
|
157
|
+
measurement.outcome = "empty";
|
|
158
|
+
if (!candidates.length) {
|
|
159
|
+
reason = "no_candidates";
|
|
160
|
+
diagnostics?.record(agentId, "memory", "no_candidates");
|
|
161
|
+
return;
|
|
122
162
|
}
|
|
163
|
+
stage = "judgment";
|
|
164
|
+
const judgeStarted = performance.now();
|
|
165
|
+
const judgeConversation = conversation ? { ...conversation, truncated: event.messages.length > conversation.history.length }
|
|
166
|
+
: memoryConversation(event.prompt, event.messages);
|
|
167
|
+
// Every eligible passage gets its own request; no request sees another candidate.
|
|
168
|
+
const judged = (await Promise.all(candidates.map(async (candidate, candidateIndex) => {
|
|
169
|
+
const requestStarted = performance.now();
|
|
170
|
+
const fields = { stage: "judgment", candidateIndex,
|
|
171
|
+
timeoutMs: typesafe.timeoutMs };
|
|
172
|
+
try {
|
|
173
|
+
const { hit, excerpt } = candidate;
|
|
174
|
+
const [probability] = await judgeTypeSafeMemories({
|
|
175
|
+
apiKey, timeoutMs: typesafe.timeoutMs, signal,
|
|
176
|
+
conversation: judgeConversation,
|
|
177
|
+
candidates: [{
|
|
178
|
+
excerpt, corpus: hit.corpus, ...(hit.messageTimestamp ? { messageTimestamp: hit.messageTimestamp } : {}),
|
|
179
|
+
}],
|
|
180
|
+
});
|
|
181
|
+
if (signal.aborted)
|
|
182
|
+
return [];
|
|
183
|
+
requestsSucceeded++;
|
|
184
|
+
log("info", "candidate_completed", { ...fields, elapsedMs: performance.now() - requestStarted });
|
|
185
|
+
// Failed candidates have no score, not zero. Keep each score with its own source.
|
|
186
|
+
return [{ ...candidate, probability }];
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (signal.aborted)
|
|
190
|
+
return [];
|
|
191
|
+
requestsFailed++;
|
|
192
|
+
diagnostics?.record(agentId, "memory", "judge_candidate_failed");
|
|
193
|
+
log("warn", "candidate_failed", { ...fields, ...failureFields(error), elapsedMs: performance.now() - requestStarted });
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
}))).flat();
|
|
197
|
+
if (signal.aborted || sessions.get(key) !== state)
|
|
198
|
+
return;
|
|
199
|
+
measurement.judgeMs = performance.now() - judgeStarted;
|
|
200
|
+
if (!await gate || signal.aborted || sessions.get(key) !== state)
|
|
201
|
+
return;
|
|
202
|
+
if (!judged.length) {
|
|
203
|
+
reason = "all_candidates_failed";
|
|
204
|
+
measurement.outcome = "failed";
|
|
205
|
+
diagnostics?.record(agentId, "memory", "failed");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const ranked = judged
|
|
209
|
+
.filter(candidate => candidate.probability >= config.minUsefulness)
|
|
210
|
+
.sort((a, b) => b.probability - a.probability)
|
|
211
|
+
.slice(0, 4);
|
|
212
|
+
let selected = ranked.slice(0, config.maxHints);
|
|
213
|
+
if (!selected.length) {
|
|
214
|
+
reason = "rejected";
|
|
215
|
+
diagnostics?.record(agentId, "memory", "rejected");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (config.complementaryHints && config.maxHints > 1 && ranked.length > 1) {
|
|
219
|
+
stage = "redundancy";
|
|
220
|
+
try {
|
|
221
|
+
const pairs = await reviewMemoryRedundancy({ apiKey, timeoutMs: typesafe.timeoutMs, signal,
|
|
222
|
+
excerpts: ranked.map(candidate => candidate.excerpt) });
|
|
223
|
+
selected = complementaryIndices(ranked.length, pairs, config.maxHints).map(index => ranked[index]);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
// Preserve the original useful candidates when the optional refinement is unavailable.
|
|
227
|
+
if (!signal.aborted) {
|
|
228
|
+
diagnostics?.record(agentId, "memory", "redundancy_unavailable");
|
|
229
|
+
log("warn", "redundancy_unavailable", failureFields(error));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (signal.aborted || sessions.get(key) !== state)
|
|
234
|
+
return;
|
|
235
|
+
stage = "render";
|
|
236
|
+
const hints = selected.map(({ hit, excerpt }) => ({
|
|
237
|
+
source: hit.path, lines: `${hit.startLine}-${hit.endLine}`, body: excerpt,
|
|
238
|
+
}));
|
|
239
|
+
// Bound the complete injected payload, including source metadata.
|
|
240
|
+
// Preserve exact JSON values while keeping source text inside its prompt tags.
|
|
241
|
+
const rendered = JSON.stringify(hints).replace(/</gu, "\\u003c").replace(/>/gu, "\\u003e");
|
|
242
|
+
if (rendered.length > 5000) {
|
|
243
|
+
reason = "payload_limit";
|
|
244
|
+
diagnostics?.record(agentId, "memory", "payload_limit");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
for (const candidate of selected)
|
|
248
|
+
state.recent.set(candidate.id, state.turn);
|
|
249
|
+
diagnostics?.record(agentId, "memory", "emitted");
|
|
250
|
+
const appendContext = `<memory>\n${rendered}\n</memory>`;
|
|
251
|
+
measurement.outcome = "ok";
|
|
252
|
+
reason = "emitted";
|
|
253
|
+
measurement.results = selected.length;
|
|
254
|
+
measurement.contextChars = appendContext.length;
|
|
255
|
+
return { appendContext };
|
|
256
|
+
};
|
|
257
|
+
const [, result] = await Promise.all([gate, retrieveAndJudge()]);
|
|
258
|
+
return result;
|
|
259
|
+
};
|
|
260
|
+
// Prompt rebuilds need the same context, not another retrieval or an empty hook result.
|
|
261
|
+
state.result = (async () => {
|
|
262
|
+
try {
|
|
263
|
+
return await Promise.race([run(), aborted]);
|
|
123
264
|
}
|
|
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");
|
|
265
|
+
catch (error) {
|
|
266
|
+
reason = "stage_failed";
|
|
267
|
+
measurement.outcome = "failed";
|
|
268
|
+
if (!signal.aborted) {
|
|
269
|
+
diagnostics?.record(agentId, "memory", "failed");
|
|
270
|
+
log("warn", "stage_failed", failureFields(error));
|
|
271
|
+
}
|
|
135
272
|
return;
|
|
136
273
|
}
|
|
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
|
-
});
|
|
274
|
+
finally {
|
|
275
|
+
measurement.elapsedMs = performance.now() - started;
|
|
276
|
+
if (signal.aborted) {
|
|
277
|
+
measurement.outcome = gateFailed ? "failed" : recallRejected ? "skipped" : timedOut ? "timed_out" : "cancelled";
|
|
278
|
+
reason = gateFailed ? "recall_failed" : recallRejected ? "recall_not_needed" : timedOut ? "deadline" : "cancelled";
|
|
279
|
+
}
|
|
280
|
+
diagnostics?.measureMemory(agentId, measurement);
|
|
281
|
+
log("info", "completed", { ...measurement, reason, recallProbability, queryCount, requestsSucceeded, requestsFailed,
|
|
282
|
+
judgedCandidates: requestsSucceeded, partial: requestsSucceeded > 0 && requestsFailed > 0, timeoutMs: config.timeoutMs,
|
|
283
|
+
requestTimeoutMs: typesafe.timeoutMs });
|
|
284
|
+
if (signal.aborted && !recallRejected && !gateFailed)
|
|
285
|
+
diagnostics?.record(agentId, "memory", timedOut ? "timed_out" : "cancelled");
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
signal.removeEventListener("abort", onAbort);
|
|
288
|
+
// Stop sibling provider/worker work after an error or an empty early result too.
|
|
289
|
+
state.controller.abort();
|
|
290
|
+
}
|
|
291
|
+
})();
|
|
292
|
+
return state.result;
|
|
293
|
+
};
|
|
168
294
|
api.on("session_end", (event, context) => {
|
|
169
295
|
for (const [key, state] of sessions) {
|
|
170
296
|
if (context.agentId && state.agentId !== context.agentId)
|
|
@@ -177,8 +303,10 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
177
303
|
}
|
|
178
304
|
});
|
|
179
305
|
api.on("gateway_stop", () => {
|
|
306
|
+
generator?.close();
|
|
180
307
|
for (const state of sessions.values())
|
|
181
308
|
state.controller.abort();
|
|
182
309
|
sessions.clear();
|
|
183
310
|
});
|
|
311
|
+
return beforePrompt;
|
|
184
312
|
}
|