openclaw-db9-audit 0.1.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/index.ts +22 -0
- package/openclaw.plugin.json +28 -0
- package/package.json +40 -0
- package/src/bootstrap.ts +277 -0
- package/src/cli.ts +362 -0
- package/src/config.ts +53 -0
- package/src/control-plane.ts +170 -0
- package/src/event-log-sync.ts +166 -0
- package/src/fs-client.ts +317 -0
- package/src/postgres.ts +430 -0
- package/src/redact.ts +37 -0
- package/src/run-tracker.ts +158 -0
- package/src/service.ts +356 -0
- package/src/session-store.ts +150 -0
- package/src/state-store.ts +165 -0
- package/src/transcript-sync.ts +514 -0
- package/src/types.ts +215 -0
- package/src/utils.ts +277 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import type { PluginLogger } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { redactUnknown } from "./redact.js";
|
|
4
|
+
import { Db9AuditSessionLookup } from "./session-store.js";
|
|
5
|
+
import type {
|
|
6
|
+
Db9AuditDiagnosticEvent,
|
|
7
|
+
Db9AuditRedactConfig,
|
|
8
|
+
TranscriptFileOffset,
|
|
9
|
+
TranscriptMessageRecord,
|
|
10
|
+
TranscriptSessionHeader,
|
|
11
|
+
TranscriptSyncBatch,
|
|
12
|
+
TranscriptSyncResult,
|
|
13
|
+
TranscriptSyncSessionMeta,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import {
|
|
16
|
+
coerceIsoTimestamp,
|
|
17
|
+
extractErrorMessage,
|
|
18
|
+
isRecord,
|
|
19
|
+
normalizeSessionPath,
|
|
20
|
+
safeJsonParse,
|
|
21
|
+
} from "./utils.js";
|
|
22
|
+
|
|
23
|
+
export type TranscriptSyncStore = {
|
|
24
|
+
getOffset: (sessionFile: string) => Promise<TranscriptFileOffset | null>;
|
|
25
|
+
syncTranscriptBatch: (batch: TranscriptSyncBatch) => Promise<TranscriptSyncResult>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function resolveSessionKey(params: { sessionKey?: string | undefined; sessionFile: string }): {
|
|
29
|
+
sessionKey: string;
|
|
30
|
+
derived: boolean;
|
|
31
|
+
} {
|
|
32
|
+
const sessionKey = params.sessionKey?.trim();
|
|
33
|
+
if (sessionKey) {
|
|
34
|
+
return { sessionKey, derived: false };
|
|
35
|
+
}
|
|
36
|
+
return { sessionKey: `file:${normalizeSessionPath(params.sessionFile)}`, derived: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeSource(role: string | undefined, record: Record<string, unknown>): string {
|
|
40
|
+
if (role) {
|
|
41
|
+
return role;
|
|
42
|
+
}
|
|
43
|
+
const type = typeof record.type === "string" ? record.type.trim() : "";
|
|
44
|
+
return type ? "meta" : "unknown";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extractTextFromContent(value: unknown): string | undefined {
|
|
48
|
+
if (typeof value === "string") {
|
|
49
|
+
return value.trim() || undefined;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
const texts = value
|
|
53
|
+
.map((entry) => {
|
|
54
|
+
if (typeof entry === "string") {
|
|
55
|
+
return entry.trim();
|
|
56
|
+
}
|
|
57
|
+
if (!isRecord(entry)) {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
if (typeof entry.text === "string") {
|
|
61
|
+
return entry.text.trim();
|
|
62
|
+
}
|
|
63
|
+
if (typeof entry.content === "string") {
|
|
64
|
+
return entry.content.trim();
|
|
65
|
+
}
|
|
66
|
+
if (typeof entry.output_text === "string") {
|
|
67
|
+
return entry.output_text.trim();
|
|
68
|
+
}
|
|
69
|
+
if (typeof entry.input_text === "string") {
|
|
70
|
+
return entry.input_text.trim();
|
|
71
|
+
}
|
|
72
|
+
return "";
|
|
73
|
+
})
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
return texts.length > 0 ? texts.join("\n") : undefined;
|
|
76
|
+
}
|
|
77
|
+
if (isRecord(value)) {
|
|
78
|
+
if (typeof value.text === "string" && value.text.trim()) {
|
|
79
|
+
return value.text.trim();
|
|
80
|
+
}
|
|
81
|
+
if (typeof value.content === "string" && value.content.trim()) {
|
|
82
|
+
return value.content.trim();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extractToolName(record: Record<string, unknown>, message: Record<string, unknown> | undefined): string | undefined {
|
|
89
|
+
const candidates = [
|
|
90
|
+
message?.toolName,
|
|
91
|
+
message?.tool_name,
|
|
92
|
+
message?.name,
|
|
93
|
+
record.toolName,
|
|
94
|
+
record.tool_name,
|
|
95
|
+
record.name,
|
|
96
|
+
];
|
|
97
|
+
for (const value of candidates) {
|
|
98
|
+
if (typeof value === "string" && value.trim()) {
|
|
99
|
+
return value.trim();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const content = message?.content;
|
|
103
|
+
if (Array.isArray(content)) {
|
|
104
|
+
for (const entry of content) {
|
|
105
|
+
if (!isRecord(entry)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const candidate = entry.toolName ?? entry.tool_name ?? entry.name;
|
|
109
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
110
|
+
return candidate.trim();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function extractToolCallId(record: Record<string, unknown>, message: Record<string, unknown> | undefined): string | undefined {
|
|
118
|
+
const candidates = [
|
|
119
|
+
message?.toolCallId,
|
|
120
|
+
message?.tool_call_id,
|
|
121
|
+
message?.callId,
|
|
122
|
+
record.toolCallId,
|
|
123
|
+
record.tool_call_id,
|
|
124
|
+
record.callId,
|
|
125
|
+
];
|
|
126
|
+
for (const value of candidates) {
|
|
127
|
+
if (typeof value === "string" && value.trim()) {
|
|
128
|
+
return value.trim();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function extractRunId(record: Record<string, unknown>, message: Record<string, unknown> | undefined): string | undefined {
|
|
135
|
+
const candidates = [record.runId, record.run_id, message?.runId, message?.run_id];
|
|
136
|
+
for (const value of candidates) {
|
|
137
|
+
if (typeof value === "string" && value.trim()) {
|
|
138
|
+
return value.trim();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function extractCreatedAt(record: Record<string, unknown>, message: Record<string, unknown> | undefined): string | undefined {
|
|
145
|
+
return (
|
|
146
|
+
coerceIsoTimestamp(record.timestamp) ??
|
|
147
|
+
coerceIsoTimestamp(message?.timestamp) ??
|
|
148
|
+
coerceIsoTimestamp(record.createdAt) ??
|
|
149
|
+
coerceIsoTimestamp(record.created_at)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type ParsedTranscriptLine =
|
|
154
|
+
| { kind: "header"; header: TranscriptSessionHeader }
|
|
155
|
+
| { kind: "message"; message: TranscriptMessageRecord }
|
|
156
|
+
| { kind: "skip" };
|
|
157
|
+
|
|
158
|
+
export function parseTranscriptRecord(params: {
|
|
159
|
+
record: Record<string, unknown>;
|
|
160
|
+
lineNo: number;
|
|
161
|
+
sessionFile: string;
|
|
162
|
+
sessionMeta: TranscriptSyncSessionMeta;
|
|
163
|
+
redactConfig: Db9AuditRedactConfig;
|
|
164
|
+
}): ParsedTranscriptLine {
|
|
165
|
+
if (params.record.type === "session") {
|
|
166
|
+
return {
|
|
167
|
+
kind: "header",
|
|
168
|
+
header: {
|
|
169
|
+
type: "session",
|
|
170
|
+
...(typeof params.record.version === "string" || typeof params.record.version === "number"
|
|
171
|
+
? { version: params.record.version }
|
|
172
|
+
: {}),
|
|
173
|
+
...(typeof params.record.id === "string" && params.record.id.trim()
|
|
174
|
+
? { id: params.record.id.trim() }
|
|
175
|
+
: {}),
|
|
176
|
+
...(typeof params.record.timestamp === "string" && params.record.timestamp.trim()
|
|
177
|
+
? { timestamp: params.record.timestamp.trim() }
|
|
178
|
+
: {}),
|
|
179
|
+
...(typeof params.record.cwd === "string" && params.record.cwd.trim()
|
|
180
|
+
? { cwd: params.record.cwd.trim() }
|
|
181
|
+
: {}),
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const redactedRecord = redactUnknown(params.record, params.redactConfig);
|
|
187
|
+
if (!isRecord(redactedRecord)) {
|
|
188
|
+
return { kind: "skip" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const rawMessage = isRecord(params.record.message) ? params.record.message : undefined;
|
|
192
|
+
const redactedMessage = isRecord(redactedRecord.message) ? redactedRecord.message : undefined;
|
|
193
|
+
const role = typeof rawMessage?.role === "string" && rawMessage.role.trim() ? rawMessage.role.trim() : undefined;
|
|
194
|
+
const contentValue = redactedMessage?.content;
|
|
195
|
+
const contentText = extractTextFromContent(contentValue);
|
|
196
|
+
const source = normalizeSource(role, params.record);
|
|
197
|
+
const runId = extractRunId(params.record, rawMessage);
|
|
198
|
+
const toolName = extractToolName(params.record, rawMessage);
|
|
199
|
+
const toolCallId = extractToolCallId(params.record, rawMessage);
|
|
200
|
+
const createdAt = extractCreatedAt(params.record, rawMessage);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
kind: "message",
|
|
204
|
+
message: {
|
|
205
|
+
sessionKey: params.sessionMeta.sessionKey,
|
|
206
|
+
agentId: params.sessionMeta.agentId,
|
|
207
|
+
sessionFile: params.sessionFile,
|
|
208
|
+
lineNo: params.lineNo,
|
|
209
|
+
...(runId ? { runId } : {}),
|
|
210
|
+
...(role ? { role } : {}),
|
|
211
|
+
source,
|
|
212
|
+
...(toolName ? { toolName } : {}),
|
|
213
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
214
|
+
...(contentText ? { contentText } : {}),
|
|
215
|
+
...(contentValue !== undefined && typeof contentValue !== "string"
|
|
216
|
+
? { contentJson: contentValue }
|
|
217
|
+
: {}),
|
|
218
|
+
rawJson: redactedRecord,
|
|
219
|
+
...(createdAt ? { createdAt } : {}),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
type JsonlLine = {
|
|
225
|
+
line: string;
|
|
226
|
+
bytes: number;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
async function* readJsonlLines(params: {
|
|
230
|
+
sessionFile: string;
|
|
231
|
+
offset: number;
|
|
232
|
+
chunkSizeBytes?: number | undefined;
|
|
233
|
+
}): AsyncGenerator<JsonlLine> {
|
|
234
|
+
const handle = await fs.promises.open(params.sessionFile, "r");
|
|
235
|
+
try {
|
|
236
|
+
let position = params.offset;
|
|
237
|
+
const chunkSize = Math.max(1024, params.chunkSizeBytes ?? 64 * 1024);
|
|
238
|
+
const buffer = Buffer.alloc(chunkSize);
|
|
239
|
+
|
|
240
|
+
// Buffers for the current line when it spans multiple reads.
|
|
241
|
+
// We only materialize a full line buffer once a trailing \n is observed.
|
|
242
|
+
let carryParts: Buffer[] = [];
|
|
243
|
+
let carryLength = 0;
|
|
244
|
+
|
|
245
|
+
while (true) {
|
|
246
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
247
|
+
if (bytesRead <= 0) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
position += bytesRead;
|
|
251
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
252
|
+
|
|
253
|
+
let start = 0;
|
|
254
|
+
for (let index = 0; index < chunk.length; index += 1) {
|
|
255
|
+
if (chunk[index] !== 0x0a) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const linePart = chunk.subarray(start, index);
|
|
260
|
+
const bytes = carryLength + linePart.length + 1;
|
|
261
|
+
|
|
262
|
+
let lineBuffer: Buffer;
|
|
263
|
+
if (carryLength > 0) {
|
|
264
|
+
carryParts.push(linePart);
|
|
265
|
+
carryLength += linePart.length;
|
|
266
|
+
lineBuffer = Buffer.concat(carryParts, carryLength);
|
|
267
|
+
carryParts = [];
|
|
268
|
+
carryLength = 0;
|
|
269
|
+
} else {
|
|
270
|
+
lineBuffer = linePart;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let end = lineBuffer.length;
|
|
274
|
+
if (end > 0 && lineBuffer[end - 1] === 0x0d) {
|
|
275
|
+
end -= 1;
|
|
276
|
+
}
|
|
277
|
+
const line = end > 0 ? lineBuffer.subarray(0, end).toString("utf8") : "";
|
|
278
|
+
yield { line, bytes };
|
|
279
|
+
|
|
280
|
+
start = index + 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (start < chunk.length) {
|
|
284
|
+
// Copy to avoid holding onto the shared read buffer across iterations.
|
|
285
|
+
const tail = Buffer.from(chunk.subarray(start));
|
|
286
|
+
carryParts.push(tail);
|
|
287
|
+
carryLength += tail.length;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} finally {
|
|
291
|
+
await handle.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export class Db9AuditTranscriptSync {
|
|
296
|
+
private readonly sessionLookup: Db9AuditSessionLookup;
|
|
297
|
+
private readonly redactConfig: Db9AuditRedactConfig;
|
|
298
|
+
private readonly batchSize: number;
|
|
299
|
+
private readonly logger?: PluginLogger | undefined;
|
|
300
|
+
private readonly getStore: () => Promise<TranscriptSyncStore | null>;
|
|
301
|
+
private readonly onDiagnostic?: ((event: Db9AuditDiagnosticEvent) => Promise<void> | void) | undefined;
|
|
302
|
+
private readonly pending = new Set<string>();
|
|
303
|
+
private flushPromise: Promise<{ flushedSessions: number; insertedMessages: number }> | null = null;
|
|
304
|
+
|
|
305
|
+
constructor(params: {
|
|
306
|
+
sessionLookup: Db9AuditSessionLookup;
|
|
307
|
+
redactConfig: Db9AuditRedactConfig;
|
|
308
|
+
batchSize: number;
|
|
309
|
+
logger?: PluginLogger | undefined;
|
|
310
|
+
getStore: () => Promise<TranscriptSyncStore | null>;
|
|
311
|
+
onDiagnostic?: ((event: Db9AuditDiagnosticEvent) => Promise<void> | void) | undefined;
|
|
312
|
+
}) {
|
|
313
|
+
this.sessionLookup = params.sessionLookup;
|
|
314
|
+
this.redactConfig = params.redactConfig;
|
|
315
|
+
this.batchSize = params.batchSize;
|
|
316
|
+
this.logger = params.logger;
|
|
317
|
+
this.getStore = params.getStore;
|
|
318
|
+
this.onDiagnostic = params.onDiagnostic;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
enqueue(sessionFile: string): void {
|
|
322
|
+
this.pending.add(normalizeSessionPath(sessionFile));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async flushPending(): Promise<{ flushedSessions: number; insertedMessages: number }> {
|
|
326
|
+
if (this.flushPromise) {
|
|
327
|
+
return await this.flushPromise;
|
|
328
|
+
}
|
|
329
|
+
this.flushPromise = this.doFlushPending();
|
|
330
|
+
try {
|
|
331
|
+
return await this.flushPromise;
|
|
332
|
+
} finally {
|
|
333
|
+
this.flushPromise = null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private async doFlushPending(): Promise<{ flushedSessions: number; insertedMessages: number }> {
|
|
338
|
+
const store = await this.getStore();
|
|
339
|
+
if (!store || this.pending.size === 0) {
|
|
340
|
+
return { flushedSessions: 0, insertedMessages: 0 };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const sessionFiles = [...this.pending];
|
|
344
|
+
this.pending.clear();
|
|
345
|
+
let flushedSessions = 0;
|
|
346
|
+
let insertedMessages = 0;
|
|
347
|
+
|
|
348
|
+
for (const sessionFile of sessionFiles) {
|
|
349
|
+
try {
|
|
350
|
+
const result = await this.syncSessionFile(sessionFile, { store });
|
|
351
|
+
flushedSessions += 1;
|
|
352
|
+
insertedMessages += result.insertedMessages;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
this.pending.add(sessionFile);
|
|
355
|
+
this.logger?.warn(`db9-audit: transcript sync failed for ${sessionFile}: ${extractErrorMessage(error) ?? String(error)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { flushedSessions, insertedMessages };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async syncSessionFile(
|
|
363
|
+
sessionFile: string,
|
|
364
|
+
options: {
|
|
365
|
+
resetOffsets?: boolean | undefined;
|
|
366
|
+
store?: TranscriptSyncStore | undefined;
|
|
367
|
+
} = {},
|
|
368
|
+
): Promise<TranscriptSyncResult> {
|
|
369
|
+
const normalizedSessionFile = normalizeSessionPath(sessionFile);
|
|
370
|
+
const store = options.store ?? (await this.getStore());
|
|
371
|
+
if (!store) {
|
|
372
|
+
throw new Error("Postgres store is not available");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const lookup = await this.sessionLookup.lookup(normalizedSessionFile);
|
|
376
|
+
const sessionKeyInfo = resolveSessionKey({
|
|
377
|
+
sessionKey: lookup.sessionKey,
|
|
378
|
+
sessionFile: normalizedSessionFile,
|
|
379
|
+
});
|
|
380
|
+
const sessionMeta: TranscriptSyncSessionMeta = {
|
|
381
|
+
sessionKey: sessionKeyInfo.sessionKey,
|
|
382
|
+
agentId: lookup.agentId,
|
|
383
|
+
...(lookup.sessionId ? { sessionId: lookup.sessionId } : {}),
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
let offset = options.resetOffsets ? null : await store.getOffset(normalizedSessionFile);
|
|
387
|
+
const stat = await fs.promises.stat(normalizedSessionFile);
|
|
388
|
+
let resetExistingFile = Boolean(options.resetOffsets);
|
|
389
|
+
if (!resetExistingFile && offset && stat.size < offset.lastOffset) {
|
|
390
|
+
resetExistingFile = true;
|
|
391
|
+
await this.onDiagnostic?.({
|
|
392
|
+
stream: "error",
|
|
393
|
+
sessionKey: sessionMeta.sessionKey,
|
|
394
|
+
agentId: sessionMeta.agentId,
|
|
395
|
+
data: {
|
|
396
|
+
type: "transcript_truncated",
|
|
397
|
+
sessionFile: normalizedSessionFile,
|
|
398
|
+
previousOffset: offset.lastOffset,
|
|
399
|
+
currentSize: stat.size,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (resetExistingFile) {
|
|
405
|
+
offset = null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const startOffset = offset?.lastOffset ?? 0;
|
|
409
|
+
|
|
410
|
+
let currentOffset = resetExistingFile ? 0 : startOffset;
|
|
411
|
+
let lineNo = resetExistingFile ? 0 : (offset?.lastLineNo ?? 0);
|
|
412
|
+
let insertedMessages = 0;
|
|
413
|
+
let sessionHeader: TranscriptSessionHeader | undefined;
|
|
414
|
+
let resetBeforeInsert = resetExistingFile;
|
|
415
|
+
let pendingMessages: TranscriptMessageRecord[] = [];
|
|
416
|
+
let lastFlushedOffset = currentOffset;
|
|
417
|
+
let lastFlushedLineNo = lineNo;
|
|
418
|
+
|
|
419
|
+
const flushBatch = async (): Promise<void> => {
|
|
420
|
+
const batch: TranscriptSyncBatch = {
|
|
421
|
+
sessionFile: normalizedSessionFile,
|
|
422
|
+
sessionMeta: {
|
|
423
|
+
...sessionMeta,
|
|
424
|
+
...(sessionHeader?.id && !sessionMeta.sessionId ? { sessionId: sessionHeader.id } : {}),
|
|
425
|
+
},
|
|
426
|
+
messages: pendingMessages,
|
|
427
|
+
lastOffset: currentOffset,
|
|
428
|
+
lastLineNo: lineNo,
|
|
429
|
+
resetExistingFile: resetBeforeInsert,
|
|
430
|
+
...(sessionHeader ? { sessionHeader } : {}),
|
|
431
|
+
sessionMetaJson: {
|
|
432
|
+
sessionFile: normalizedSessionFile,
|
|
433
|
+
...(lookup.sessionStorePath ? { sessionStorePath: lookup.sessionStorePath } : {}),
|
|
434
|
+
sessionKeyDerived: sessionKeyInfo.derived,
|
|
435
|
+
batchSizeHint: this.batchSize,
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const result = await store.syncTranscriptBatch(batch);
|
|
440
|
+
insertedMessages += result.insertedMessages;
|
|
441
|
+
resetBeforeInsert = false;
|
|
442
|
+
pendingMessages = [];
|
|
443
|
+
lastFlushedOffset = currentOffset;
|
|
444
|
+
lastFlushedLineNo = lineNo;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
for await (const entry of readJsonlLines({
|
|
448
|
+
sessionFile: normalizedSessionFile,
|
|
449
|
+
offset: currentOffset,
|
|
450
|
+
})) {
|
|
451
|
+
currentOffset += entry.bytes;
|
|
452
|
+
lineNo += 1;
|
|
453
|
+
|
|
454
|
+
const trimmed = entry.line.trim();
|
|
455
|
+
if (!trimmed) {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let parsed: unknown;
|
|
460
|
+
try {
|
|
461
|
+
parsed = safeJsonParse(trimmed);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
await this.onDiagnostic?.({
|
|
464
|
+
stream: "error",
|
|
465
|
+
sessionKey: sessionMeta.sessionKey,
|
|
466
|
+
agentId: sessionMeta.agentId,
|
|
467
|
+
data: {
|
|
468
|
+
type: "transcript_parse_error",
|
|
469
|
+
sessionFile: normalizedSessionFile,
|
|
470
|
+
lineNo,
|
|
471
|
+
error: extractErrorMessage(error) ?? String(error),
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (!isRecord(parsed)) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const parsedLine = parseTranscriptRecord({
|
|
481
|
+
record: parsed,
|
|
482
|
+
lineNo,
|
|
483
|
+
sessionFile: normalizedSessionFile,
|
|
484
|
+
sessionMeta,
|
|
485
|
+
redactConfig: this.redactConfig,
|
|
486
|
+
});
|
|
487
|
+
if (parsedLine.kind === "header") {
|
|
488
|
+
sessionHeader = parsedLine.header;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (parsedLine.kind === "message") {
|
|
492
|
+
pendingMessages.push(parsedLine.message);
|
|
493
|
+
if (pendingMessages.length >= this.batchSize) {
|
|
494
|
+
await flushBatch();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (
|
|
500
|
+
resetBeforeInsert ||
|
|
501
|
+
pendingMessages.length > 0 ||
|
|
502
|
+
currentOffset !== lastFlushedOffset ||
|
|
503
|
+
lineNo !== lastFlushedLineNo
|
|
504
|
+
) {
|
|
505
|
+
await flushBatch();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
insertedMessages,
|
|
510
|
+
lastOffset: currentOffset,
|
|
511
|
+
lastLineNo: lineNo,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|