@unblocklabs/unblock-memory 0.7.3 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent-transcript.d.ts +36 -5
- package/dist/src/agent-transcript.js +101 -11
- package/dist/src/inside-out-sources.js +21 -12
- package/dist/src/response-episodes.d.ts +2 -2
- package/dist/src/response-episodes.js +51 -12
- package/dist/src/session-sync.d.ts +1 -1
- package/dist/src/session-sync.js +26 -14
- 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/memory-training.md +3 -1
- package/docs/response-audit.md +2 -2
- package/docs/retrieval.md +10 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
-
/**
|
|
2
|
+
/** Identity fence, not a payload-format allowlist. Required capabilities are checked below. */
|
|
3
3
|
export declare function agentTranscriptSchemaVersion(db: DatabaseSync, errorMessage?: string): number;
|
|
4
4
|
export declare function assertAgentTranscriptIdentity(db: DatabaseSync, agentId: string, version: number, errorMessage?: string): void;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
type AgentTranscriptRow = {
|
|
6
|
+
seq: number;
|
|
7
|
+
eventJson: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
};
|
|
10
|
+
type TranscriptSnapshot = {
|
|
11
|
+
kind: "ready";
|
|
12
|
+
rows: AgentTranscriptRow[];
|
|
13
|
+
} | {
|
|
14
|
+
kind: "cold";
|
|
15
|
+
} | {
|
|
16
|
+
kind: "oversized";
|
|
17
|
+
} | {
|
|
18
|
+
kind: "unreadable";
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* One read-only storage boundary for full-fidelity active transcripts. The public
|
|
22
|
+
* SDK's full reader may restore cold archives; its read-only catalog truncates and
|
|
23
|
+
* redacts content. Neither currently provides the contract these consumers need.
|
|
24
|
+
* Keep payload SQL here until the SDK offers a bounded, full-fidelity read-only API.
|
|
25
|
+
*/
|
|
26
|
+
export declare class AgentTranscriptReader {
|
|
27
|
+
#private;
|
|
28
|
+
readonly db: DatabaseSync;
|
|
29
|
+
constructor(db: DatabaseSync, agentId: string, errorMessage?: string);
|
|
30
|
+
/** Include storage availability in incremental fingerprints, not just hot row counts. */
|
|
31
|
+
coldSql(sessionIdExpression: string): string;
|
|
32
|
+
/** Caller owns a transaction so metadata, cold marker, bounds and rows share one snapshot. */
|
|
33
|
+
read(sessionId: string, limits?: {
|
|
34
|
+
maxEvents?: number;
|
|
35
|
+
maxBytes?: number;
|
|
36
|
+
maxSeq?: number;
|
|
37
|
+
}): TranscriptSnapshot;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import { zstdDecompressSync } from "node:zlib";
|
|
2
|
+
/** Identity fence, not a payload-format allowlist. Required capabilities are checked below. */
|
|
2
3
|
export function agentTranscriptSchemaVersion(db, errorMessage) {
|
|
3
4
|
const version = db.prepare("PRAGMA user_version").get()?.user_version;
|
|
4
|
-
if (typeof version !== "number" || !
|
|
5
|
-
throw new Error(errorMessage ?? "unsupported OpenClaw agent database schema: expected
|
|
5
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 17) {
|
|
6
|
+
throw new Error(errorMessage ?? "unsupported OpenClaw agent database schema: expected 17 or newer, " +
|
|
6
7
|
`found ${String(version ?? "unknown")}`);
|
|
7
8
|
}
|
|
8
9
|
return version;
|
|
@@ -18,12 +19,101 @@ export function assertAgentTranscriptIdentity(db, agentId, version, errorMessage
|
|
|
18
19
|
fail(`OpenClaw agent database belongs to ${String(meta?.agentId)}, not ${agentId}`);
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
|
-
|
|
22
|
-
assertAgentTranscriptIdentity(db, agentId, agentTranscriptSchemaVersion(db, errorMessage), errorMessage);
|
|
23
|
-
}
|
|
24
|
-
export const ACTIVE_EVENTS_FROM = `FROM session_transcript_active_events a
|
|
22
|
+
const ACTIVE_EVENTS_FROM = `FROM session_transcript_active_events a
|
|
25
23
|
JOIN transcript_events e ON e.session_id=a.session_id AND e.seq=a.event_seq`;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
const MAX_COMPRESSED_BYTES = 4 * 1024 * 1024;
|
|
25
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
26
|
+
/**
|
|
27
|
+
* One read-only storage boundary for full-fidelity active transcripts. The public
|
|
28
|
+
* SDK's full reader may restore cold archives; its read-only catalog truncates and
|
|
29
|
+
* redacts content. Neither currently provides the contract these consumers need.
|
|
30
|
+
* Keep payload SQL here until the SDK offers a bounded, full-fidelity read-only API.
|
|
31
|
+
*/
|
|
32
|
+
export class AgentTranscriptReader {
|
|
33
|
+
db;
|
|
34
|
+
#schemaVersion;
|
|
35
|
+
#compressed;
|
|
36
|
+
#cold;
|
|
37
|
+
#utf8Storage;
|
|
38
|
+
constructor(db, agentId, errorMessage) {
|
|
39
|
+
this.db = db;
|
|
40
|
+
const version = agentTranscriptSchemaVersion(db, errorMessage);
|
|
41
|
+
this.#schemaVersion = version;
|
|
42
|
+
this.#utf8Storage = db.prepare("PRAGMA encoding").get()?.encoding === "UTF-8";
|
|
43
|
+
assertAgentTranscriptIdentity(db, agentId, version, errorMessage);
|
|
44
|
+
const required = {
|
|
45
|
+
transcript_events: ["session_id", "seq", "event_json", "created_at"],
|
|
46
|
+
session_transcript_active_events: ["session_id", "active_position", "event_seq"],
|
|
47
|
+
};
|
|
48
|
+
const columns = (table) => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(row => row.name));
|
|
49
|
+
for (const [table, names] of Object.entries(required)) {
|
|
50
|
+
const found = columns(table);
|
|
51
|
+
if (names.some(name => !found.has(name)))
|
|
52
|
+
throw new Error(`Unsupported transcript capabilities: ${table}`);
|
|
53
|
+
}
|
|
54
|
+
const payload = columns("transcript_events");
|
|
55
|
+
this.#compressed = payload.has("event_zstd") && payload.has("event_utf8_bytes");
|
|
56
|
+
if ((version >= 23 || payload.has("event_zstd") || payload.has("event_utf8_bytes")) && !this.#compressed) {
|
|
57
|
+
throw new Error("Unsupported transcript compression capabilities");
|
|
58
|
+
}
|
|
59
|
+
this.#cold = columns("session_transcript_cold_archives").has("session_id");
|
|
60
|
+
if (version >= 20 && !this.#cold)
|
|
61
|
+
throw new Error("Unsupported transcript cold-storage capabilities");
|
|
62
|
+
}
|
|
63
|
+
/** Include storage availability in incremental fingerprints, not just hot row counts. */
|
|
64
|
+
coldSql(sessionIdExpression) {
|
|
65
|
+
return this.#cold ? `EXISTS (SELECT 1 FROM session_transcript_cold_archives cold WHERE cold.session_id=${sessionIdExpression})` : "0";
|
|
66
|
+
}
|
|
67
|
+
/** Caller owns a transaction so metadata, cold marker, bounds and rows share one snapshot. */
|
|
68
|
+
read(sessionId, limits = {}) {
|
|
69
|
+
if (!this.db.isTransaction)
|
|
70
|
+
throw new Error("Transcript read requires a snapshot transaction");
|
|
71
|
+
// Long-lived audit/source readers may outlive a host migration. Reopen before
|
|
72
|
+
// trusting cached capabilities; absence of old hot rows is not deletion proof.
|
|
73
|
+
if (this.db.prepare("PRAGMA user_version").get()?.user_version !== this.#schemaVersion)
|
|
74
|
+
return { kind: "unreadable" };
|
|
75
|
+
if (this.#cold && this.db.prepare("SELECT 1 FROM session_transcript_cold_archives WHERE session_id=?").get(sessionId)) {
|
|
76
|
+
return { kind: "cold" };
|
|
77
|
+
}
|
|
78
|
+
const maxEvents = limits.maxEvents ?? 50_000, maxBytes = limits.maxBytes ?? 64 * 1024 * 1024;
|
|
79
|
+
const maxSeq = limits.maxSeq ?? Number.MAX_SAFE_INTEGER;
|
|
80
|
+
// SQLite's BLOB length measures the database encoding, not decoded UTF-8.
|
|
81
|
+
// Non-UTF-8 stores still enforce the byte budget below during bounded iteration.
|
|
82
|
+
const bytes = !this.#utf8Storage ? "0" : this.#compressed
|
|
83
|
+
? "COALESCE(length(CAST(e.event_json AS BLOB)),e.event_utf8_bytes,0)" : "length(CAST(e.event_json AS BLOB))";
|
|
84
|
+
const size = this.db.prepare(`SELECT COUNT(*) n,COALESCE(SUM(${bytes}),0) bytes
|
|
85
|
+
${ACTIVE_EVENTS_FROM} WHERE a.session_id=? AND e.seq<=?`).get(sessionId, maxSeq);
|
|
86
|
+
if (Number(size.n) > maxEvents || Number(size.bytes) > maxBytes)
|
|
87
|
+
return { kind: "oversized" };
|
|
88
|
+
const rows = [];
|
|
89
|
+
let totalBytes = 0;
|
|
90
|
+
for (const row of this.db.prepare(`SELECT e.seq,e.event_json eventJson,e.created_at createdAt
|
|
91
|
+
${this.#compressed ? ",e.event_zstd compressed,e.event_utf8_bytes rawBytes" : ""}
|
|
92
|
+
${ACTIVE_EVENTS_FROM} WHERE a.session_id=? AND e.seq<=? ORDER BY a.active_position`).iterate(sessionId, maxSeq)) {
|
|
93
|
+
let eventJson;
|
|
94
|
+
if (typeof row.eventJson === "string")
|
|
95
|
+
eventJson = row.eventJson;
|
|
96
|
+
else {
|
|
97
|
+
const compressed = row.compressed, rawBytes = row.rawBytes;
|
|
98
|
+
if (!(compressed instanceof Uint8Array) || !compressed.byteLength || compressed.byteLength > MAX_COMPRESSED_BYTES ||
|
|
99
|
+
typeof rawBytes !== "number" || !Number.isSafeInteger(rawBytes) || rawBytes <= 0 || rawBytes > MAX_COMPRESSED_BYTES) {
|
|
100
|
+
return { kind: "unreadable" };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const decoded = zstdDecompressSync(compressed, { maxOutputLength: rawBytes });
|
|
104
|
+
if (decoded.byteLength !== rawBytes)
|
|
105
|
+
return { kind: "unreadable" };
|
|
106
|
+
eventJson = utf8.decode(decoded);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { kind: "unreadable" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
totalBytes += Buffer.byteLength(eventJson);
|
|
113
|
+
if (totalBytes > maxBytes)
|
|
114
|
+
return { kind: "oversized" };
|
|
115
|
+
rows.push({ seq: Number(row.seq), eventJson, createdAt: Number(row.createdAt) });
|
|
116
|
+
}
|
|
117
|
+
return { kind: "ready", rows };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -5,7 +5,7 @@ import { createZstdDecompress, zstdDecompressSync } from "node:zlib";
|
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { PassThrough } from "node:stream";
|
|
7
7
|
import { pipeline } from "node:stream/promises";
|
|
8
|
-
import {
|
|
8
|
+
import { AgentTranscriptReader } from "./agent-transcript.js";
|
|
9
9
|
/** Files contain branch trees; keep the selected leaf, not abandoned sibling conversations. */
|
|
10
10
|
function selectedBranch(events) {
|
|
11
11
|
const nodes = new Map(events.filter(e => e.id).map(e => [e.id, e]));
|
|
@@ -57,10 +57,11 @@ export async function* readInsideOutSources(paths, errors) {
|
|
|
57
57
|
const db = new DatabaseSync(paths.databasePath, { readOnly: true });
|
|
58
58
|
try {
|
|
59
59
|
db.exec("PRAGMA busy_timeout=1000");
|
|
60
|
-
|
|
60
|
+
const transcripts = new AgentTranscriptReader(db, paths.agentId);
|
|
61
61
|
const sessions = db.prepare(`SELECT w.session_id sessionId, COALESCE(w.channel,c.channel,'local') channel,
|
|
62
62
|
COALESCE(w.account_id,c.account_id,'') account, COALESCE(r.generation,'') revision,
|
|
63
|
-
COALESCE((SELECT MAX(seq) FROM transcript_events WHERE session_id=w.session_id),0) tail
|
|
63
|
+
COALESCE((SELECT MAX(seq) FROM transcript_events WHERE session_id=w.session_id),0) tail,
|
|
64
|
+
${transcripts.coldSql("w.session_id")} cold FROM session_windows w
|
|
64
65
|
LEFT JOIN conversations c ON c.conversation_id=w.primary_conversation_id
|
|
65
66
|
LEFT JOIN transcript_rewrite_watermarks r ON r.session_id=w.session_id
|
|
66
67
|
WHERE (? IS NULL OR w.session_id=?) ORDER BY w.started_at,w.session_id`).all(paths.sessionId ?? null, paths.sessionId ?? null);
|
|
@@ -83,15 +84,23 @@ export async function* readInsideOutSources(paths, errors) {
|
|
|
83
84
|
}
|
|
84
85
|
for (const s of sessions) {
|
|
85
86
|
const sessionId = String(s.sessionId), source = `live:${sessionId}`;
|
|
86
|
-
yield { source, sessionId, revision: String(s.revision), signature: JSON.stringify([s.revision, s.tail]), read: () => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
87
|
+
yield { source, sessionId, revision: String(s.revision), signature: JSON.stringify([s.revision, s.tail, s.cold]), read: () => {
|
|
88
|
+
db.exec("BEGIN");
|
|
89
|
+
try {
|
|
90
|
+
const snapshot = transcripts.read(sessionId, { maxSeq: Number(s.tail) });
|
|
91
|
+
if (snapshot.kind !== "ready")
|
|
92
|
+
throw new Error(`Transcript unavailable: ${snapshot.kind}`);
|
|
93
|
+
const events = snapshot.rows.map(row => {
|
|
94
|
+
const event = JSON.parse(row.eventJson);
|
|
95
|
+
event.seq = row.seq;
|
|
96
|
+
event.timestamp ??= new Date(row.createdAt).toISOString();
|
|
97
|
+
return event;
|
|
98
|
+
});
|
|
99
|
+
return { sessionId, source, events, ...metadata.get(sessionId) };
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
db.exec("COMMIT");
|
|
103
|
+
}
|
|
95
104
|
} };
|
|
96
105
|
}
|
|
97
106
|
}
|
|
@@ -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,6 +1,6 @@
|
|
|
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
6
|
export const RESPONSE_EXTRACTOR_VERSION = 5;
|
|
@@ -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]);
|
|
@@ -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,6 +1,6 @@
|
|
|
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 { conversationUserText } from "./response-text.js";
|
|
6
6
|
import { prepareQueryConversation, QUERY_CONTRACT, QueryInputBudgetError } from "./query-contract.js";
|
|
@@ -133,12 +133,13 @@ export function trainingExamples(rows, renew) {
|
|
|
133
133
|
/** Only active events; no Markdown projections, archived branches, or tool bodies. */
|
|
134
134
|
export class TrainingTranscriptReader {
|
|
135
135
|
#db;
|
|
136
|
+
#transcripts;
|
|
136
137
|
#lineage;
|
|
137
138
|
constructor(path, agentId) {
|
|
138
139
|
this.#db = new DatabaseSync(path, { readOnly: true });
|
|
139
140
|
try {
|
|
140
141
|
this.#db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=1000");
|
|
141
|
-
|
|
142
|
+
this.#transcripts = new AgentTranscriptReader(this.#db, agentId, "Unsupported training transcript schema or agent");
|
|
142
143
|
const columns = this.#db.prepare("PRAGMA table_info(session_windows)").all().map(c => c.name);
|
|
143
144
|
this.#lineage = ["parent_session_key", "spawned_by", "plugin_owner_id", "hook_external_content_source"].every(c => columns.includes(c));
|
|
144
145
|
}
|
|
@@ -161,11 +162,12 @@ export class TrainingTranscriptReader {
|
|
|
161
162
|
/:(?:cron|subagent|heartbeat|hook)(?::|$)/i.test(String(session.session_key)) ||
|
|
162
163
|
session.parent_session_key || session.spawned_by || session.plugin_owner_id || session.hook_external_content_source)
|
|
163
164
|
return null;
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
165
|
+
const snapshot = this.#transcripts.read(sessionId, { maxEvents: 50_000, maxBytes: 32_000_000 });
|
|
166
|
+
if (snapshot.kind === "oversized")
|
|
166
167
|
return { oversized: true };
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
if (snapshot.kind !== "ready")
|
|
169
|
+
return { unavailable: true };
|
|
170
|
+
return trainingExamples(snapshot.rows, renew);
|
|
169
171
|
}
|
|
170
172
|
finally {
|
|
171
173
|
this.#db.exec("COMMIT");
|
|
@@ -34,6 +34,7 @@ export declare function generateTrainingQueries(source: Source, store: TrainingS
|
|
|
34
34
|
sessions: number;
|
|
35
35
|
excludedSessions: number;
|
|
36
36
|
oversizedSessions: number;
|
|
37
|
+
unavailableSessions: number;
|
|
37
38
|
eligible: number;
|
|
38
39
|
users: number;
|
|
39
40
|
filtered: number;
|
|
@@ -79,6 +80,7 @@ export declare function evaluateTrainingQueries(source: Source, store: TrainingS
|
|
|
79
80
|
sessions: number;
|
|
80
81
|
excludedSessions: number;
|
|
81
82
|
oversizedSessions: number;
|
|
83
|
+
unavailableSessions: number;
|
|
82
84
|
eligible: number;
|
|
83
85
|
users: number;
|
|
84
86
|
filtered: number;
|
package/dist/src/training.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export declare function collectTraining(source: Source, store?: TrainingStore, o
|
|
|
13
13
|
sessions: number;
|
|
14
14
|
excludedSessions: number;
|
|
15
15
|
oversizedSessions: number;
|
|
16
|
+
unavailableSessions: number;
|
|
16
17
|
eligible: number;
|
|
17
18
|
users: number;
|
|
18
19
|
filtered: number;
|
|
@@ -39,6 +40,7 @@ export declare function runTraining(source: Source, store: TrainingStore, config
|
|
|
39
40
|
sessions: number;
|
|
40
41
|
excludedSessions: number;
|
|
41
42
|
oversizedSessions: number;
|
|
43
|
+
unavailableSessions: number;
|
|
42
44
|
eligible: number;
|
|
43
45
|
users: number;
|
|
44
46
|
filtered: number;
|
package/dist/src/training.js
CHANGED
|
@@ -5,7 +5,7 @@ import { judgeTrainingInput, TRAINING_GATE_QUESTIONS } from "./training-gate.js"
|
|
|
5
5
|
export function collectTraining(source, store, options = {}) {
|
|
6
6
|
const since = options.since ?? 0, until = options.until ?? Number.MAX_SAFE_INTEGER;
|
|
7
7
|
const reader = new TrainingTranscriptReader(source.databasePath, source.agentId);
|
|
8
|
-
const result = { sessions: 0, excludedSessions: 0, oversizedSessions: 0, eligible: 0,
|
|
8
|
+
const result = { sessions: 0, excludedSessions: 0, oversizedSessions: 0, unavailableSessions: 0, eligible: 0,
|
|
9
9
|
users: 0, filtered: 0, oversized: 0, unanswered: 0, added: 0, changed: 0, unchanged: 0, retired: 0,
|
|
10
10
|
review: [] };
|
|
11
11
|
try {
|
|
@@ -14,6 +14,11 @@ export function collectTraining(source, store, options = {}) {
|
|
|
14
14
|
store?.renew();
|
|
15
15
|
const extracted = reader.read(id, () => store?.renew());
|
|
16
16
|
result.sessions++;
|
|
17
|
+
if (extracted && "unavailable" in extracted) {
|
|
18
|
+
result.unavailableSessions++;
|
|
19
|
+
store?.unavailable(id);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
17
22
|
if (extracted && "oversized" in extracted) {
|
|
18
23
|
result.oversizedSessions++;
|
|
19
24
|
store?.unavailable(id);
|
package/docs/memory-training.md
CHANGED
|
@@ -78,7 +78,9 @@ bytes, so changed corpus content still invalidates caches.
|
|
|
78
78
|
|
|
79
79
|
## Input and historical boundaries
|
|
80
80
|
|
|
81
|
-
Read original agent SQLite active-branch conversations
|
|
81
|
+
Read original agent SQLite active-branch conversations through the shared read-only
|
|
82
|
+
adapter, including compressed payloads, while deferring cold or oversized sessions
|
|
83
|
+
without retiring their previously collected examples. This includes
|
|
82
84
|
direct/group/channel chats, excluding cron, heartbeat, spawned/subagent, hook/plugin
|
|
83
85
|
and untyped diagnostic sessions. Exclude explicit bots, synthetic messages,
|
|
84
86
|
analysis/thinking, errors and tool payloads. Ordinary older users need not have
|
package/docs/response-audit.md
CHANGED
|
@@ -202,8 +202,8 @@ factual verification. Week buckets
|
|
|
202
202
|
may be partial, and several exchanges in one session are not independent. Wilson
|
|
203
203
|
intervals are descriptive, not calibrated confidence about overall agent ability.
|
|
204
204
|
|
|
205
|
-
Each run selects at most 100 recent sessions
|
|
206
|
-
|
|
205
|
+
Each run selects at most 100 recent sessions containing approved-sender messages for inference,
|
|
206
|
+
each at most 2,000 active events/2M decoded UTF-8 bytes; episodes must fit 24K characters and six feedback messages without
|
|
207
207
|
truncating the answer. Coverage counts describe the scanned sessions; only episodes
|
|
208
208
|
within `lookbackDays` are judged. Caps, failures and no-feedback cases remain visible.
|
|
209
209
|
Saved sessions in the report window are also reconciled independently of that
|
package/docs/retrieval.md
CHANGED
|
@@ -155,13 +155,19 @@ fits. The default is `500`; the original semantic chunk is preserved when
|
|
|
155
155
|
neither complete context fits, so expansion never clips the matched evidence.
|
|
156
156
|
Run
|
|
157
157
|
`memory_sync_sessions` to start a refresh, then use `memory_sync_status` to
|
|
158
|
-
check its progress or result. The read-only adapter
|
|
159
|
-
agent
|
|
160
|
-
|
|
158
|
+
check its progress or result. The shared read-only transcript adapter validates
|
|
159
|
+
agent identity and the storage capabilities it needs, rather than rejecting every
|
|
160
|
+
new schema number. It reads both text and bounded Zstd payloads (schema 23+),
|
|
161
|
+
counts decoded UTF-8 bytes for budgets, and preserves previous projections when
|
|
162
|
+
history is cold, oversized, or unreadable. Cold history is deferred until restored
|
|
163
|
+
by OpenClaw; the plugin never restores or migrates the source database. The public
|
|
164
|
+
full-transcript SDK reader currently permits restoration, while its read-only
|
|
165
|
+
catalog redacts and truncates text, so neither supplies this full-fidelity read-only
|
|
166
|
+
contract. Extraction retains its supported host cursor API. Projections are private derived Markdown under the
|
|
161
167
|
agent's `unblock-memory/sessions` state directory and can be rebuilt from
|
|
162
168
|
OpenClaw at any time. Their embedded text contains only `# Transcript` and
|
|
163
169
|
role-labeled, timestamped speaker messages; filtering metadata remains in the
|
|
164
|
-
session manifest. Projection
|
|
170
|
+
session manifest. Projection v8 retains message metadata and exact character
|
|
165
171
|
boundaries there, without duplicating message bodies. Readers use those boundaries
|
|
166
172
|
only when the projection hash matches the indexed document. Older/mismatched snapshots
|
|
167
173
|
use a conservative heading parser that skips code fences and blockquotes; an unfenced
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.4",
|
|
5
5
|
"description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"activation": { "onStartup": true },
|