@unblocklabs/unblock-memory 0.3.14 → 0.3.16
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 +265 -0
- package/dist/src/abortable.d.ts +2 -0
- package/dist/src/abortable.js +21 -0
- package/dist/src/cluster-review.d.ts +47 -0
- package/dist/src/cluster-review.js +64 -0
- package/dist/src/config.d.ts +7 -0
- package/dist/src/config.js +25 -3
- package/dist/src/curation.js +4 -1
- package/dist/src/diagnostics.d.ts +39 -0
- package/dist/src/diagnostics.js +18 -0
- package/dist/src/evidence-review.d.ts +41 -0
- package/dist/src/evidence-review.js +50 -0
- package/dist/src/manager.d.ts +84 -4
- package/dist/src/manager.js +72 -3
- package/dist/src/memory-whisperer.d.ts +2 -1
- package/dist/src/memory-whisperer.js +45 -9
- package/dist/src/plugin.js +10 -20
- package/dist/src/quality-audit.d.ts +3 -0
- package/dist/src/quality-audit.js +6 -3
- package/dist/src/quality-triage.d.ts +9 -0
- package/dist/src/quality-triage.js +38 -0
- package/dist/src/response-audit.d.ts +87 -0
- package/dist/src/response-audit.js +193 -0
- package/dist/src/response-config.d.ts +13 -0
- package/dist/src/response-config.js +43 -0
- package/dist/src/response-episodes.d.ts +68 -0
- package/dist/src/response-episodes.js +242 -0
- package/dist/src/response-identity.d.ts +15 -0
- package/dist/src/response-identity.js +34 -0
- package/dist/src/response-judge.d.ts +224 -0
- package/dist/src/response-judge.js +248 -0
- package/dist/src/response-memory.d.ts +8 -0
- package/dist/src/response-memory.js +25 -0
- package/dist/src/response-outcome.d.ts +30 -0
- package/dist/src/response-outcome.js +51 -0
- package/dist/src/response-reviews.d.ts +27 -0
- package/dist/src/response-reviews.js +116 -0
- package/dist/src/response-runtime.d.ts +3 -0
- package/dist/src/response-runtime.js +150 -0
- package/dist/src/response-stages.d.ts +184 -0
- package/dist/src/response-stages.js +38 -0
- package/dist/src/response-store.d.ts +180 -0
- package/dist/src/response-store.js +411 -0
- package/dist/src/response-text.d.ts +6 -0
- package/dist/src/response-text.js +37 -0
- package/dist/src/review-tools.d.ts +5 -0
- package/dist/src/review-tools.js +116 -0
- package/dist/src/session-noise.d.ts +20 -0
- package/dist/src/session-noise.js +142 -0
- package/dist/src/session-projector.d.ts +6 -0
- package/dist/src/session-projector.js +16 -0
- package/dist/src/session-sync.d.ts +3 -1
- package/dist/src/session-sync.js +4 -1
- package/dist/src/skill-whisperer.d.ts +2 -1
- package/dist/src/skill-whisperer.js +24 -8
- package/dist/src/tool-context.d.ts +7 -0
- package/dist/src/tool-context.js +17 -0
- package/dist/src/typesafe-review.d.ts +45 -0
- package/dist/src/typesafe-review.js +134 -0
- package/openclaw.plugin.json +36 -1
- package/package.json +2 -2
- package/skills/memory-curator/SKILL.md +11 -0
- package/skills/people-whisperer/SKILL.md +7 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { UnblockMemoryConfig } from "./config.js";
|
|
2
|
+
import type { ResolvedSource } from "./sources.js";
|
|
3
|
+
export declare function responseCohort(config: UnblockMemoryConfig["responseAudit"]): string;
|
|
4
|
+
export type ResponseAuditOptions = {
|
|
5
|
+
agentId: string;
|
|
6
|
+
databasePath: string;
|
|
7
|
+
storePath: string;
|
|
8
|
+
indexPath: string;
|
|
9
|
+
config: UnblockMemoryConfig;
|
|
10
|
+
sources: readonly ResolvedSource[];
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
peoplePath?: string;
|
|
14
|
+
};
|
|
15
|
+
/** All inference is outside memory's mutation queue and outside transcript DB transactions. */
|
|
16
|
+
export declare function auditResponses(options: ResponseAuditOptions): Promise<{
|
|
17
|
+
status: "disabled";
|
|
18
|
+
reason?: undefined;
|
|
19
|
+
cohort?: undefined;
|
|
20
|
+
coverage?: undefined;
|
|
21
|
+
} | {
|
|
22
|
+
status: "unavailable";
|
|
23
|
+
reason: string;
|
|
24
|
+
cohort?: undefined;
|
|
25
|
+
coverage?: undefined;
|
|
26
|
+
} | {
|
|
27
|
+
status: "already_running";
|
|
28
|
+
reason?: undefined;
|
|
29
|
+
cohort?: undefined;
|
|
30
|
+
coverage?: undefined;
|
|
31
|
+
} | {
|
|
32
|
+
status: "ok" | "dry_run";
|
|
33
|
+
cohort: string;
|
|
34
|
+
coverage: {
|
|
35
|
+
sessions: number;
|
|
36
|
+
sessionLimitReached: boolean;
|
|
37
|
+
sessionsOverBudget: number;
|
|
38
|
+
completedResponses: number;
|
|
39
|
+
reconciledSessions: number;
|
|
40
|
+
reconciliationDeferred: number;
|
|
41
|
+
extractedSessions: number;
|
|
42
|
+
unchangedSessions: number;
|
|
43
|
+
stageAttempts: number;
|
|
44
|
+
stageCacheHits: number;
|
|
45
|
+
eligible: number;
|
|
46
|
+
noFeedback: number;
|
|
47
|
+
pendingFeedback: number;
|
|
48
|
+
oversized: number;
|
|
49
|
+
filteredEvents: number;
|
|
50
|
+
outsideLookback: number;
|
|
51
|
+
attempted: number;
|
|
52
|
+
evaluated: number;
|
|
53
|
+
cachedOrBackoff: number;
|
|
54
|
+
deferredByLimit: number;
|
|
55
|
+
failed: number;
|
|
56
|
+
stale: number;
|
|
57
|
+
};
|
|
58
|
+
reason?: undefined;
|
|
59
|
+
} | {
|
|
60
|
+
status: "unavailable";
|
|
61
|
+
reason: string;
|
|
62
|
+
cohort: string;
|
|
63
|
+
coverage: {
|
|
64
|
+
sessions: number;
|
|
65
|
+
sessionLimitReached: boolean;
|
|
66
|
+
sessionsOverBudget: number;
|
|
67
|
+
completedResponses: number;
|
|
68
|
+
reconciledSessions: number;
|
|
69
|
+
reconciliationDeferred: number;
|
|
70
|
+
extractedSessions: number;
|
|
71
|
+
unchangedSessions: number;
|
|
72
|
+
stageAttempts: number;
|
|
73
|
+
stageCacheHits: number;
|
|
74
|
+
eligible: number;
|
|
75
|
+
noFeedback: number;
|
|
76
|
+
pendingFeedback: number;
|
|
77
|
+
oversized: number;
|
|
78
|
+
filteredEvents: number;
|
|
79
|
+
outsideLookback: number;
|
|
80
|
+
attempted: number;
|
|
81
|
+
evaluated: number;
|
|
82
|
+
cachedOrBackoff: number;
|
|
83
|
+
deferredByLimit: number;
|
|
84
|
+
failed: number;
|
|
85
|
+
stale: number;
|
|
86
|
+
};
|
|
87
|
+
}>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { abortable } from "./abortable.js";
|
|
3
|
+
import { resolveTypeSafeApiKey } from "./typesafe.js";
|
|
4
|
+
import { RESPONSE_EXTRACTOR_VERSION, ResponseTranscriptReader } from "./response-episodes.js";
|
|
5
|
+
import { RESPONSE_RUBRIC_VERSION, RESPONSE_STAGE_VERSIONS, judgeMemoryOpportunity } from "./response-judge.js";
|
|
6
|
+
import { ResponseAuditStore } from "./response-store.js";
|
|
7
|
+
import { responseMemoryCandidates } from "./response-memory.js";
|
|
8
|
+
import { assessResponseStages } from "./response-stages.js";
|
|
9
|
+
import { ResponsePeople } from "./response-identity.js";
|
|
10
|
+
import { TYPESAFE_REVIEW_MODEL } from "./typesafe-review.js";
|
|
11
|
+
export function responseCohort(config) {
|
|
12
|
+
return RESPONSE_RUBRIC_VERSION + ":" + createHash("sha256").update(JSON.stringify({
|
|
13
|
+
extractor: RESPONSE_EXTRACTOR_VERSION, historyMessages: config.historyMessages,
|
|
14
|
+
stages: RESPONSE_STAGE_VERSIONS,
|
|
15
|
+
sentimentEnabled: config.sentimentEnabled,
|
|
16
|
+
senderIds: [...config.senderIds].sort(), chatTypes: [...config.chatTypes].sort(), memoryCorpora: [...config.memoryCorpora].sort(),
|
|
17
|
+
})).digest("hex").slice(0, 16);
|
|
18
|
+
}
|
|
19
|
+
/** All inference is outside memory's mutation queue and outside transcript DB transactions. */
|
|
20
|
+
export async function auditResponses(options) {
|
|
21
|
+
const { config, agentId } = options;
|
|
22
|
+
if (!config.responseAudit.enabled || !config.typesafe.enabled)
|
|
23
|
+
return { status: "disabled" };
|
|
24
|
+
const signal = AbortSignal.any([AbortSignal.timeout(120_000), ...(options.signal ? [options.signal] : [])]);
|
|
25
|
+
const cohort = responseCohort(config.responseAudit);
|
|
26
|
+
let key;
|
|
27
|
+
if (!options.dryRun) {
|
|
28
|
+
try {
|
|
29
|
+
key = await abortable(resolveTypeSafeApiKey(config.typesafe), signal);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { status: "unavailable", reason: "Credentials unavailable or audit cancelled" };
|
|
33
|
+
}
|
|
34
|
+
if (!key)
|
|
35
|
+
return { status: "unavailable", reason: "TypeSafe API key not configured" };
|
|
36
|
+
}
|
|
37
|
+
let reader, store, lease;
|
|
38
|
+
const people = new ResponsePeople(options.peoplePath);
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const coverage = { sessions: 0, sessionLimitReached: false, sessionsOverBudget: 0, completedResponses: 0,
|
|
41
|
+
reconciledSessions: 0, reconciliationDeferred: 0,
|
|
42
|
+
extractedSessions: 0, unchangedSessions: 0,
|
|
43
|
+
stageAttempts: 0, stageCacheHits: 0,
|
|
44
|
+
eligible: 0, noFeedback: 0, pendingFeedback: 0, oversized: 0, filteredEvents: 0, outsideLookback: 0,
|
|
45
|
+
attempted: 0, evaluated: 0, cachedOrBackoff: 0, deferredByLimit: 0, failed: 0, stale: 0 };
|
|
46
|
+
try {
|
|
47
|
+
signal.throwIfAborted();
|
|
48
|
+
if (!options.dryRun) {
|
|
49
|
+
store = new ResponseAuditStore(options.storePath);
|
|
50
|
+
lease = store.acquire(now);
|
|
51
|
+
if (!lease)
|
|
52
|
+
return { status: "already_running" };
|
|
53
|
+
}
|
|
54
|
+
reader = new ResponseTranscriptReader(options.databasePath, agentId);
|
|
55
|
+
const since = now - config.responseAudit.lookbackDays * 86400_000;
|
|
56
|
+
store?.reviews.refresh(cohort, since);
|
|
57
|
+
const cursor = store?.cursor(cohort) ?? "";
|
|
58
|
+
const tracked = new Set(store?.activeSessions(cohort, since) ?? []);
|
|
59
|
+
const sessions = [...new Set([...reader.sessions(config.responseAudit, now, cursor).map(s => s.sessionId),
|
|
60
|
+
...[...tracked].filter(id => id > cursor)])].sort();
|
|
61
|
+
coverage.sessionLimitReached = sessions.length > 100;
|
|
62
|
+
let stoppedForBudget = false;
|
|
63
|
+
for (const session of sessions.slice(0, 100)) {
|
|
64
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
65
|
+
signal.throwIfAborted();
|
|
66
|
+
coverage.sessions++;
|
|
67
|
+
const checkpoint = store?.checkpoint(cohort, session);
|
|
68
|
+
const work = store?.sessionWork(cohort, session, since, now);
|
|
69
|
+
const snapshot = reader.read(session, config.responseAudit, work?.due ? undefined : checkpoint?.revision);
|
|
70
|
+
if (snapshot === undefined) {
|
|
71
|
+
coverage.sessionsOverBudget++;
|
|
72
|
+
coverage.reconciliationDeferred++;
|
|
73
|
+
store?.advance(cohort, session);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (snapshot === null) {
|
|
77
|
+
store?.observe(cohort, session, []);
|
|
78
|
+
store?.checkpointForget(cohort, session);
|
|
79
|
+
coverage.reconciledSessions++;
|
|
80
|
+
store?.advance(cohort, session);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if ("unchanged" in snapshot) {
|
|
84
|
+
coverage.unchangedSessions++;
|
|
85
|
+
coverage.cachedOrBackoff += work?.total ?? 0;
|
|
86
|
+
if (checkpoint)
|
|
87
|
+
for (const key of Object.keys(checkpoint.coverage))
|
|
88
|
+
coverage[key] += checkpoint.coverage[key];
|
|
89
|
+
store?.advance(cohort, session);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
coverage.extractedSessions++;
|
|
93
|
+
if (tracked.has(session))
|
|
94
|
+
coverage.reconciledSessions++;
|
|
95
|
+
for (const key of Object.keys(snapshot.coverage))
|
|
96
|
+
coverage[key] += snapshot.coverage[key];
|
|
97
|
+
const episodes = snapshot.episodes.filter(e => e.timestamp >= now - config.responseAudit.lookbackDays * 86400_000);
|
|
98
|
+
coverage.outsideLookback += snapshot.episodes.length - episodes.length;
|
|
99
|
+
store?.observe(cohort, session, snapshot.episodes);
|
|
100
|
+
store?.checkpointSave(cohort, session, snapshot.revision, snapshot.coverage);
|
|
101
|
+
// Oldest first within a session; new data cannot forever starve its existing backlog.
|
|
102
|
+
for (const e of episodes) {
|
|
103
|
+
signal.throwIfAborted();
|
|
104
|
+
if (options.dryRun)
|
|
105
|
+
continue;
|
|
106
|
+
if (!store.needsJudgment(cohort, e, Date.now())) {
|
|
107
|
+
coverage.cachedOrBackoff++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (coverage.attempted >= config.responseAudit.maxEpisodes) {
|
|
111
|
+
coverage.deferredByLimit++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
coverage.attempted++;
|
|
115
|
+
store.attempted(cohort, e, Date.now());
|
|
116
|
+
try {
|
|
117
|
+
const params = { apiKey: key, timeoutMs: config.typesafe.timeoutMs, signal };
|
|
118
|
+
const judgment = await abortable(assessResponseStages(store, cohort, e, params, config.responseAudit.sentimentEnabled, coverage), signal);
|
|
119
|
+
let memory = { status: "not_requested", candidates: [] };
|
|
120
|
+
if (judgment.feedback.memoryGap.noul >= 0.8 && config.responseAudit.memoryCorpora.length) {
|
|
121
|
+
try {
|
|
122
|
+
const approved = options.sources.filter(s => config.responseAudit.memoryCorpora.includes(s.corpus));
|
|
123
|
+
const candidates = responseMemoryCandidates(options.indexPath, approved, e);
|
|
124
|
+
const memoryKey = createHash("sha256").update(JSON.stringify([TYPESAFE_REVIEW_MODEL, RESPONSE_STAGE_VERSIONS.memory,
|
|
125
|
+
e.id, e.session, e.senderId, e.request, e.answer, e.feedback, candidates,
|
|
126
|
+
[...config.responseAudit.memoryCorpora].sort()])).digest("hex");
|
|
127
|
+
let judged = store.stage(cohort, e, "memory", memoryKey);
|
|
128
|
+
if (judged)
|
|
129
|
+
coverage.stageCacheHits++;
|
|
130
|
+
else {
|
|
131
|
+
store.stageBegin([memoryKey], Date.now());
|
|
132
|
+
coverage.stageAttempts++;
|
|
133
|
+
judged = await abortable(judgeMemoryOpportunity(e, candidates, params), signal);
|
|
134
|
+
store.stageSave(memoryKey, judged, Date.now());
|
|
135
|
+
}
|
|
136
|
+
memory = { status: "checked", candidates: judged };
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
memory = { status: "unavailable", candidates: [] };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
signal.throwIfAborted();
|
|
143
|
+
const fresh = reader.read(session, config.responseAudit);
|
|
144
|
+
if (!fresh?.episodes.some(candidate => candidate.id === e.id && candidate.inputHash === e.inputHash)) {
|
|
145
|
+
coverage.stale++;
|
|
146
|
+
store.stale(cohort, e);
|
|
147
|
+
if (fresh !== undefined) {
|
|
148
|
+
store.observe(cohort, session, fresh?.episodes ?? []);
|
|
149
|
+
store.checkpointForget(cohort, session);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
store.save(cohort, e, { ...judgment, human: people.resolve(e), references: references(e), agentModel: e.model,
|
|
154
|
+
contextLimited: e.contextLimited, memorySearchCalls: e.memorySearchCalls, memory }, Date.now());
|
|
155
|
+
coverage.evaluated++;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
coverage.failed++;
|
|
159
|
+
if (signal.aborted)
|
|
160
|
+
throw new Error("Audit cancelled");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Let stop/cancellation handlers run between bounded synchronous session reads.
|
|
164
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
165
|
+
store?.advance(cohort, session);
|
|
166
|
+
if (coverage.attempted >= config.responseAudit.maxEpisodes && session !== sessions.at(-1)) {
|
|
167
|
+
stoppedForBudget = true;
|
|
168
|
+
coverage.deferredByLimit++; // At least one unvisited session; precise episode count is not yet known.
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!coverage.sessionLimitReached && !stoppedForBudget)
|
|
173
|
+
store?.advance(cohort, "");
|
|
174
|
+
coverage.deferredByLimit = Math.max(coverage.deferredByLimit, store?.pendingWork(cohort, since, Date.now()) ?? 0);
|
|
175
|
+
store?.scan(cohort, coverage, Date.now());
|
|
176
|
+
return { status: options.dryRun ? "dry_run" : "ok", cohort, coverage };
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
store?.scan(cohort, { ...coverage, interrupted: true }, Date.now());
|
|
180
|
+
return { status: "unavailable", reason: "Response audit failed or was cancelled", cohort, coverage };
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
reader?.close();
|
|
184
|
+
if (lease)
|
|
185
|
+
store?.release(lease);
|
|
186
|
+
store?.close();
|
|
187
|
+
people.close();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function references(e) {
|
|
191
|
+
return { sessionId: e.session.sessionId, request: e.request.map(m => m.seq), answer: e.answer.map(m => m.seq),
|
|
192
|
+
feedback: e.feedback.map(m => m.seq), followup: e.followup.messages.map(m => m.seq), inputHash: e.inputHash };
|
|
193
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ChatType, CorpusConfig } from "./config.js";
|
|
2
|
+
export type ResponseAuditConfig = {
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
sentimentEnabled: boolean;
|
|
5
|
+
senderIds: string[];
|
|
6
|
+
chatTypes: ChatType[];
|
|
7
|
+
historyMessages: number;
|
|
8
|
+
lookbackDays: number;
|
|
9
|
+
maxEpisodes: number;
|
|
10
|
+
intervalMinutes: number;
|
|
11
|
+
memoryCorpora: string[];
|
|
12
|
+
};
|
|
13
|
+
export declare function resolveResponseAudit(value: unknown, corpora: readonly CorpusConfig[]): ResponseAuditConfig;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function resolveResponseAudit(value, corpora) {
|
|
2
|
+
const defaults = { enabled: false, sentimentEnabled: true, senderIds: [], chatTypes: ["direct"],
|
|
3
|
+
historyMessages: 6, lookbackDays: 30, maxEpisodes: 20, intervalMinutes: 60, memoryCorpora: [] };
|
|
4
|
+
if (value === undefined)
|
|
5
|
+
return defaults;
|
|
6
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
7
|
+
throw new Error("responseAudit must be an object");
|
|
8
|
+
const v = value;
|
|
9
|
+
for (const key of Object.keys(v))
|
|
10
|
+
if (!Object.hasOwn(defaults, key))
|
|
11
|
+
throw new Error(`unknown responseAudit property: ${key}`);
|
|
12
|
+
const enabled = v.enabled ?? false;
|
|
13
|
+
if (typeof enabled !== "boolean")
|
|
14
|
+
throw new Error("responseAudit.enabled must be boolean");
|
|
15
|
+
const sentimentEnabled = v.sentimentEnabled === undefined ? true : v.sentimentEnabled;
|
|
16
|
+
if (typeof sentimentEnabled !== "boolean")
|
|
17
|
+
throw new Error("responseAudit.sentimentEnabled must be boolean");
|
|
18
|
+
const strings = (key) => {
|
|
19
|
+
const raw = v[key] ?? defaults[key];
|
|
20
|
+
if (!Array.isArray(raw) || raw.length > 50 || !raw.every((x) => typeof x === "string" && !!x.trim())) {
|
|
21
|
+
throw new Error(`responseAudit.${key} must be a bounded string array`);
|
|
22
|
+
}
|
|
23
|
+
return [...new Set(raw.map(x => x.trim()))];
|
|
24
|
+
};
|
|
25
|
+
const senderIds = strings("senderIds"), chatTypes = strings("chatTypes"), memoryCorpora = strings("memoryCorpora");
|
|
26
|
+
if (!chatTypes.length || !chatTypes.every((x) => ["direct", "group", "channel"].includes(x))) {
|
|
27
|
+
throw new Error("responseAudit.chatTypes must specify direct, group or channel");
|
|
28
|
+
}
|
|
29
|
+
if (enabled && !senderIds.length)
|
|
30
|
+
throw new Error("responseAudit requires explicit approved human senderIds");
|
|
31
|
+
if (memoryCorpora.some(name => !corpora.some(c => c.name === name && c.kind === "files"))) {
|
|
32
|
+
throw new Error("responseAudit.memoryCorpora must name configured file corpora");
|
|
33
|
+
}
|
|
34
|
+
const integer = (key, min, max) => {
|
|
35
|
+
const n = v[key] ?? defaults[key];
|
|
36
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < min || n > max)
|
|
37
|
+
throw new Error(`invalid responseAudit.${key}`);
|
|
38
|
+
return n;
|
|
39
|
+
};
|
|
40
|
+
return { enabled, sentimentEnabled, senderIds, chatTypes, memoryCorpora, historyMessages: integer("historyMessages", 0, 20),
|
|
41
|
+
lookbackDays: integer("lookbackDays", 1, 90), maxEpisodes: integer("maxEpisodes", 1, 100),
|
|
42
|
+
intervalMinutes: integer("intervalMinutes", 0, 1440) };
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ResponseAuditConfig } from "./response-config.js";
|
|
2
|
+
export declare const RESPONSE_EXTRACTOR_VERSION = 5;
|
|
3
|
+
type Row = {
|
|
4
|
+
seq: number;
|
|
5
|
+
eventJson: string;
|
|
6
|
+
createdAt: number;
|
|
7
|
+
};
|
|
8
|
+
export type ResponseSession = {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
accountId: string;
|
|
11
|
+
chatType: string;
|
|
12
|
+
conversationId: string;
|
|
13
|
+
};
|
|
14
|
+
type Text = {
|
|
15
|
+
seq: number;
|
|
16
|
+
role: "user" | "assistant";
|
|
17
|
+
text: string;
|
|
18
|
+
};
|
|
19
|
+
export type ResponseEpisode = {
|
|
20
|
+
id: string;
|
|
21
|
+
inputHash: string;
|
|
22
|
+
session: ResponseSession;
|
|
23
|
+
senderId: string;
|
|
24
|
+
thread: string;
|
|
25
|
+
timestamp: number;
|
|
26
|
+
model: string;
|
|
27
|
+
before: Text[];
|
|
28
|
+
request: Text[];
|
|
29
|
+
answer: Text[];
|
|
30
|
+
feedback: Text[];
|
|
31
|
+
followup: {
|
|
32
|
+
status: "pending" | "complete" | "partial" | "unavailable" | "oversized";
|
|
33
|
+
messages: Text[];
|
|
34
|
+
};
|
|
35
|
+
memorySearchCalls: number;
|
|
36
|
+
contextLimited: boolean;
|
|
37
|
+
};
|
|
38
|
+
type ResponseCoverage = {
|
|
39
|
+
completedResponses: number;
|
|
40
|
+
eligible: number;
|
|
41
|
+
noFeedback: number;
|
|
42
|
+
pendingFeedback: number;
|
|
43
|
+
oversized: number;
|
|
44
|
+
filteredEvents: number;
|
|
45
|
+
};
|
|
46
|
+
/** Never deduce human identity from text or the user role alone. */
|
|
47
|
+
export declare function responseEpisodes(session: ResponseSession, rows: readonly Row[], config: ResponseAuditConfig): {
|
|
48
|
+
episodes: ResponseEpisode[];
|
|
49
|
+
coverage: ResponseCoverage;
|
|
50
|
+
};
|
|
51
|
+
/** Bounded, read-only active transcript snapshot; archived/deleted branches are excluded. */
|
|
52
|
+
export declare class ResponseTranscriptReader {
|
|
53
|
+
#private;
|
|
54
|
+
constructor(path: string, agentId: string);
|
|
55
|
+
sessions(config: ResponseAuditConfig, now: number, after?: string): ResponseSession[];
|
|
56
|
+
/** null = confirmed absent/ineligible; undefined = over budget, not evidence of deletion. */
|
|
57
|
+
read(input: ResponseSession | string, config: ResponseAuditConfig): (ReturnType<typeof responseEpisodes> & {
|
|
58
|
+
revision: string;
|
|
59
|
+
}) | null | undefined;
|
|
60
|
+
read(input: ResponseSession | string, config: ResponseAuditConfig, previousRevision: string | undefined): (ReturnType<typeof responseEpisodes> & {
|
|
61
|
+
revision: string;
|
|
62
|
+
}) | {
|
|
63
|
+
unchanged: true;
|
|
64
|
+
revision: string;
|
|
65
|
+
} | null | undefined;
|
|
66
|
+
close(): void;
|
|
67
|
+
}
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { messageText } from "./whisperer-context.js";
|
|
4
|
+
import { responseUserText } from "./response-text.js";
|
|
5
|
+
export const RESPONSE_EXTRACTOR_VERSION = 5;
|
|
6
|
+
const MAX_EVENTS = 2000, MAX_SESSION_BYTES = 2_000_000, MAX_EPISODE_CHARS = 24_000;
|
|
7
|
+
function record(v) {
|
|
8
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
9
|
+
}
|
|
10
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
11
|
+
/** Never deduce human identity from text or the user role alone. */
|
|
12
|
+
export function responseEpisodes(session, rows, config) {
|
|
13
|
+
const coverage = { completedResponses: 0, eligible: 0, noFeedback: 0,
|
|
14
|
+
pendingFeedback: 0, oversized: 0, filteredEvents: 0 };
|
|
15
|
+
const episodes = [];
|
|
16
|
+
let history = [], historyDropped = false;
|
|
17
|
+
let followupTarget;
|
|
18
|
+
const closeFollowup = (safe, partial = false) => {
|
|
19
|
+
if (!followupTarget)
|
|
20
|
+
return;
|
|
21
|
+
if (!safe || !current?.answer.length || (!partial && !current.final))
|
|
22
|
+
followupTarget.followup = { status: "unavailable", messages: [] };
|
|
23
|
+
else if (current.answer.length > 6 || JSON.stringify(current.answer).length > 12_000) {
|
|
24
|
+
followupTarget.followup = { status: "oversized", messages: [] };
|
|
25
|
+
}
|
|
26
|
+
else
|
|
27
|
+
followupTarget.followup = { status: partial ? "partial" : "complete", messages: [...current.answer] };
|
|
28
|
+
followupTarget = undefined;
|
|
29
|
+
};
|
|
30
|
+
let current;
|
|
31
|
+
let pending = [];
|
|
32
|
+
const finish = (closed) => {
|
|
33
|
+
if (!current?.final) {
|
|
34
|
+
current = undefined;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
coverage.completedResponses++;
|
|
38
|
+
if (!current.feedback.length)
|
|
39
|
+
coverage.noFeedback++;
|
|
40
|
+
else if (!closed)
|
|
41
|
+
coverage.pendingFeedback++;
|
|
42
|
+
else if (current.feedback.length > 6 || JSON.stringify(current).length > MAX_EPISODE_CHARS)
|
|
43
|
+
coverage.oversized++;
|
|
44
|
+
else {
|
|
45
|
+
const { final: _final, ...content } = current;
|
|
46
|
+
const id = hash([session.sessionId, current.answer.at(-1).seq]);
|
|
47
|
+
const episode = { id, inputHash: "", session, ...content, followup: { status: "pending", messages: [] } };
|
|
48
|
+
episodes.push(episode);
|
|
49
|
+
followupTarget = episode;
|
|
50
|
+
coverage.eligible++;
|
|
51
|
+
}
|
|
52
|
+
current = undefined;
|
|
53
|
+
};
|
|
54
|
+
const boundary = () => { closeFollowup(false); finish(false); history = []; historyDropped = false; pending = []; };
|
|
55
|
+
const remember = (text) => {
|
|
56
|
+
history.push(text);
|
|
57
|
+
if (history.length > config.historyMessages) {
|
|
58
|
+
history.shift();
|
|
59
|
+
historyDropped = true;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
for (const row of rows) {
|
|
63
|
+
let e;
|
|
64
|
+
try {
|
|
65
|
+
e = record(JSON.parse(row.eventJson));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
coverage.filteredEvents++;
|
|
69
|
+
boundary();
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (e?.type !== "message") {
|
|
73
|
+
// Compaction/context rewrites cannot silently join unrelated transcript segments.
|
|
74
|
+
if (e?.type === "compaction")
|
|
75
|
+
boundary();
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const m = record(e.message), meta = record(m?.__openclaw);
|
|
79
|
+
if (!m) {
|
|
80
|
+
boundary();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (m.role === "toolResult")
|
|
84
|
+
continue; // Tool bodies/thinking are never sent.
|
|
85
|
+
if (m.role === "user") {
|
|
86
|
+
const identity = record(meta?.senderIdentity), transport = record(meta?.transport);
|
|
87
|
+
const senderId = meta?.senderId;
|
|
88
|
+
const human = identity?.senderKind !== "bot" && (identity?.senderKind === "human" || meta?.senderIsOwner === true);
|
|
89
|
+
if (m.provenance !== undefined || !human ||
|
|
90
|
+
typeof senderId !== "string" || !config.senderIds.includes(senderId) ||
|
|
91
|
+
transport?.channel !== "slack" || transport.conversationRef !== session.conversationId) {
|
|
92
|
+
coverage.filteredEvents++;
|
|
93
|
+
boundary();
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const raw = typeof meta?.upstreamUserText === "string" ? meta.upstreamUserText : messageText(m)?.text;
|
|
97
|
+
const visible = raw ? responseUserText(raw, senderId) : undefined;
|
|
98
|
+
if (!visible) {
|
|
99
|
+
coverage.filteredEvents++;
|
|
100
|
+
boundary();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const thread = typeof transport.threadId === "string" ? transport.threadId : "";
|
|
104
|
+
if ((current && (current.senderId !== senderId || current.thread !== thread)) ||
|
|
105
|
+
pending.some(p => p.senderId !== senderId || p.thread !== thread))
|
|
106
|
+
boundary();
|
|
107
|
+
closeFollowup(true);
|
|
108
|
+
if (visible.contextLimited)
|
|
109
|
+
historyDropped = true;
|
|
110
|
+
const text = { seq: row.seq, role: "user", text: visible.text };
|
|
111
|
+
if (current) {
|
|
112
|
+
if (!current.final)
|
|
113
|
+
boundary();
|
|
114
|
+
else
|
|
115
|
+
current.feedback.push(text);
|
|
116
|
+
}
|
|
117
|
+
pending.push({ text, senderId, thread });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (m.role !== "assistant") {
|
|
121
|
+
coverage.filteredEvents++;
|
|
122
|
+
boundary();
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (m.provenance !== undefined || meta?.turnTainted === true) {
|
|
126
|
+
coverage.filteredEvents++;
|
|
127
|
+
boundary();
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (m.provider === "openclaw" && (m.model === "delivery-mirror" || m.model === "gateway-injected")) {
|
|
131
|
+
// Preserve only preceding clean assistant evidence, never the synthetic notice.
|
|
132
|
+
// This is not a completed response and cannot enter the original quality grade.
|
|
133
|
+
closeFollowup(true, true);
|
|
134
|
+
coverage.filteredEvents++;
|
|
135
|
+
boundary();
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (pending.length) {
|
|
139
|
+
const request = pending.map(p => p.text), identity = pending[0];
|
|
140
|
+
finish(true);
|
|
141
|
+
current = { request, before: [...history], answer: [], feedback: [], senderId: identity.senderId,
|
|
142
|
+
thread: identity.thread, final: false, timestamp: row.createdAt, model: "unknown",
|
|
143
|
+
memorySearchCalls: 0, contextLimited: historyDropped };
|
|
144
|
+
pending = [];
|
|
145
|
+
request.forEach(remember);
|
|
146
|
+
}
|
|
147
|
+
if (!current)
|
|
148
|
+
continue;
|
|
149
|
+
if (meta?.turnTainted === true || m.stopReason === "error" || m.stopReason === "aborted") {
|
|
150
|
+
boundary();
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (Array.isArray(m.content)) {
|
|
154
|
+
current.memorySearchCalls += m.content.filter(p => {
|
|
155
|
+
const block = record(p);
|
|
156
|
+
return block?.type === "toolCall" && (block.name === "memory_search" || block.name === "memory_get");
|
|
157
|
+
}).length;
|
|
158
|
+
}
|
|
159
|
+
const text = messageText(m)?.text;
|
|
160
|
+
if (!text || text === "NO_REPLY" || text === "HEARTBEAT_OK" || m.channel === "analysis")
|
|
161
|
+
continue;
|
|
162
|
+
const item = { seq: row.seq, role: "assistant", text };
|
|
163
|
+
current.answer.push(item);
|
|
164
|
+
remember(item);
|
|
165
|
+
current.model = typeof m.model === "string" ? m.model : "unknown";
|
|
166
|
+
current.timestamp = row.createdAt;
|
|
167
|
+
current.final = meta?.runTerminal === true || (m.stopReason === "stop" && m.channel !== "commentary");
|
|
168
|
+
}
|
|
169
|
+
if (current?.final)
|
|
170
|
+
closeFollowup(true);
|
|
171
|
+
finish(false); // No next assistant turn: the feedback block may still grow.
|
|
172
|
+
for (const episode of episodes) {
|
|
173
|
+
const { inputHash: _hash, session: s, ...content } = episode;
|
|
174
|
+
episode.inputHash = hash([RESPONSE_EXTRACTOR_VERSION, s.sessionId, s.accountId, s.chatType, s.conversationId, content]);
|
|
175
|
+
}
|
|
176
|
+
return { episodes, coverage };
|
|
177
|
+
}
|
|
178
|
+
/** Bounded, read-only active transcript snapshot; archived/deleted branches are excluded. */
|
|
179
|
+
export class ResponseTranscriptReader {
|
|
180
|
+
#db;
|
|
181
|
+
constructor(path, agentId) {
|
|
182
|
+
this.#db = new DatabaseSync(path, { readOnly: true });
|
|
183
|
+
try {
|
|
184
|
+
this.#db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=1000");
|
|
185
|
+
const version = this.#db.prepare("PRAGMA user_version").get()?.user_version;
|
|
186
|
+
const meta = this.#db.prepare("SELECT role,agent_id,schema_version FROM schema_meta WHERE meta_key='primary'").get();
|
|
187
|
+
if (![17, 18, 19].includes(Number(version)) || meta?.role !== "agent" || meta.agent_id !== agentId || meta.schema_version !== version) {
|
|
188
|
+
throw new Error("Unsupported response-audit transcript schema or agent");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
this.#db.close();
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
sessions(config, now, after = "") {
|
|
197
|
+
return this.#db.prepare(`SELECT w.session_id sessionId, COALESCE(w.account_id,c.account_id,'') accountId,
|
|
198
|
+
w.chat_type chatType, w.primary_conversation_id conversationId
|
|
199
|
+
FROM session_windows w JOIN conversations c ON c.conversation_id=w.primary_conversation_id
|
|
200
|
+
WHERE COALESCE(w.channel,c.channel)='slack' AND w.chat_type IN (${config.chatTypes.map(() => "?").join(",")})
|
|
201
|
+
AND w.session_id>?
|
|
202
|
+
AND EXISTS (SELECT 1 FROM transcript_events e JOIN session_transcript_active_events a
|
|
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
|
+
AND json_extract(e.event_json,'$.message.role')='user'
|
|
205
|
+
AND json_extract(e.event_json,'$.message.__openclaw.senderId') IN (${config.senderIds.map(() => "?").join(",")}))
|
|
206
|
+
ORDER BY w.session_id LIMIT 101`)
|
|
207
|
+
.all(...config.chatTypes, after, now - config.lookbackDays * 86400_000, ...config.senderIds);
|
|
208
|
+
}
|
|
209
|
+
read(input, config, previousRevision) {
|
|
210
|
+
this.#db.exec("BEGIN");
|
|
211
|
+
try {
|
|
212
|
+
const sessionId = typeof input === "string" ? input : input.sessionId;
|
|
213
|
+
const window = this.#db.prepare(`SELECT w.chat_type chatType,COALESCE(w.channel,c.channel) provider,
|
|
214
|
+
COALESCE(w.account_id,c.account_id,'') accountId,w.primary_conversation_id conversationId
|
|
215
|
+
FROM session_windows w JOIN conversations c ON c.conversation_id=w.primary_conversation_id WHERE w.session_id=?`).get(sessionId);
|
|
216
|
+
if (!window || window.provider !== "slack" || !config.chatTypes.some(type => type === window.chatType) ||
|
|
217
|
+
(typeof input !== "string" && (window.accountId !== input.accountId ||
|
|
218
|
+
window.conversationId !== input.conversationId || window.chatType !== input.chatType)))
|
|
219
|
+
return null;
|
|
220
|
+
const session = { sessionId, accountId: String(window.accountId),
|
|
221
|
+
conversationId: String(window.conversationId), chatType: String(window.chatType) };
|
|
222
|
+
const count = this.#db.prepare(`SELECT COUNT(*) n,COALESCE(SUM(length(e.event_json)),0) bytes
|
|
223
|
+
FROM session_transcript_active_events a JOIN transcript_events e ON e.session_id=a.session_id AND e.seq=a.event_seq
|
|
224
|
+
WHERE a.session_id=?`).get(session.sessionId);
|
|
225
|
+
if (Number(count.n) > MAX_EVENTS || Number(count.bytes) > MAX_SESSION_BYTES)
|
|
226
|
+
return undefined;
|
|
227
|
+
const rows = this.#db.prepare(`SELECT e.seq,e.event_json eventJson,e.created_at createdAt
|
|
228
|
+
FROM session_transcript_active_events a JOIN transcript_events e ON e.session_id=a.session_id AND e.seq=a.event_seq
|
|
229
|
+
WHERE a.session_id=? ORDER BY a.active_position`).all(session.sessionId);
|
|
230
|
+
// Exact active content catches in-place edits and branch changes even when writer
|
|
231
|
+
// watermarks are absent. Raw text is never retained in the checkpoint database.
|
|
232
|
+
const revision = hash([RESPONSE_EXTRACTOR_VERSION, session, config.historyMessages, [...config.senderIds].sort(), rows]);
|
|
233
|
+
if (revision === previousRevision)
|
|
234
|
+
return { unchanged: true, revision };
|
|
235
|
+
return { ...responseEpisodes(session, rows, config), revision };
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
this.#db.exec("COMMIT");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
close() { this.#db.close(); }
|
|
242
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResponseEpisode } from "./response-episodes.js";
|
|
2
|
+
export type ResponseHuman = {
|
|
3
|
+
key: string;
|
|
4
|
+
provider: "slack";
|
|
5
|
+
accountScope: string;
|
|
6
|
+
senderId: string;
|
|
7
|
+
personId: string | null;
|
|
8
|
+
};
|
|
9
|
+
/** Identity is trusted metadata, never inferred from names or transcript text. */
|
|
10
|
+
export declare class ResponsePeople {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(path?: string);
|
|
13
|
+
resolve(e: ResponseEpisode): ResponseHuman;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|