@unblocklabs/unblock-memory 0.7.3 → 0.8.0
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/README.md +10 -10
- package/dist/src/agent-transcript.d.ts +36 -5
- package/dist/src/agent-transcript.js +101 -11
- package/dist/src/contracts.d.ts +1 -1
- package/dist/src/diagnostics.d.ts +5 -1
- package/dist/src/diagnostics.js +3 -0
- package/dist/src/inside-out-sources.js +21 -12
- package/dist/src/manager.d.ts +3 -2
- package/dist/src/manager.js +9 -3
- package/dist/src/memory-search.d.ts +31 -0
- package/dist/src/memory-search.js +61 -0
- package/dist/src/memory-whisperer.d.ts +2 -6
- package/dist/src/memory-whisperer.js +39 -73
- package/dist/src/plugin.js +72 -34
- package/dist/src/response-episodes.d.ts +3 -3
- package/dist/src/response-episodes.js +52 -13
- package/dist/src/response-text.js +13 -0
- package/dist/src/retrieval-telemetry.d.ts +3 -2
- package/dist/src/review-tools.js +1 -1
- package/dist/src/session-sync.d.ts +1 -1
- package/dist/src/session-sync.js +26 -14
- package/dist/src/training-candidates.d.ts +2 -2
- package/dist/src/training-candidates.js +2 -2
- package/dist/src/training-input.d.ts +4 -0
- package/dist/src/training-input.js +8 -6
- package/dist/src/training-queries.d.ts +2 -0
- package/dist/src/training.d.ts +2 -0
- package/dist/src/training.js +6 -1
- package/docs/configuration.md +3 -2
- package/docs/memory-training.md +3 -1
- package/docs/response-audit.md +2 -2
- package/docs/retrieval.md +59 -59
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -2
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { judgeMemoryPassage, memoryUsefulnessRequest } from "./typesafe.js";
|
|
3
1
|
import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
|
|
4
2
|
import { complementaryIndices, reviewMemoryRedundancy } from "./typesafe-review.js";
|
|
5
3
|
import { ApiQueryGenerator, QueryApiError, queryConversation } from "./query-generator.js";
|
|
6
4
|
import { judgeTrainingInput, TRAINING_GATE_THRESHOLD } from "./training-gate.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
function fingerprint(text) {
|
|
10
|
-
return createHash("sha256").update(text.replace(/\s+/gu, " ").trim()).digest("hex");
|
|
11
|
-
}
|
|
5
|
+
import { QueryInputBudgetError } from "./query-contract.js";
|
|
6
|
+
import { searchMemory, memoryPassageId } from "./memory-search.js";
|
|
12
7
|
export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnostics) {
|
|
13
8
|
if (!config.enabled || !typesafe.enabled)
|
|
14
9
|
return;
|
|
@@ -29,7 +24,7 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
29
24
|
const started = performance.now();
|
|
30
25
|
const asOf = new Date().toISOString();
|
|
31
26
|
const measurement = { outcome: "skipped", elapsedMs: 0 };
|
|
32
|
-
let stage = "
|
|
27
|
+
let stage = "input", reason = "completed";
|
|
33
28
|
let recallProbability, queryCount;
|
|
34
29
|
let requestsSucceeded = 0, requestsFailed = 0;
|
|
35
30
|
const log = (level, event, fields) => api.logger[level]("unblock-memory memory_whisperer " + JSON.stringify({ event, agentId, runId, sessionId, stage, ...fields }));
|
|
@@ -56,6 +51,8 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
56
51
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
57
52
|
});
|
|
58
53
|
const run = async () => {
|
|
54
|
+
const conversation = queryConversation(event.prompt, event.messages);
|
|
55
|
+
stage = "credentials";
|
|
59
56
|
const apiKey = await resolveTypeSafeApiKey(typesafe);
|
|
60
57
|
if (signal.aborted)
|
|
61
58
|
return;
|
|
@@ -64,8 +61,6 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
64
61
|
diagnostics?.record(agentId, "memory", "missing_key");
|
|
65
62
|
return;
|
|
66
63
|
}
|
|
67
|
-
stage = "input";
|
|
68
|
-
const conversation = queryConversation(event.prompt, event.messages);
|
|
69
64
|
const gateStarted = performance.now();
|
|
70
65
|
const gateSignal = AbortSignal.any([signal, AbortSignal.timeout(typesafe.timeoutMs)]);
|
|
71
66
|
// Speculation is intentional: neither model generation nor QMD waits for the recall judgment.
|
|
@@ -125,81 +120,46 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
125
120
|
diagnostics?.record(agentId, "memory", "unavailable");
|
|
126
121
|
return;
|
|
127
122
|
}
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
const search = await searchMemory(manager, queries, {
|
|
124
|
+
corpora, signal, apiKey, timeoutMs: typesafe.timeoutMs, conversation, asOf,
|
|
125
|
+
minUsefulness: config.minUsefulness, maxResults: 4, excludedPassages: state.recent,
|
|
126
|
+
onCandidates: observation => {
|
|
127
|
+
measurement.retrievalMs = observation.retrievalMs;
|
|
128
|
+
measurement.candidates = observation.candidates;
|
|
129
|
+
measurement.eligible = observation.eligible;
|
|
130
|
+
measurement.outcome = "empty";
|
|
131
|
+
stage = "judgment";
|
|
132
|
+
},
|
|
133
|
+
onJudgment: ({ candidateIndex, elapsedMs, error }) => {
|
|
134
|
+
const fields = { stage: "judgment", candidateIndex, elapsedMs, timeoutMs: typesafe.timeoutMs };
|
|
135
|
+
if (error !== undefined) {
|
|
136
|
+
requestsFailed++;
|
|
137
|
+
diagnostics?.record(agentId, "memory", "judge_candidate_failed");
|
|
138
|
+
log("warn", "candidate_failed", { ...fields, ...failureFields(error) });
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
requestsSucceeded++;
|
|
142
|
+
log("info", "candidate_completed", fields);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
if (signal.aborted || sessions.get(key) !== state)
|
|
133
147
|
return;
|
|
134
|
-
measurement.
|
|
135
|
-
|
|
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) || duplicateMemoryPassage({ ...hit, text: excerpt }, candidates.map(candidate => ({ ...candidate.hit, text: candidate.excerpt }))))
|
|
148
|
-
continue;
|
|
149
|
-
candidates.push({ hit, excerpt, id });
|
|
150
|
-
}
|
|
151
|
-
measurement.eligible = candidates.length;
|
|
152
|
-
measurement.outcome = "empty";
|
|
153
|
-
if (!candidates.length) {
|
|
148
|
+
measurement.judgeMs = search.judgeMs;
|
|
149
|
+
if (!search.eligible) {
|
|
154
150
|
reason = "no_candidates";
|
|
155
151
|
diagnostics?.record(agentId, "memory", "no_candidates");
|
|
156
152
|
return;
|
|
157
153
|
}
|
|
158
|
-
stage = "judgment";
|
|
159
|
-
const judgeStarted = performance.now();
|
|
160
|
-
// Every eligible passage gets its own request; no request sees another candidate.
|
|
161
|
-
const judged = (await Promise.all(candidates.map(async (candidate, candidateIndex) => {
|
|
162
|
-
const requestStarted = performance.now();
|
|
163
|
-
const fields = { stage: "judgment", candidateIndex,
|
|
164
|
-
timeoutMs: typesafe.timeoutMs };
|
|
165
|
-
try {
|
|
166
|
-
const { hit, excerpt } = candidate;
|
|
167
|
-
const { probability } = await judgeMemoryPassage(memoryUsefulnessRequest(conversation, {
|
|
168
|
-
excerpt, corpus: hit.corpus, sourcePath: hit.path,
|
|
169
|
-
dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
|
|
170
|
-
?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
|
|
171
|
-
}, asOf), { apiKey, timeoutMs: typesafe.timeoutMs, signal });
|
|
172
|
-
if (signal.aborted)
|
|
173
|
-
return [];
|
|
174
|
-
requestsSucceeded++;
|
|
175
|
-
log("info", "candidate_completed", { ...fields, elapsedMs: performance.now() - requestStarted });
|
|
176
|
-
// Failed candidates have no score, not zero. Keep each score with its own source.
|
|
177
|
-
return [{ ...candidate, probability }];
|
|
178
|
-
}
|
|
179
|
-
catch (error) {
|
|
180
|
-
if (signal.aborted)
|
|
181
|
-
return [];
|
|
182
|
-
requestsFailed++;
|
|
183
|
-
diagnostics?.record(agentId, "memory", "judge_candidate_failed");
|
|
184
|
-
log("warn", "candidate_failed", { ...fields, ...failureFields(error), elapsedMs: performance.now() - requestStarted });
|
|
185
|
-
return [];
|
|
186
|
-
}
|
|
187
|
-
}))).flat();
|
|
188
|
-
if (signal.aborted || sessions.get(key) !== state)
|
|
189
|
-
return;
|
|
190
|
-
measurement.judgeMs = performance.now() - judgeStarted;
|
|
191
154
|
if (!await gate || signal.aborted || sessions.get(key) !== state)
|
|
192
155
|
return;
|
|
193
|
-
if (!
|
|
156
|
+
if (!search.requestsSucceeded) {
|
|
194
157
|
reason = "all_candidates_failed";
|
|
195
158
|
measurement.outcome = "failed";
|
|
196
159
|
diagnostics?.record(agentId, "memory", "failed");
|
|
197
160
|
return;
|
|
198
161
|
}
|
|
199
|
-
const ranked =
|
|
200
|
-
.filter(candidate => candidate.probability >= config.minUsefulness)
|
|
201
|
-
.sort((a, b) => b.probability - a.probability)
|
|
202
|
-
.slice(0, 4);
|
|
162
|
+
const ranked = search.results.map(hit => ({ hit, excerpt: hit.snippet, id: memoryPassageId(hit.snippet) }));
|
|
203
163
|
let selected = ranked.slice(0, config.maxHints);
|
|
204
164
|
if (!selected.length) {
|
|
205
165
|
reason = "rejected";
|
|
@@ -254,6 +214,12 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
|
|
|
254
214
|
return await Promise.race([run(), aborted]);
|
|
255
215
|
}
|
|
256
216
|
catch (error) {
|
|
217
|
+
if (error instanceof QueryInputBudgetError) {
|
|
218
|
+
reason = "input_too_large";
|
|
219
|
+
measurement.outcome = "skipped";
|
|
220
|
+
diagnostics?.record(agentId, "memory", "input_too_large");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
257
223
|
reason = "stage_failed";
|
|
258
224
|
measurement.outcome = "failed";
|
|
259
225
|
if (!signal.aborted) {
|
package/dist/src/plugin.js
CHANGED
|
@@ -17,9 +17,13 @@ import { registerResponseAudit } from "./response-runtime.js";
|
|
|
17
17
|
import { registerInsideOut } from "./inside-out-runtime.js";
|
|
18
18
|
import { registerMemoryTraining } from "./training-runtime.js";
|
|
19
19
|
import { resolveTimezone } from "./session-projector.js";
|
|
20
|
+
import { searchMemory } from "./memory-search.js";
|
|
21
|
+
import { prepareQueryConversation, QueryInputBudgetError } from "./query-contract.js";
|
|
22
|
+
import { resolveTypeSafeApiKey } from "./typesafe-client.js";
|
|
20
23
|
import { registerExtraction } from "./extraction-runtime.js";
|
|
21
24
|
const searchParameters = Type.Object({
|
|
22
|
-
|
|
25
|
+
bm25Query: Type.String({ pattern: "\\S", description: "Distinctive names, identifiers and keywords for BM25 retrieval." }),
|
|
26
|
+
vectorQuery: Type.String({ pattern: "\\S", description: "Natural-language question describing the information needed; also the intent used to judge usefulness." }),
|
|
23
27
|
corpora: Type.Optional(Type.Array(Type.String({ pattern: "\\S" }), {
|
|
24
28
|
minItems: 1, description: 'Configured corpus names; default is all non-skill corpora. Use ["all"] alone for explicit all-corpora recall.',
|
|
25
29
|
})),
|
|
@@ -38,7 +42,7 @@ const searchParameters = Type.Object({
|
|
|
38
42
|
conversationId: Type.Optional(Type.String({ pattern: "\\S" })),
|
|
39
43
|
}, { additionalProperties: false, description: "Restricts session documents only; selected file corpora remain eligible. Not an audience access control." })),
|
|
40
44
|
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "Maximum hits; default 5." })),
|
|
41
|
-
|
|
45
|
+
minUsefulness: Type.Optional(Type.Number({ minimum: 0, maximum: 1, description: "Minimum TypeSafe usefulness probability; default 0.7. Not confidence in factual truth." })),
|
|
42
46
|
}, { additionalProperties: false });
|
|
43
47
|
const getParameters = Type.Object({
|
|
44
48
|
path: Type.String({ pattern: "\\S" }),
|
|
@@ -49,45 +53,79 @@ const syncSessionsParameters = Type.Object({
|
|
|
49
53
|
force: Type.Optional(Type.Boolean()),
|
|
50
54
|
}, { additionalProperties: false });
|
|
51
55
|
const syncStatusParameters = Type.Object({}, { additionalProperties: false });
|
|
52
|
-
function createSearchTool(runtime, ctx) {
|
|
56
|
+
function createSearchTool(runtime, ctx, typesafe, diagnostics) {
|
|
53
57
|
const active = getContext(ctx);
|
|
54
58
|
if (!active)
|
|
55
59
|
return null;
|
|
56
60
|
return {
|
|
57
61
|
name: "memory_search",
|
|
58
62
|
label: "Memory Search",
|
|
59
|
-
description: "Search this agent's configured memory corpora with
|
|
63
|
+
description: "Search this agent's configured memory corpora with BM25 and vector queries, then TypeSafe usefulness ranking. Supply keywords in bm25Query and the information needed in vectorQuery; no query generation or recall gate runs. Queries and candidate passages are sent to TypeSafe. Skills are excluded. Session snippets are message arrays; file snippets are strings. Inspect source context with memory_get. Empty results or errors do not prove absence of a fact.",
|
|
60
64
|
parameters: searchParameters,
|
|
61
65
|
async execute(_toolCallId, params, signal) {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
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
|
-
|
|
66
|
+
const started = performance.now();
|
|
67
|
+
const measurement = { outcome: "failed", elapsedMs: 0 };
|
|
68
|
+
try {
|
|
69
|
+
signal?.throwIfAborted();
|
|
70
|
+
const { bm25Query, vectorQuery, corpora, sessionFilter, maxResults, minUsefulness, } = Value.Parse(searchParameters, params);
|
|
71
|
+
const queries = { lex: bm25Query.trim(), vec: vectorQuery.trim() };
|
|
72
|
+
// Reject oversized explicit inputs before credential resolution or any service call.
|
|
73
|
+
prepareQueryConversation([], queries.lex);
|
|
74
|
+
const { conversation } = prepareQueryConversation([], queries.vec);
|
|
75
|
+
const apiKey = await resolveTypeSafeApiKey(typesafe);
|
|
76
|
+
if (!apiKey) {
|
|
77
|
+
measurement.outcome = "skipped";
|
|
78
|
+
return jsonResult({ results: [], error: "TypeSafe API key not configured or TypeSafe disabled" });
|
|
79
|
+
}
|
|
80
|
+
const { manager, error } = await runtime.getMemorySearchManager(active);
|
|
81
|
+
if (!manager)
|
|
82
|
+
return jsonResult({ results: [], error: error ?? "memory unavailable" });
|
|
83
|
+
const search = await searchMemory(manager, queries, {
|
|
84
|
+
corpora: corpora?.map((corpus) => corpus.trim()),
|
|
85
|
+
sessionFilter,
|
|
86
|
+
maxResults,
|
|
87
|
+
minUsefulness,
|
|
88
|
+
apiKey, timeoutMs: typesafe.timeoutMs, conversation,
|
|
89
|
+
signal,
|
|
90
|
+
requestContext: active.requestContext,
|
|
91
|
+
onCandidates: ({ retrievalMs, candidates, eligible }) => {
|
|
92
|
+
Object.assign(measurement, { retrievalMs, candidates, eligible });
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
measurement.judgeMs = search.judgeMs;
|
|
96
|
+
measurement.results = search.results.length;
|
|
97
|
+
measurement.contextChars = search.results.reduce((sum, hit) => sum + hit.snippet.length, 0);
|
|
98
|
+
if (search.eligible && !search.requestsSucceeded)
|
|
99
|
+
return jsonResult({ results: [], error: "All memory usefulness judgments failed" });
|
|
100
|
+
measurement.outcome = search.requestsFailed ? "partial" : search.results.length ? "ok" : "empty";
|
|
101
|
+
// Compact only the public tool response; internal ranking and consumers keep
|
|
102
|
+
// full-precision scores and the host's source/citation compatibility fields.
|
|
103
|
+
const payload = {
|
|
104
|
+
...(search.requestsFailed ? { warning: `${search.requestsFailed} candidate usefulness judgments failed; results are partial` } : {}),
|
|
105
|
+
results: search.results.map(({ source: _source, citation: _citation, session, sessionMessages, ...result }) => ({
|
|
106
|
+
...result,
|
|
107
|
+
snippet: result.corpus === "sessions" ? sessionMessages ?? [{ body: result.snippet, partial: true }] : result.snippet,
|
|
108
|
+
score: Number(result.score.toFixed(2)),
|
|
109
|
+
...(session ? { session: { ...session, startedAt: new Date(session.startedAt).toISOString() } } : {}),
|
|
110
|
+
})),
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
114
|
+
details: payload,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof QueryInputBudgetError)
|
|
119
|
+
measurement.outcome = "skipped";
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
measurement.elapsedMs = performance.now() - started;
|
|
124
|
+
if (signal?.aborted)
|
|
125
|
+
measurement.outcome = signal.reason instanceof Error && signal.reason.name === "TimeoutError"
|
|
126
|
+
? "timed_out" : "cancelled";
|
|
127
|
+
diagnostics.measureSearch(active.agentId, measurement);
|
|
128
|
+
}
|
|
91
129
|
},
|
|
92
130
|
};
|
|
93
131
|
}
|
|
@@ -455,7 +493,7 @@ export function registerUnblockMemory(api) {
|
|
|
455
493
|
skill: registerSkillWhisperer(api, runtime, config.skillWhisperer, config.typesafe, diagnostics),
|
|
456
494
|
people: peopleWhisperer,
|
|
457
495
|
});
|
|
458
|
-
api.registerTool((ctx) => createSearchTool(runtime, ctx), { names: ["memory_search"] });
|
|
496
|
+
api.registerTool((ctx) => createSearchTool(runtime, ctx, config.typesafe, diagnostics), { names: ["memory_search"] });
|
|
459
497
|
api.registerTool((ctx) => createGetTool(runtime, ctx), { names: ["memory_get"] });
|
|
460
498
|
api.registerTool((ctx) => createSyncSessionsTool(runtime, ctx), {
|
|
461
499
|
names: ["memory_sync_sessions"],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ResponseAuditConfig } from "./response-config.js";
|
|
2
|
-
export declare const RESPONSE_EXTRACTOR_VERSION =
|
|
2
|
+
export declare const RESPONSE_EXTRACTOR_VERSION = 6;
|
|
3
3
|
type Row = {
|
|
4
4
|
seq: number;
|
|
5
5
|
eventJson: string;
|
|
@@ -48,12 +48,12 @@ export declare function responseEpisodes(session: ResponseSession, rows: readonl
|
|
|
48
48
|
episodes: ResponseEpisode[];
|
|
49
49
|
coverage: ResponseCoverage;
|
|
50
50
|
};
|
|
51
|
-
/** Bounded, read-only active transcript snapshot;
|
|
51
|
+
/** Bounded, read-only active transcript snapshot; cold history is deferred, inactive branches excluded. */
|
|
52
52
|
export declare class ResponseTranscriptReader {
|
|
53
53
|
#private;
|
|
54
54
|
constructor(path: string, agentId: string);
|
|
55
55
|
sessions(config: ResponseAuditConfig, now: number, after?: string): ResponseSession[];
|
|
56
|
-
/** null = confirmed absent/ineligible; undefined = over budget,
|
|
56
|
+
/** null = confirmed absent/ineligible; undefined = unavailable/over budget, never evidence of deletion. */
|
|
57
57
|
read(input: ResponseSession | string, config: ResponseAuditConfig): (ReturnType<typeof responseEpisodes> & {
|
|
58
58
|
revision: string;
|
|
59
59
|
}) | null | undefined;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { DatabaseSync } from "node:sqlite";
|
|
3
|
-
import {
|
|
3
|
+
import { AgentTranscriptReader } from "./agent-transcript.js";
|
|
4
4
|
import { messageText } from "./whisperer-context.js";
|
|
5
5
|
import { responseUserText } from "./response-text.js";
|
|
6
|
-
export const RESPONSE_EXTRACTOR_VERSION =
|
|
6
|
+
export const RESPONSE_EXTRACTOR_VERSION = 6;
|
|
7
7
|
const MAX_EVENTS = 2000, MAX_SESSION_BYTES = 2_000_000, MAX_EPISODE_CHARS = 24_000;
|
|
8
8
|
function record(v) {
|
|
9
9
|
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
@@ -176,14 +176,15 @@ export function responseEpisodes(session, rows, config) {
|
|
|
176
176
|
}
|
|
177
177
|
return { episodes, coverage };
|
|
178
178
|
}
|
|
179
|
-
/** Bounded, read-only active transcript snapshot;
|
|
179
|
+
/** Bounded, read-only active transcript snapshot; cold history is deferred, inactive branches excluded. */
|
|
180
180
|
export class ResponseTranscriptReader {
|
|
181
181
|
#db;
|
|
182
|
+
#transcripts;
|
|
182
183
|
constructor(path, agentId) {
|
|
183
184
|
this.#db = new DatabaseSync(path, { readOnly: true });
|
|
184
185
|
try {
|
|
185
186
|
this.#db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=1000");
|
|
186
|
-
|
|
187
|
+
this.#transcripts = new AgentTranscriptReader(this.#db, agentId, "Unsupported response-audit transcript schema or agent");
|
|
187
188
|
}
|
|
188
189
|
catch (error) {
|
|
189
190
|
this.#db.close();
|
|
@@ -191,17 +192,55 @@ export class ResponseTranscriptReader {
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
sessions(config, now, after = "") {
|
|
194
|
-
|
|
195
|
+
// Page metadata candidates, then apply the approved-sender filter to decoded
|
|
196
|
+
// events before the audit cap. Unrelated Slack traffic must not hide previews.
|
|
197
|
+
const candidates = this.#db.prepare(`SELECT w.session_id sessionId, COALESCE(w.account_id,c.account_id,'') accountId,
|
|
195
198
|
w.chat_type chatType, w.primary_conversation_id conversationId
|
|
196
199
|
FROM session_windows w JOIN conversations c ON c.conversation_id=w.primary_conversation_id
|
|
197
200
|
WHERE COALESCE(w.channel,c.channel)='slack' AND w.chat_type IN (${config.chatTypes.map(() => "?").join(",")})
|
|
198
201
|
AND w.session_id>?
|
|
199
202
|
AND EXISTS (SELECT 1 FROM transcript_events e JOIN session_transcript_active_events a
|
|
200
|
-
ON a.session_id=e.session_id AND a.event_seq=e.seq WHERE e.session_id=w.session_id AND e.created_at >= ?
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
ON a.session_id=e.session_id AND a.event_seq=e.seq WHERE e.session_id=w.session_id AND e.created_at >= ?)
|
|
204
|
+
ORDER BY w.session_id LIMIT 101`);
|
|
205
|
+
const since = now - config.lookbackDays * 86400_000;
|
|
206
|
+
const sessions = [];
|
|
207
|
+
this.#db.exec("BEGIN");
|
|
208
|
+
try {
|
|
209
|
+
while (sessions.length < 101) {
|
|
210
|
+
const page = candidates.all(...config.chatTypes, after, since);
|
|
211
|
+
for (const session of page) {
|
|
212
|
+
const snapshot = this.#transcripts.read(session.sessionId, { maxEvents: MAX_EVENTS, maxBytes: MAX_SESSION_BYTES });
|
|
213
|
+
// Saved unavailable sessions are reconciled separately by the caller.
|
|
214
|
+
if (snapshot.kind !== "ready")
|
|
215
|
+
continue;
|
|
216
|
+
const approved = snapshot.rows.some(row => {
|
|
217
|
+
if (row.createdAt < since)
|
|
218
|
+
return false;
|
|
219
|
+
let event;
|
|
220
|
+
try {
|
|
221
|
+
event = record(JSON.parse(row.eventJson));
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
const message = record(event?.message), sender = record(message?.__openclaw)?.senderId;
|
|
227
|
+
return event?.type === "message" && message?.role === "user" &&
|
|
228
|
+
typeof sender === "string" && config.senderIds.includes(sender);
|
|
229
|
+
});
|
|
230
|
+
if (approved)
|
|
231
|
+
sessions.push(session);
|
|
232
|
+
if (sessions.length === 101)
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
if (page.length < 101)
|
|
236
|
+
break;
|
|
237
|
+
after = page.at(-1).sessionId;
|
|
238
|
+
}
|
|
239
|
+
return sessions;
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
this.#db.exec("COMMIT");
|
|
243
|
+
}
|
|
205
244
|
}
|
|
206
245
|
read(input, config, previousRevision) {
|
|
207
246
|
this.#db.exec("BEGIN");
|
|
@@ -216,10 +255,10 @@ export class ResponseTranscriptReader {
|
|
|
216
255
|
return null;
|
|
217
256
|
const session = { sessionId, accountId: String(window.accountId),
|
|
218
257
|
conversationId: String(window.conversationId), chatType: String(window.chatType) };
|
|
219
|
-
const
|
|
220
|
-
if (
|
|
258
|
+
const snapshot = this.#transcripts.read(session.sessionId, { maxEvents: MAX_EVENTS, maxBytes: MAX_SESSION_BYTES });
|
|
259
|
+
if (snapshot.kind !== "ready")
|
|
221
260
|
return undefined;
|
|
222
|
-
const rows =
|
|
261
|
+
const rows = snapshot.rows;
|
|
223
262
|
// Exact active content catches in-place edits and branch changes even when writer
|
|
224
263
|
// watermarks are absent. Raw text is never retained in the checkpoint database.
|
|
225
264
|
const revision = hash([RESPONSE_EXTRACTOR_VERSION, session, config.historyMessages, [...config.senderIds].sort(), rows]);
|
|
@@ -43,6 +43,19 @@ export function responseUserText(input, senderId) {
|
|
|
43
43
|
const marker = markers[0];
|
|
44
44
|
contextLimited = meta.history_truncated === true || rest.slice(0, marker.index).includes("Chat history since last reply:");
|
|
45
45
|
text = rest.slice(marker.index + marker[0].length).trim();
|
|
46
|
+
// OpenClaw appends channel metadata after the current Slack message. Only
|
|
47
|
+
// recognize its exact boundary inside an already validated envelope.
|
|
48
|
+
const contexts = [...text.matchAll(/\r?\n\r?\nContext: ⟦openclaw:ctx⟧\r?\n/g)];
|
|
49
|
+
if (contexts.length) {
|
|
50
|
+
if (contexts.length !== 1)
|
|
51
|
+
return undefined;
|
|
52
|
+
const context = contexts[0];
|
|
53
|
+
const suffix = text.slice(context.index + context[0].length);
|
|
54
|
+
if (!suffix.trim() || suffix.includes("⟦openclaw:ctx⟧") || /^Chat history since last reply:/m.test(suffix))
|
|
55
|
+
return undefined;
|
|
56
|
+
text = text.slice(0, context.index).trim();
|
|
57
|
+
contextLimited = true;
|
|
58
|
+
}
|
|
46
59
|
}
|
|
47
60
|
else {
|
|
48
61
|
const from = /^From: [^\r\n]+ \(([^()\r\n]+)\)\r?\n/.exec(text);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
type RetrievalOperation = "vector" | "memoryWhisperer";
|
|
2
|
-
type RetrievalOutcome = "ok" | "empty" | "failed" | "cancelled" | "timed_out" | "skipped";
|
|
1
|
+
type RetrievalOperation = "vector" | "memoryWhisperer" | "memorySearch";
|
|
2
|
+
type RetrievalOutcome = "ok" | "partial" | "empty" | "failed" | "cancelled" | "timed_out" | "skipped";
|
|
3
3
|
declare const fields: readonly ["elapsedMs", "retrievalMs", "judgeMs", "gateMs", "generationMs", "candidates", "eligible", "results", "contextChars"];
|
|
4
4
|
type Field = typeof fields[number];
|
|
5
5
|
export type RetrievalObservation = {
|
|
@@ -18,6 +18,7 @@ export declare class RetrievalTelemetry {
|
|
|
18
18
|
outcomes: {
|
|
19
19
|
ok?: number | undefined;
|
|
20
20
|
cancelled?: number | undefined;
|
|
21
|
+
partial?: number | undefined;
|
|
21
22
|
skipped?: number | undefined;
|
|
22
23
|
failed?: number | undefined;
|
|
23
24
|
empty?: number | undefined;
|
package/dist/src/review-tools.js
CHANGED
|
@@ -32,7 +32,7 @@ export function registerReviewTools(api, runtime, config, diagnostics) {
|
|
|
32
32
|
return null;
|
|
33
33
|
return {
|
|
34
34
|
name: "memory_diagnostics", label: "Memory Diagnostics",
|
|
35
|
-
description: "Read content-free whisperer
|
|
35
|
+
description: "Read content-free manual-search and whisperer telemetry, configuration state, projection version and index readiness. Does not call TypeSafe.",
|
|
36
36
|
parameters: noParameters,
|
|
37
37
|
async execute(_id, params) {
|
|
38
38
|
Value.Parse(noParameters, params);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChatType } from "./config.js";
|
|
2
2
|
import { type SessionMetadata, type SessionProjectionInput, type SessionMessageSpan } from "./session-projector.js";
|
|
3
|
-
export declare const PROJECTOR_VERSION =
|
|
3
|
+
export declare const PROJECTOR_VERSION = 8;
|
|
4
4
|
type IndexedSession = SessionMetadata & {
|
|
5
5
|
sourceGeneration: string;
|
|
6
6
|
maxSeq: number;
|
package/dist/src/session-sync.js
CHANGED
|
@@ -3,10 +3,10 @@ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
|
|
|
3
3
|
import { chmod, mkdir, readFile, rename, unlink, utimes, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { DatabaseSync } from "node:sqlite";
|
|
6
|
-
import {
|
|
6
|
+
import { AgentTranscriptReader, agentTranscriptSchemaVersion, assertAgentTranscriptIdentity } from "./agent-transcript.js";
|
|
7
7
|
import { projectSessionDocument, sessionDocumentPath, } from "./session-projector.js";
|
|
8
8
|
const MANIFEST_VERSION = 1;
|
|
9
|
-
export const PROJECTOR_VERSION =
|
|
9
|
+
export const PROJECTOR_VERSION = 8;
|
|
10
10
|
// Source lives in src/, published code in dist/src/. Read our own pinned dependency
|
|
11
11
|
// metadata, not QMD internals (which may also be substituted by runtime inspectors).
|
|
12
12
|
const sourcePackage = new URL("../package.json", import.meta.url);
|
|
@@ -91,6 +91,7 @@ function readSnapshot(params) {
|
|
|
91
91
|
try {
|
|
92
92
|
db.exec("PRAGMA query_only = ON; PRAGMA busy_timeout = 5000; BEGIN");
|
|
93
93
|
assertSchema(db, params.agentId);
|
|
94
|
+
const transcripts = new AgentTranscriptReader(db, params.agentId);
|
|
94
95
|
const placeholders = params.chatTypes.map(() => "?").join(", ");
|
|
95
96
|
const windows = db.prepare(`
|
|
96
97
|
SELECT
|
|
@@ -104,7 +105,8 @@ function readSnapshot(params) {
|
|
|
104
105
|
COALESCE(window.started_at, window.created_at) AS startedAt,
|
|
105
106
|
rewrite.generation AS sourceGeneration,
|
|
106
107
|
MAX(active.event_seq) AS maxSeq,
|
|
107
|
-
COUNT(active.event_seq) AS activeEventCount
|
|
108
|
+
COUNT(active.event_seq) AS activeEventCount,
|
|
109
|
+
${transcripts.coldSql("window.session_id")} AS cold
|
|
108
110
|
FROM session_windows AS window
|
|
109
111
|
LEFT JOIN conversations AS conversation
|
|
110
112
|
ON conversation.conversation_id = window.primary_conversation_id
|
|
@@ -116,15 +118,9 @@ function readSnapshot(params) {
|
|
|
116
118
|
GROUP BY window.session_id
|
|
117
119
|
ORDER BY window.created_at, window.session_id
|
|
118
120
|
`).all(...params.chatTypes);
|
|
119
|
-
const readEvents = db.prepare(`
|
|
120
|
-
SELECT a.session_id AS sessionId, e.event_json AS eventJson,
|
|
121
|
-
e.created_at AS createdAt
|
|
122
|
-
${ACTIVE_EVENTS_FROM}
|
|
123
|
-
WHERE a.session_id = ?
|
|
124
|
-
ORDER BY a.active_position
|
|
125
|
-
`);
|
|
126
121
|
const events = new Map();
|
|
127
122
|
const changed = new Set();
|
|
123
|
+
const unavailable = new Set();
|
|
128
124
|
for (const window of windows) {
|
|
129
125
|
const metadata = {
|
|
130
126
|
sessionId: window.sessionId,
|
|
@@ -137,7 +133,7 @@ function readSnapshot(params) {
|
|
|
137
133
|
const previous = params.previousManifest.sessions[window.sessionId];
|
|
138
134
|
const documentPath = sessionDocumentPath(metadata);
|
|
139
135
|
const sourceFingerprint = JSON.stringify(window);
|
|
140
|
-
const unchanged = !params.force &&
|
|
136
|
+
const unchanged = !window.cold && !params.force &&
|
|
141
137
|
(previous ? previous.sourceFingerprint === sourceFingerprint &&
|
|
142
138
|
previous.projectorVersion === PROJECTOR_VERSION &&
|
|
143
139
|
previous.documentPath === documentPath &&
|
|
@@ -145,12 +141,22 @@ function readSnapshot(params) {
|
|
|
145
141
|
params.previousManifest.ignoredSessions?.[window.sessionId] === sourceFingerprint);
|
|
146
142
|
if (!unchanged) {
|
|
147
143
|
changed.add(window.sessionId);
|
|
148
|
-
if (!params.metadataOnly)
|
|
149
|
-
|
|
144
|
+
if (!params.metadataOnly) {
|
|
145
|
+
try {
|
|
146
|
+
const snapshot = transcripts.read(window.sessionId);
|
|
147
|
+
if (snapshot.kind === "ready")
|
|
148
|
+
events.set(window.sessionId, snapshot.rows.map(row => ({ ...row, sessionId: window.sessionId })));
|
|
149
|
+
else
|
|
150
|
+
unavailable.add(window.sessionId);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
unavailable.add(window.sessionId);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
150
156
|
}
|
|
151
157
|
}
|
|
152
158
|
db.exec("COMMIT");
|
|
153
|
-
return { windows, events, changed };
|
|
159
|
+
return { windows, events, changed, unavailable };
|
|
154
160
|
}
|
|
155
161
|
catch (error) {
|
|
156
162
|
try {
|
|
@@ -262,6 +268,12 @@ export async function syncSessionProjections(params) {
|
|
|
262
268
|
startedAt: window.startedAt,
|
|
263
269
|
};
|
|
264
270
|
const documentPath = sessionDocumentPath(metadata);
|
|
271
|
+
if (snapshot.unavailable.has(window.sessionId)) {
|
|
272
|
+
counts.failed += 1;
|
|
273
|
+
if (previous)
|
|
274
|
+
sessions[window.sessionId] = previous;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
265
277
|
if (events === undefined) {
|
|
266
278
|
if (previous)
|
|
267
279
|
sessions[window.sessionId] = previous;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { QMDStore } from "@unblocklabs/qmd";
|
|
1
|
+
import type { AllowedDocumentPaths, QMDStore } from "@unblocklabs/qmd";
|
|
2
2
|
import type { QueryLane } from "./query-contract.js";
|
|
3
3
|
/** Runtime and training share the exact ten-candidate recipe, not hybrid ranking. */
|
|
4
|
-
export declare function trainingCandidates(qmd: QMDStore, query: string, collection: string | string[], lane: QueryLane, signal?: AbortSignal): Promise<{
|
|
4
|
+
export declare function trainingCandidates(qmd: QMDStore, query: string, collection: string | string[], lane: QueryLane, signal?: AbortSignal, allowedPaths?: AllowedDocumentPaths): Promise<{
|
|
5
5
|
vector?: {
|
|
6
6
|
score: number;
|
|
7
7
|
rank: number;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Runtime and training share the exact ten-candidate recipe, not hybrid ranking. */
|
|
2
|
-
export async function trainingCandidates(qmd, query, collection, lane, signal) {
|
|
2
|
+
export async function trainingCandidates(qmd, query, collection, lane, signal, allowedPaths) {
|
|
3
3
|
const hits = await qmd.discoverCandidates({ query, lane, collection, limit: 10, signal,
|
|
4
4
|
// Preserve keyword chunk tie-breaking against this lane's own query.
|
|
5
|
-
intent: query });
|
|
5
|
+
intent: query, allowedPaths });
|
|
6
6
|
return hits.map(({ method, rank, score, file, body, bestChunk, bestChunkPos }) => {
|
|
7
7
|
const retrieval = {
|
|
8
8
|
[method]: { score, rank: rank + 1 },
|