@yeaft/webchat-agent 1.0.18 → 1.0.20
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/package.json +1 -1
- package/yeaft/cli.js +7 -7
- package/yeaft/debug-trace.js +897 -740
- package/yeaft/session.js +9 -17
- package/yeaft/web-bridge.js +3 -3
package/yeaft/debug-trace.js
CHANGED
|
@@ -1,364 +1,669 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* debug-trace.js —
|
|
2
|
+
* debug-trace.js — file-backed debug trace for Yeaft
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Stores bounded request traces on disk without SQLite. Each request owns one
|
|
5
|
+
* compact JSON file under the session's debug folder:
|
|
6
|
+
* <yeaftDir>/sessions/<sessionId>/debug/requests/<requestKey>/trace.json
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
+
* The file keeps one base request snapshot plus per-loop deltas. This avoids
|
|
9
|
+
* 100-200 tiny loop files and avoids repeating the whole cumulative message
|
|
10
|
+
* array for every loop. Debug history remains best-effort: failures are logged
|
|
11
|
+
* and dropped, never allowed to stop the agent.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
statSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from 'fs';
|
|
24
|
+
import { basename, dirname, extname, join } from 'path';
|
|
11
25
|
import { randomUUID } from 'crypto';
|
|
12
|
-
import { statSync } from 'fs';
|
|
13
|
-
|
|
14
|
-
/** Schema DDL — 3 tables + indexes */
|
|
15
|
-
const SCHEMA = `
|
|
16
|
-
CREATE TABLE IF NOT EXISTS trace_turns (
|
|
17
|
-
id TEXT PRIMARY KEY,
|
|
18
|
-
trace_id TEXT NOT NULL,
|
|
19
|
-
message_id TEXT,
|
|
20
|
-
mode TEXT,
|
|
21
|
-
turn_number INTEGER,
|
|
22
|
-
model TEXT,
|
|
23
|
-
input_tokens INTEGER,
|
|
24
|
-
output_tokens INTEGER,
|
|
25
|
-
cache_read_tokens INTEGER DEFAULT 0,
|
|
26
|
-
cache_write_tokens INTEGER DEFAULT 0,
|
|
27
|
-
stop_reason TEXT,
|
|
28
|
-
latency_ms INTEGER,
|
|
29
|
-
response_text TEXT,
|
|
30
|
-
started_at INTEGER NOT NULL,
|
|
31
|
-
ended_at INTEGER,
|
|
32
|
-
group_id TEXT,
|
|
33
|
-
vp_id TEXT,
|
|
34
|
-
thread_id TEXT,
|
|
35
|
-
system_prompt TEXT,
|
|
36
|
-
messages_json TEXT,
|
|
37
|
-
tool_calls_json TEXT,
|
|
38
|
-
usage_json TEXT,
|
|
39
|
-
ttfb_ms INTEGER,
|
|
40
|
-
raw_request TEXT,
|
|
41
|
-
raw_response TEXT,
|
|
42
|
-
user_prompt TEXT
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
CREATE TABLE IF NOT EXISTS trace_tools (
|
|
46
|
-
id TEXT PRIMARY KEY,
|
|
47
|
-
turn_id TEXT NOT NULL,
|
|
48
|
-
tool_name TEXT NOT NULL,
|
|
49
|
-
tool_input TEXT,
|
|
50
|
-
tool_output TEXT,
|
|
51
|
-
tool_call_id TEXT,
|
|
52
|
-
duration_ms INTEGER,
|
|
53
|
-
is_error INTEGER DEFAULT 0,
|
|
54
|
-
created_at INTEGER NOT NULL,
|
|
55
|
-
FOREIGN KEY (turn_id) REFERENCES trace_turns(id)
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
CREATE TABLE IF NOT EXISTS trace_events (
|
|
59
|
-
id TEXT PRIMARY KEY,
|
|
60
|
-
trace_id TEXT NOT NULL,
|
|
61
|
-
event_type TEXT NOT NULL,
|
|
62
|
-
event_data TEXT,
|
|
63
|
-
created_at INTEGER NOT NULL
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
CREATE INDEX IF NOT EXISTS idx_turns_trace_id ON trace_turns(trace_id);
|
|
67
|
-
CREATE INDEX IF NOT EXISTS idx_turns_message_id ON trace_turns(message_id);
|
|
68
|
-
CREATE INDEX IF NOT EXISTS idx_turns_started_at ON trace_turns(started_at);
|
|
69
|
-
CREATE INDEX IF NOT EXISTS idx_turns_model ON trace_turns(model);
|
|
70
|
-
CREATE INDEX IF NOT EXISTS idx_tools_turn_id ON trace_tools(turn_id);
|
|
71
|
-
CREATE INDEX IF NOT EXISTS idx_tools_name ON trace_tools(tool_name);
|
|
72
|
-
CREATE INDEX IF NOT EXISTS idx_events_trace_id ON trace_events(trace_id);
|
|
73
|
-
CREATE INDEX IF NOT EXISTS idx_events_type ON trace_events(event_type);
|
|
74
|
-
`;
|
|
75
26
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
27
|
+
const TRACE_VERSION = 2;
|
|
28
|
+
const REQUEST_RETENTION = 10;
|
|
29
|
+
const MAX_HISTORY_LIMIT = 10;
|
|
30
|
+
const MAX_DREAM_EVENTS = 100;
|
|
31
|
+
const MAX_TEXT_BYTES = 1024 * 1024;
|
|
32
|
+
const MAX_TOOL_INPUT = 10 * 1024;
|
|
33
|
+
const MAX_INLINE_VALUE_BYTES = 1024 * 1024;
|
|
34
|
+
const MAX_RAW_REQUEST_BYTES = 2 * 1024 * 1024;
|
|
35
|
+
const TRACE_FLUSH_INTERVAL_MS = 5_000;
|
|
36
|
+
const TRACE_FLUSH_DIRTY_LOOPS = 10;
|
|
37
|
+
|
|
38
|
+
function isPlainObject(value) {
|
|
39
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
}
|
|
89
41
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
42
|
+
function safeDirComponent(value, fallback = 'unknown') {
|
|
43
|
+
const raw = String(value || '').trim();
|
|
44
|
+
if (!raw) return fallback;
|
|
45
|
+
return raw.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 120) || fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fileTraceRoot(inputPath) {
|
|
49
|
+
const raw = String(inputPath || '').trim();
|
|
50
|
+
if (!raw) return null;
|
|
51
|
+
// Back-compat: callers and tests historically pass a concrete debug.db
|
|
52
|
+
// path. Do NOT collapse that to dirname(raw), or unrelated temp DB paths all
|
|
53
|
+
// share /tmp/debug and traces bleed across tests/sessions. Explicit dirPath
|
|
54
|
+
// callers pass the Yeaft root and get session-adjacent paths.
|
|
55
|
+
return extname(basename(raw)) ? `${raw}.files` : raw;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ensureDir(dir) {
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function atomicWriteJson(filePath, value) {
|
|
63
|
+
ensureDir(dirname(filePath));
|
|
64
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
65
|
+
writeFileSync(tmp, JSON.stringify(value), 'utf8');
|
|
66
|
+
renameSync(tmp, filePath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readJson(filePath) {
|
|
96
70
|
try {
|
|
97
|
-
|
|
98
|
-
} catch
|
|
99
|
-
|
|
100
|
-
throw err;
|
|
101
|
-
}
|
|
71
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
102
74
|
}
|
|
103
75
|
}
|
|
104
76
|
|
|
105
|
-
|
|
106
|
-
|
|
77
|
+
function truncateText(value, maxBytes = MAX_TEXT_BYTES) {
|
|
78
|
+
if (value == null) return value ?? null;
|
|
79
|
+
const str = String(value);
|
|
80
|
+
if (Buffer.byteLength(str, 'utf8') <= maxBytes) return str;
|
|
81
|
+
let out = str.slice(0, maxBytes);
|
|
82
|
+
while (Buffer.byteLength(out, 'utf8') > maxBytes && out.length > 0) out = out.slice(0, -1);
|
|
83
|
+
return `${out}\n... [truncated to ${maxBytes} bytes]`;
|
|
84
|
+
}
|
|
107
85
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
* panel verbatim for the most recent traces without bloating the DB.
|
|
114
|
-
*/
|
|
115
|
-
const MAX_LOOP_PAYLOAD = 256 * 1024;
|
|
86
|
+
function cloneJsonValue(value) {
|
|
87
|
+
if (value == null) return value;
|
|
88
|
+
try { return JSON.parse(JSON.stringify(value)); }
|
|
89
|
+
catch { return null; }
|
|
90
|
+
}
|
|
116
91
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
92
|
+
function safeJsonValue(value, maxBytes = MAX_INLINE_VALUE_BYTES) {
|
|
93
|
+
if (value == null) return value;
|
|
94
|
+
try {
|
|
95
|
+
const json = JSON.stringify(value);
|
|
96
|
+
if (Buffer.byteLength(json, 'utf8') <= maxBytes) return JSON.parse(json);
|
|
97
|
+
return {
|
|
98
|
+
__truncated: true,
|
|
99
|
+
originalBytes: Buffer.byteLength(json, 'utf8'),
|
|
100
|
+
maxBytes,
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeUsage(usage = {}, fallback = {}) {
|
|
108
|
+
const inputTokens = Number.isFinite(Number(usage?.inputTokens)) ? Number(usage.inputTokens) : Number(fallback.inputTokens || 0);
|
|
109
|
+
const outputTokens = Number.isFinite(Number(usage?.outputTokens)) ? Number(usage.outputTokens) : Number(fallback.outputTokens || 0);
|
|
110
|
+
const cacheReadTokens = Number.isFinite(Number(usage?.cacheReadTokens)) ? Number(usage.cacheReadTokens) : Number(fallback.cacheReadTokens || 0);
|
|
111
|
+
const cacheWriteTokens = Number.isFinite(Number(usage?.cacheWriteTokens)) ? Number(usage.cacheWriteTokens) : Number(fallback.cacheWriteTokens || 0);
|
|
112
|
+
const totalInputTokens = Number.isFinite(Number(usage?.totalInputTokens))
|
|
113
|
+
? Number(usage.totalInputTokens)
|
|
114
|
+
: inputTokens + cacheReadTokens + cacheWriteTokens;
|
|
115
|
+
return {
|
|
116
|
+
inputTokens,
|
|
117
|
+
outputTokens,
|
|
118
|
+
cacheReadTokens,
|
|
119
|
+
cacheWriteTokens,
|
|
120
|
+
totalInputTokens,
|
|
121
|
+
totalTokens: Number.isFinite(Number(usage?.totalTokens))
|
|
122
|
+
? Number(usage.totalTokens)
|
|
123
|
+
: totalInputTokens + outputTokens,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function stableEqual(a, b) {
|
|
128
|
+
try { return JSON.stringify(a) === JSON.stringify(b); }
|
|
129
|
+
catch { return false; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function jsonByteLength(value) {
|
|
133
|
+
try { return Buffer.byteLength(JSON.stringify(value), 'utf8'); }
|
|
134
|
+
catch { return Infinity; }
|
|
127
135
|
}
|
|
128
136
|
|
|
129
|
-
function
|
|
137
|
+
function rawRequestSentinel(reason, value = null, maxBytes = MAX_RAW_REQUEST_BYTES) {
|
|
138
|
+
const preview = typeof value === 'string'
|
|
139
|
+
? truncateText(value, Math.min(64 * 1024, maxBytes))
|
|
140
|
+
: null;
|
|
141
|
+
const originalBytes = value == null ? null : jsonByteLength(value);
|
|
130
142
|
return {
|
|
131
143
|
__truncated: true,
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
reason,
|
|
145
|
+
...(originalBytes != null ? { originalBytes } : {}),
|
|
146
|
+
maxBytes,
|
|
147
|
+
...(preview ? { preview } : {}),
|
|
134
148
|
};
|
|
135
149
|
}
|
|
136
150
|
|
|
137
|
-
function
|
|
151
|
+
function boundRawValue(value, reason = 'raw_request_budget') {
|
|
138
152
|
if (value == null) return value;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
const cloned = typeof value === 'string' ? value : cloneJsonValue(value);
|
|
154
|
+
if (cloned == null) return null;
|
|
155
|
+
if (jsonByteLength(cloned) <= MAX_RAW_REQUEST_BYTES) return cloned;
|
|
156
|
+
return rawRequestSentinel(reason, value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildRawRequestBase(value) {
|
|
160
|
+
if (value == null) return null;
|
|
161
|
+
if (typeof value === 'string') return truncateText(value, MAX_RAW_REQUEST_BYTES);
|
|
162
|
+
if (!isPlainObject(value)) return boundRawValue(value);
|
|
163
|
+
const base = {};
|
|
164
|
+
for (const [key, item] of Object.entries(value)) {
|
|
165
|
+
if (key === 'body' && isPlainObject(item)) {
|
|
166
|
+
const body = {};
|
|
167
|
+
for (const [bodyKey, bodyValue] of Object.entries(item)) {
|
|
168
|
+
body[bodyKey] = boundRawValue(bodyValue, `raw_request_body_${bodyKey}_budget`);
|
|
169
|
+
}
|
|
170
|
+
base.body = body;
|
|
171
|
+
} else {
|
|
172
|
+
base[key] = boundRawValue(item, `raw_request_${key}_budget`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return base;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildRawMessagesDelta(previousMessages, nextMessages) {
|
|
179
|
+
if (!Array.isArray(previousMessages) || !Array.isArray(nextMessages)) return null;
|
|
180
|
+
const prefix = messagesPrefixLength(previousMessages, nextMessages);
|
|
181
|
+
if (prefix === previousMessages.length && prefix <= nextMessages.length) {
|
|
182
|
+
return { messagesFrom: prefix, messagesAppend: boundRawValue(nextMessages.slice(prefix), 'raw_request_messages_append_budget') };
|
|
146
183
|
}
|
|
184
|
+
return { messages: boundRawValue(nextMessages, 'raw_request_messages_budget') };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function rawComparableRequest(value) {
|
|
188
|
+
if (value == null) return null;
|
|
189
|
+
if (!isPlainObject(value)) return value;
|
|
190
|
+
const out = { ...value };
|
|
191
|
+
if (isPlainObject(value.body)) out.body = { ...value.body };
|
|
192
|
+
return out;
|
|
147
193
|
}
|
|
148
194
|
|
|
149
|
-
function
|
|
150
|
-
if (
|
|
195
|
+
function buildRawRequestDelta(previous, next) {
|
|
196
|
+
if (next == null) return previous == null ? null : { replacement: null };
|
|
197
|
+
if (previous == null) return { base: buildRawRequestBase(next) };
|
|
198
|
+
const comparablePrevious = rawComparableRequest(previous);
|
|
199
|
+
const comparableNext = rawComparableRequest(next);
|
|
200
|
+
if (typeof comparablePrevious === 'string' || typeof comparableNext === 'string') {
|
|
201
|
+
return comparablePrevious === comparableNext ? null : { replacement: rawRequestSentinel('raw_request_string_replaced', comparableNext) };
|
|
202
|
+
}
|
|
203
|
+
if (!isPlainObject(comparablePrevious) || !isPlainObject(comparableNext)) {
|
|
204
|
+
return stableEqual(comparablePrevious, comparableNext) ? null : { replacement: rawRequestSentinel('raw_request_replaced') };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const delta = { set: {}, body: {} };
|
|
208
|
+
for (const key of Object.keys(comparableNext)) {
|
|
209
|
+
if (key === 'body') continue;
|
|
210
|
+
if (!stableEqual(comparablePrevious[key], comparableNext[key])) delta.set[key] = boundRawValue(comparableNext[key], `raw_request_${key}_budget`);
|
|
211
|
+
}
|
|
212
|
+
for (const key of Object.keys(comparablePrevious)) {
|
|
213
|
+
if (key !== 'body' && !Object.prototype.hasOwnProperty.call(comparableNext, key)) delta.set[key] = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const prevBody = isPlainObject(comparablePrevious.body) ? comparablePrevious.body : null;
|
|
217
|
+
const nextBody = isPlainObject(comparableNext.body) ? comparableNext.body : null;
|
|
218
|
+
if (prevBody && nextBody) {
|
|
219
|
+
for (const key of Object.keys(nextBody)) {
|
|
220
|
+
if (key === 'messages') continue;
|
|
221
|
+
if (!stableEqual(prevBody[key], nextBody[key])) delta.body[key] = boundRawValue(nextBody[key], `raw_request_body_${key}_budget`);
|
|
222
|
+
}
|
|
223
|
+
for (const key of Object.keys(prevBody)) {
|
|
224
|
+
if (key !== 'messages' && !Object.prototype.hasOwnProperty.call(nextBody, key)) delta.body[key] = null;
|
|
225
|
+
}
|
|
226
|
+
const msgDelta = buildRawMessagesDelta(prevBody.messages, nextBody.messages);
|
|
227
|
+
if (msgDelta) Object.assign(delta.body, msgDelta);
|
|
228
|
+
} else if (!stableEqual(previous.body, next.body)) {
|
|
229
|
+
delta.set.body = rawRequestSentinel('raw_request_body_replaced');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (Object.keys(delta.set).length === 0) delete delta.set;
|
|
233
|
+
if (Object.keys(delta.body).length === 0) delete delta.body;
|
|
234
|
+
if (!delta.set && !delta.body) return null;
|
|
235
|
+
if (jsonByteLength(delta) > MAX_RAW_REQUEST_BYTES) {
|
|
236
|
+
return { replacement: rawRequestSentinel('raw_request_delta_budget') };
|
|
237
|
+
}
|
|
238
|
+
return delta;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function applyRawRequestDelta(previous, delta) {
|
|
242
|
+
if (!delta) return previous ?? null;
|
|
243
|
+
if (Object.prototype.hasOwnProperty.call(delta, 'base')) return cloneJsonValue(delta.base) ?? delta.base ?? null;
|
|
244
|
+
if (Object.prototype.hasOwnProperty.call(delta, 'replacement')) return cloneJsonValue(delta.replacement) ?? delta.replacement ?? null;
|
|
245
|
+
const next = isPlainObject(previous) ? cloneJsonValue(previous) || {} : {};
|
|
246
|
+
if (isPlainObject(delta.set)) {
|
|
247
|
+
for (const [key, value] of Object.entries(delta.set)) next[key] = cloneJsonValue(value) ?? value;
|
|
248
|
+
}
|
|
249
|
+
if (isPlainObject(delta.body)) {
|
|
250
|
+
const body = isPlainObject(next.body) ? { ...next.body } : {};
|
|
251
|
+
for (const [key, value] of Object.entries(delta.body)) {
|
|
252
|
+
if (key === 'messagesFrom' || key === 'messagesAppend' || key === 'messages') continue;
|
|
253
|
+
body[key] = cloneJsonValue(value) ?? value;
|
|
254
|
+
}
|
|
255
|
+
if (Array.isArray(delta.body.messages)) {
|
|
256
|
+
body.messages = cloneJsonValue(delta.body.messages) || [];
|
|
257
|
+
} else if (Array.isArray(delta.body.messagesAppend)) {
|
|
258
|
+
const from = Number.isFinite(Number(delta.body.messagesFrom)) ? Number(delta.body.messagesFrom) : (Array.isArray(body.messages) ? body.messages.length : 0);
|
|
259
|
+
body.messages = (Array.isArray(body.messages) ? body.messages.slice(0, from) : []).concat(cloneJsonValue(delta.body.messagesAppend) || []);
|
|
260
|
+
}
|
|
261
|
+
next.body = body;
|
|
262
|
+
}
|
|
263
|
+
return next;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function messagesPrefixLength(prevMessages, nextMessages) {
|
|
267
|
+
if (!Array.isArray(prevMessages) || !Array.isArray(nextMessages)) return 0;
|
|
268
|
+
const max = Math.min(prevMessages.length, nextMessages.length);
|
|
269
|
+
let i = 0;
|
|
270
|
+
for (; i < max; i++) {
|
|
271
|
+
if (!stableEqual(prevMessages[i], nextMessages[i])) break;
|
|
272
|
+
}
|
|
273
|
+
return i;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function buildRequestSnapshot(info = {}) {
|
|
151
277
|
return {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
response: truncateJsonValue(eventData.response),
|
|
156
|
-
rawRequest: truncateJsonValue(eventData.rawRequest),
|
|
157
|
-
rawResponse: truncateJsonValue(eventData.rawResponse),
|
|
278
|
+
systemPrompt: truncateText(info.systemPrompt || '', MAX_TEXT_BYTES),
|
|
279
|
+
messages: Array.isArray(info.messages) ? cloneJsonValue(info.messages) : [],
|
|
280
|
+
rawRequest: info.rawRequest ?? null,
|
|
158
281
|
};
|
|
159
282
|
}
|
|
160
283
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
284
|
+
function buildRequestDelta(previous, next) {
|
|
285
|
+
if (!previous) {
|
|
286
|
+
return {
|
|
287
|
+
base: true,
|
|
288
|
+
systemPrompt: next.systemPrompt || '',
|
|
289
|
+
messages: Array.isArray(next.messages) ? next.messages : [],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const delta = {};
|
|
293
|
+
if ((next.systemPrompt || '') !== (previous.systemPrompt || '')) {
|
|
294
|
+
delta.systemPrompt = next.systemPrompt || '';
|
|
295
|
+
}
|
|
296
|
+
const prevMessages = Array.isArray(previous.messages) ? previous.messages : [];
|
|
297
|
+
const nextMessages = Array.isArray(next.messages) ? next.messages : [];
|
|
298
|
+
const prefix = messagesPrefixLength(prevMessages, nextMessages);
|
|
299
|
+
if (prefix === prevMessages.length && prefix <= nextMessages.length) {
|
|
300
|
+
const appended = nextMessages.slice(prefix);
|
|
301
|
+
delta.messagesFrom = prefix;
|
|
302
|
+
delta.messagesAppend = appended;
|
|
303
|
+
} else {
|
|
304
|
+
delta.messages = nextMessages;
|
|
305
|
+
}
|
|
306
|
+
const rawRequestDelta = buildRawRequestDelta(previous.rawRequest, next.rawRequest);
|
|
307
|
+
if (rawRequestDelta) delta.rawRequestDelta = rawRequestDelta;
|
|
308
|
+
return delta;
|
|
309
|
+
}
|
|
167
310
|
|
|
168
|
-
|
|
169
|
-
|
|
311
|
+
function applyRequestDelta(previous, delta = {}) {
|
|
312
|
+
const base = previous || { systemPrompt: '', messages: [], rawRequest: null };
|
|
313
|
+
const next = {
|
|
314
|
+
systemPrompt: base.systemPrompt || '',
|
|
315
|
+
messages: Array.isArray(base.messages) ? [...base.messages] : [],
|
|
316
|
+
rawRequest: base.rawRequest ?? null,
|
|
317
|
+
};
|
|
318
|
+
if (delta.base) {
|
|
319
|
+
return {
|
|
320
|
+
systemPrompt: delta.systemPrompt || '',
|
|
321
|
+
messages: Array.isArray(delta.messages) ? delta.messages : [],
|
|
322
|
+
rawRequest: base.rawRequest ?? null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (typeof delta.systemPrompt === 'string') next.systemPrompt = delta.systemPrompt;
|
|
326
|
+
if (Array.isArray(delta.messages)) {
|
|
327
|
+
next.messages = delta.messages;
|
|
328
|
+
} else if (Array.isArray(delta.messagesAppend)) {
|
|
329
|
+
const from = Number.isFinite(Number(delta.messagesFrom)) ? Number(delta.messagesFrom) : next.messages.length;
|
|
330
|
+
next.messages = next.messages.slice(0, from).concat(delta.messagesAppend);
|
|
331
|
+
}
|
|
332
|
+
if (Object.prototype.hasOwnProperty.call(delta, 'rawRequestDelta')) next.rawRequest = applyRawRequestDelta(next.rawRequest, delta.rawRequestDelta);
|
|
333
|
+
return next;
|
|
334
|
+
}
|
|
170
335
|
|
|
171
|
-
|
|
172
|
-
|
|
336
|
+
function sessionRequestsDir(rootDir, sessionId) {
|
|
337
|
+
if (sessionId) return join(rootDir, 'sessions', safeDirComponent(sessionId), 'debug', 'requests');
|
|
338
|
+
return join(rootDir, 'debug', 'requests');
|
|
339
|
+
}
|
|
173
340
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
constructor(dbPath) {
|
|
178
|
-
this.#dbPath = dbPath;
|
|
179
|
-
this.#db = new DatabaseSync(dbPath);
|
|
180
|
-
// INCREMENTAL auto-vacuum lets cleanup() return freed pages to the OS via
|
|
181
|
-
// `PRAGMA incremental_vacuum` instead of leaving the file at its historical
|
|
182
|
-
// peak. SQLite only honours an auto_vacuum *change* before the first table
|
|
183
|
-
// is created (a fresh DB) — on a pre-existing store it is a silent no-op, so
|
|
184
|
-
// existing databases keep their default `auto_vacuum=NONE` and are
|
|
185
|
-
// unaffected (they only shrink under a manual compact()/VACUUM). Databases
|
|
186
|
-
// created from this version on are self-trimming. Must precede SCHEMA.
|
|
187
|
-
this.#db.exec('PRAGMA auto_vacuum = INCREMENTAL');
|
|
188
|
-
this.#db.exec('PRAGMA journal_mode = WAL');
|
|
189
|
-
this.#db.exec('PRAGMA foreign_keys = ON');
|
|
190
|
-
this.#db.exec(SCHEMA);
|
|
191
|
-
// Forward-compat: a DB created by an older version of the bridge
|
|
192
|
-
// will be missing the group/vp/thread + per-loop snapshot columns.
|
|
193
|
-
// Add them on open; no-op for fresh DBs (column already exists
|
|
194
|
-
// from SCHEMA above).
|
|
195
|
-
migrateAddColumn(this.#db, 'trace_turns', 'group_id', 'TEXT');
|
|
196
|
-
migrateAddColumn(this.#db, 'trace_turns', 'vp_id', 'TEXT');
|
|
197
|
-
migrateAddColumn(this.#db, 'trace_turns', 'thread_id', 'TEXT');
|
|
198
|
-
migrateAddColumn(this.#db, 'trace_turns', 'system_prompt', 'TEXT');
|
|
199
|
-
migrateAddColumn(this.#db, 'trace_turns', 'messages_json', 'TEXT');
|
|
200
|
-
migrateAddColumn(this.#db, 'trace_turns', 'tool_calls_json', 'TEXT');
|
|
201
|
-
migrateAddColumn(this.#db, 'trace_turns', 'usage_json', 'TEXT');
|
|
202
|
-
migrateAddColumn(this.#db, 'trace_turns', 'ttfb_ms', 'INTEGER');
|
|
203
|
-
migrateAddColumn(this.#db, 'trace_turns', 'raw_request', 'TEXT');
|
|
204
|
-
migrateAddColumn(this.#db, 'trace_turns', 'raw_response', 'TEXT');
|
|
205
|
-
// C2 fix: explicit `user_prompt` column. Deriving the prompt from
|
|
206
|
-
// `messages_json` is unsafe because every loop after turn 1 in a
|
|
207
|
-
// multi-loop tool-call cycle persists the *cumulative* conversation
|
|
208
|
-
// snapshot — `messages.find(role==='user')` would return turn 1's
|
|
209
|
-
// text for every subsequent turn, mislabeling every Turn header.
|
|
210
|
-
migrateAddColumn(this.#db, 'trace_turns', 'user_prompt', 'TEXT');
|
|
211
|
-
migrateAddColumn(this.#db, 'trace_tools', 'tool_call_id', 'TEXT');
|
|
212
|
-
// Indexes on the just-added columns. Must run AFTER the ALTER TABLEs
|
|
213
|
-
// — running them inside SCHEMA's CREATE INDEX IF NOT EXISTS block
|
|
214
|
-
// would fail with "no such column: group_id" on a pre-bugfix DB.
|
|
215
|
-
this.#db.exec(POST_MIGRATION_INDEXES);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ─── Write API ───────────────────────────────────────────────
|
|
341
|
+
function requestFilePath(requestDir) {
|
|
342
|
+
return join(requestDir, 'trace.json');
|
|
343
|
+
}
|
|
219
344
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return
|
|
345
|
+
function tracePathFor(rootDir, sessionId, requestKey) {
|
|
346
|
+
return requestFilePath(join(sessionRequestsDir(rootDir, sessionId), safeDirComponent(requestKey, 'request')));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function summarizeTrace(trace, detailsLoaded = false) {
|
|
350
|
+
const loops = Array.isArray(trace?.loops) ? trace.loops : [];
|
|
351
|
+
const usage = loops.reduce((acc, loop) => {
|
|
352
|
+
const u = normalizeUsage(loop?.usage || {});
|
|
353
|
+
acc.totalMs += Number(loop?.latencyMs || 0);
|
|
354
|
+
acc.totalTokens += u.totalTokens || 0;
|
|
355
|
+
acc.summaryInputTokens += u.totalInputTokens || 0;
|
|
356
|
+
acc.summaryOutputTokens += u.outputTokens || 0;
|
|
357
|
+
return acc;
|
|
358
|
+
}, { totalMs: 0, totalTokens: 0, summaryInputTokens: 0, summaryOutputTokens: 0 });
|
|
359
|
+
return {
|
|
360
|
+
turnId: trace?.requestId || trace?.traceId || '',
|
|
361
|
+
userPrompt: trace?.userPrompt || '',
|
|
362
|
+
sessionId: trace?.sessionId || null,
|
|
363
|
+
vpId: trace?.vpId || null,
|
|
364
|
+
threadId: trace?.threadId || null,
|
|
365
|
+
openedAt: trace?.openedAt || 0,
|
|
366
|
+
closedAt: trace?.closedAt || null,
|
|
367
|
+
totalMs: usage.totalMs,
|
|
368
|
+
totalTokens: usage.totalTokens,
|
|
369
|
+
summaryInputTokens: usage.summaryInputTokens,
|
|
370
|
+
summaryOutputTokens: usage.summaryOutputTokens,
|
|
371
|
+
loopCount: loops.length,
|
|
372
|
+
memoryLoaded: null,
|
|
373
|
+
memoryAdjust: null,
|
|
374
|
+
tools: Array.isArray(trace?.tools) ? trace.tools.map(t => ({
|
|
375
|
+
loopNumber: t.loopNumber || 0,
|
|
376
|
+
callId: t.toolCallId || t.id || null,
|
|
377
|
+
traceToolId: t.id || null,
|
|
378
|
+
name: t.toolName || t.name || '?',
|
|
379
|
+
toolOutput: t.toolOutput == null ? null : String(t.toolOutput),
|
|
380
|
+
durationMs: t.durationMs || 0,
|
|
381
|
+
isError: !!t.isError,
|
|
382
|
+
})) : [],
|
|
383
|
+
detailsLoaded,
|
|
384
|
+
requestBase: trace?.baseRequest || null,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function expandTrace(trace) {
|
|
389
|
+
const turnsById = new Map([[trace.requestId || trace.traceId, summarizeTrace(trace, true)]]);
|
|
390
|
+
let snapshot = null;
|
|
391
|
+
const loops = [];
|
|
392
|
+
for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
|
|
393
|
+
snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
|
|
394
|
+
const usage = normalizeUsage(loop?.usage || {});
|
|
395
|
+
loops.push({
|
|
396
|
+
turnId: trace.requestId || trace.traceId,
|
|
397
|
+
loopInstanceId: loop.loopInstanceId || loop.turnRowId || null,
|
|
398
|
+
loopNumber: loop.loopNumber || 0,
|
|
399
|
+
model: loop.model || null,
|
|
400
|
+
systemPrompt: snapshot.systemPrompt || '',
|
|
401
|
+
messages: Array.isArray(snapshot.messages) ? snapshot.messages : [],
|
|
402
|
+
response: loop.response || '',
|
|
403
|
+
toolCalls: Array.isArray(loop.toolCalls) ? loop.toolCalls : [],
|
|
404
|
+
usage,
|
|
405
|
+
latencyMs: loop.latencyMs || 0,
|
|
406
|
+
ttfbMs: loop.ttfbMs || null,
|
|
407
|
+
stopReason: loop.stopReason || null,
|
|
408
|
+
at: loop.at || null,
|
|
409
|
+
rawRequest: snapshot.rawRequest ?? null,
|
|
410
|
+
rawResponse: loop.rawResponse ?? null,
|
|
411
|
+
requestDelta: loop.requestDelta || {},
|
|
412
|
+
requestBase: trace.baseRequest || null,
|
|
413
|
+
sessionId: trace.sessionId || null,
|
|
414
|
+
vpId: trace.vpId || null,
|
|
415
|
+
threadId: trace.threadId || null,
|
|
416
|
+
});
|
|
233
417
|
}
|
|
418
|
+
return { loops, turns: Array.from(turnsById.values()) };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function traceToLegacyRows(trace) {
|
|
422
|
+
let snapshot = null;
|
|
423
|
+
return (Array.isArray(trace?.loops) ? trace.loops : []).map((loop) => {
|
|
424
|
+
snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
|
|
425
|
+
const u = normalizeUsage(loop?.usage || {});
|
|
426
|
+
return {
|
|
427
|
+
id: loop.turnRowId || loop.loopInstanceId || randomUUID(),
|
|
428
|
+
trace_id: trace.traceId || trace.requestId,
|
|
429
|
+
message_id: trace.messageId || null,
|
|
430
|
+
mode: trace.mode || null,
|
|
431
|
+
turn_number: loop.loopNumber || 0,
|
|
432
|
+
model: loop.model || null,
|
|
433
|
+
input_tokens: u.inputTokens || 0,
|
|
434
|
+
output_tokens: u.outputTokens || 0,
|
|
435
|
+
cache_read_tokens: u.cacheReadTokens || 0,
|
|
436
|
+
cache_write_tokens: u.cacheWriteTokens || 0,
|
|
437
|
+
stop_reason: loop.stopReason || null,
|
|
438
|
+
latency_ms: loop.latencyMs || 0,
|
|
439
|
+
response_text: loop.response || '',
|
|
440
|
+
started_at: loop.startedAt || trace.openedAt || 0,
|
|
441
|
+
ended_at: loop.at || trace.closedAt || null,
|
|
442
|
+
group_id: trace.sessionId || null,
|
|
443
|
+
vp_id: trace.vpId || null,
|
|
444
|
+
thread_id: trace.threadId || null,
|
|
445
|
+
system_prompt: snapshot.systemPrompt || '',
|
|
446
|
+
messages_json: JSON.stringify(snapshot.messages || []),
|
|
447
|
+
tool_calls_json: JSON.stringify(loop.toolCalls || []),
|
|
448
|
+
usage_json: JSON.stringify(u),
|
|
449
|
+
ttfb_ms: loop.ttfbMs || null,
|
|
450
|
+
raw_request: typeof snapshot.rawRequest === 'string' ? snapshot.rawRequest : JSON.stringify(snapshot.rawRequest ?? null),
|
|
451
|
+
raw_response: typeof loop.rawResponse === 'string' ? loop.rawResponse : JSON.stringify(loop.rawResponse ?? null),
|
|
452
|
+
user_prompt: trace.userPrompt || '',
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function traceToolToLegacy(trace, tool) {
|
|
458
|
+
return {
|
|
459
|
+
id: tool.id || randomUUID(),
|
|
460
|
+
turn_id: tool.turnRowId || null,
|
|
461
|
+
tool_name: tool.toolName || tool.name || '?',
|
|
462
|
+
tool_input: tool.toolInput == null ? null : String(tool.toolInput),
|
|
463
|
+
tool_output: tool.toolOutput == null ? null : String(tool.toolOutput),
|
|
464
|
+
tool_call_id: tool.toolCallId || null,
|
|
465
|
+
duration_ms: tool.durationMs || 0,
|
|
466
|
+
is_error: tool.isError ? 1 : 0,
|
|
467
|
+
created_at: tool.createdAt || trace.openedAt || 0,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function collectTraceFiles(rootDir, sessionId = null) {
|
|
472
|
+
const files = [];
|
|
473
|
+
const addFromRequestsDir = (requestsDir) => {
|
|
474
|
+
let entries = [];
|
|
475
|
+
try { entries = readdirSync(requestsDir, { withFileTypes: true }); }
|
|
476
|
+
catch { return; }
|
|
477
|
+
for (const entry of entries) {
|
|
478
|
+
if (!entry.isDirectory()) continue;
|
|
479
|
+
const file = requestFilePath(join(requestsDir, entry.name));
|
|
480
|
+
if (existsSync(file)) files.push(file);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
if (sessionId) {
|
|
484
|
+
addFromRequestsDir(sessionRequestsDir(rootDir, sessionId));
|
|
485
|
+
return files;
|
|
486
|
+
}
|
|
487
|
+
addFromRequestsDir(sessionRequestsDir(rootDir, null));
|
|
488
|
+
const sessionsRoot = join(rootDir, 'sessions');
|
|
489
|
+
let sessionEntries = [];
|
|
490
|
+
try { sessionEntries = readdirSync(sessionsRoot, { withFileTypes: true }); }
|
|
491
|
+
catch { return files; }
|
|
492
|
+
for (const entry of sessionEntries) {
|
|
493
|
+
if (!entry.isDirectory()) continue;
|
|
494
|
+
addFromRequestsDir(join(sessionsRoot, entry.name, 'debug', 'requests'));
|
|
495
|
+
}
|
|
496
|
+
return files;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function readTraceSummaries(rootDir, sessionId = null) {
|
|
500
|
+
const traces = [];
|
|
501
|
+
for (const file of collectTraceFiles(rootDir, sessionId)) {
|
|
502
|
+
const trace = readJson(file);
|
|
503
|
+
if (!trace || !trace.requestId) continue;
|
|
504
|
+
traces.push({ trace, file, openedAt: Number(trace.openedAt || 0) });
|
|
505
|
+
}
|
|
506
|
+
traces.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
|
|
507
|
+
return traces;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function countDirFiles(rootDir) {
|
|
511
|
+
let files = 0;
|
|
512
|
+
let bytes = 0;
|
|
513
|
+
const walk = (dir) => {
|
|
514
|
+
let entries = [];
|
|
515
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
516
|
+
catch { return; }
|
|
517
|
+
for (const entry of entries) {
|
|
518
|
+
const p = join(dir, entry.name);
|
|
519
|
+
if (entry.isDirectory()) walk(p);
|
|
520
|
+
else {
|
|
521
|
+
files += 1;
|
|
522
|
+
try { bytes += statSync(p).size; } catch { /* ignore */ }
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
walk(rootDir);
|
|
527
|
+
return { files, bytes };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export class DebugTrace {
|
|
531
|
+
/** @type {string} */
|
|
532
|
+
#rootDir;
|
|
533
|
+
/** @type {Map<string, { requestKey: string, sessionId: string|null, traceId: string, loopNumber: number }>} */
|
|
534
|
+
#turnIndex = new Map();
|
|
535
|
+
/** @type {Map<string, object>} */
|
|
536
|
+
#requestCache = new Map();
|
|
537
|
+
/** @type {Map<string, { trace: object, dirtyLoops: number, firstDirtyAt: number }>} */
|
|
538
|
+
#pendingWrites = new Map();
|
|
539
|
+
/** @type {NodeJS.Timeout|null} */
|
|
540
|
+
#flushTimer = null;
|
|
541
|
+
/** @type {number} */
|
|
542
|
+
#sequence = 0;
|
|
234
543
|
|
|
235
544
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* @param {{ model?: string, inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, stopReason?: string, latencyMs?: number, responseText?: string, systemPrompt?: string, messages?: unknown, toolCalls?: unknown, usage?: unknown, ttfbMs?: number, rawRequest?: unknown, rawResponse?: unknown }} info
|
|
545
|
+
* @param {string} tracePath — Back-compatible path. If it looks like a DB
|
|
546
|
+
* file, traces are stored in a sibling `debug/` directory.
|
|
239
547
|
*/
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
systemPrompt = null,
|
|
250
|
-
messages = null,
|
|
251
|
-
toolCalls = null,
|
|
252
|
-
usage = null,
|
|
253
|
-
ttfbMs = null,
|
|
254
|
-
rawRequest = null,
|
|
255
|
-
rawResponse = null,
|
|
256
|
-
} = {}) {
|
|
548
|
+
constructor(tracePath) {
|
|
549
|
+
const rootDir = fileTraceRoot(tracePath);
|
|
550
|
+
if (!rootDir) throw new Error('DebugTrace requires a storage path');
|
|
551
|
+
this.#rootDir = rootDir;
|
|
552
|
+
ensureDir(rootDir);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null } = {}) {
|
|
556
|
+
const turnRowId = randomUUID();
|
|
257
557
|
const now = Date.now();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// empty messages / toolCalls / usage. Persist a structured sentinel
|
|
268
|
-
// instead so the panel can render a "[truncated, N bytes]" notice.
|
|
269
|
-
const safeStringify = (v) => {
|
|
270
|
-
if (v == null) return null;
|
|
271
|
-
try {
|
|
272
|
-
const s = JSON.stringify(v);
|
|
273
|
-
if (s.length <= MAX_LOOP_PAYLOAD) return s;
|
|
274
|
-
return JSON.stringify(truncatedJsonSentinel(s.length));
|
|
275
|
-
} catch { return null; }
|
|
276
|
-
};
|
|
277
|
-
// For raw request/response, accept either a pre-stringified blob
|
|
278
|
-
// (treat as opaque text — truncation here is fine because
|
|
279
|
-
// parseJsonSafe is not used on raw_*) or a structured object (route
|
|
280
|
-
// through safeStringify which preserves JSON validity).
|
|
281
|
-
const stringifyRaw = (v) => {
|
|
282
|
-
if (v == null) return null;
|
|
283
|
-
if (typeof v === 'string') return truncate(v, MAX_LOOP_PAYLOAD);
|
|
284
|
-
return safeStringify(v);
|
|
285
|
-
};
|
|
286
|
-
this.#prepare('endTurn', `
|
|
287
|
-
UPDATE trace_turns SET
|
|
288
|
-
model = ?, input_tokens = ?, output_tokens = ?,
|
|
289
|
-
cache_read_tokens = ?, cache_write_tokens = ?,
|
|
290
|
-
stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?,
|
|
291
|
-
system_prompt = ?, messages_json = ?, tool_calls_json = ?,
|
|
292
|
-
usage_json = ?, ttfb_ms = ?, raw_request = ?, raw_response = ?
|
|
293
|
-
WHERE id = ?
|
|
294
|
-
`).run(
|
|
295
|
-
model, inputTokens, outputTokens,
|
|
296
|
-
cacheReadTokens, cacheWriteTokens,
|
|
297
|
-
stopReason, latencyMs, truncate(responseText, MAX_LOOP_PAYLOAD),
|
|
558
|
+
const request = this.#getOrCreateRequest({
|
|
559
|
+
traceId: traceId || turnRowId,
|
|
560
|
+
turnNumber: Number(turnNumber || 0),
|
|
561
|
+
messageId,
|
|
562
|
+
mode,
|
|
563
|
+
sessionId,
|
|
564
|
+
vpId,
|
|
565
|
+
threadId,
|
|
566
|
+
userPrompt,
|
|
298
567
|
now,
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
568
|
+
turnRowId,
|
|
569
|
+
});
|
|
570
|
+
this.#turnIndex.set(turnRowId, {
|
|
571
|
+
requestKey: request.requestKey,
|
|
572
|
+
sessionId: request.sessionId || null,
|
|
573
|
+
traceId: request.traceId,
|
|
574
|
+
loopNumber: Number(turnNumber || 0),
|
|
575
|
+
});
|
|
576
|
+
return turnRowId;
|
|
308
577
|
}
|
|
309
578
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
579
|
+
endTurn(turnId, info = {}) {
|
|
580
|
+
const ctx = this.#turnIndex.get(turnId);
|
|
581
|
+
if (!ctx) return;
|
|
582
|
+
const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
|
|
583
|
+
if (!trace) return;
|
|
584
|
+
const loopNumber = ctx.loopNumber || Number(info.turnNumber || 0);
|
|
585
|
+
const snapshot = buildRequestSnapshot(info);
|
|
586
|
+
const previousSnapshot = trace._lastSnapshot || this.#reconstructLastSnapshot(trace);
|
|
587
|
+
if (!trace.baseRequest) {
|
|
588
|
+
const rawRequestBaseDelta = buildRawRequestDelta(null, snapshot.rawRequest);
|
|
589
|
+
trace.baseRequest = { ...snapshot, rawRequest: applyRawRequestDelta(null, rawRequestBaseDelta) };
|
|
590
|
+
}
|
|
591
|
+
const loopIndex = (trace.loops || []).findIndex(l => l.turnRowId === turnId);
|
|
592
|
+
const loop = {
|
|
593
|
+
loopInstanceId: turnId,
|
|
594
|
+
turnRowId: turnId,
|
|
595
|
+
loopNumber,
|
|
596
|
+
startedAt: trace.openedAt || Date.now(),
|
|
597
|
+
model: info.model || null,
|
|
598
|
+
response: truncateText(info.responseText || '', MAX_TEXT_BYTES),
|
|
599
|
+
toolCalls: cloneJsonValue(Array.isArray(info.toolCalls) ? info.toolCalls : []),
|
|
600
|
+
usage: normalizeUsage(info.usage || {}, {
|
|
601
|
+
inputTokens: info.inputTokens || 0,
|
|
602
|
+
outputTokens: info.outputTokens || 0,
|
|
603
|
+
cacheReadTokens: info.cacheReadTokens || 0,
|
|
604
|
+
cacheWriteTokens: info.cacheWriteTokens || 0,
|
|
605
|
+
}),
|
|
606
|
+
latencyMs: Number(info.latencyMs || 0),
|
|
607
|
+
ttfbMs: Number.isFinite(Number(info.ttfbMs)) ? Number(info.ttfbMs) : null,
|
|
608
|
+
stopReason: info.stopReason || null,
|
|
609
|
+
at: Date.now(),
|
|
610
|
+
rawResponse: typeof info.rawResponse === 'string'
|
|
611
|
+
? truncateText(info.rawResponse, MAX_TEXT_BYTES)
|
|
612
|
+
: safeJsonValue(info.rawResponse),
|
|
613
|
+
requestDelta: buildRequestDelta(previousSnapshot, snapshot),
|
|
614
|
+
};
|
|
615
|
+
if (loopIndex >= 0) trace.loops[loopIndex] = loop;
|
|
616
|
+
else trace.loops.push(loop);
|
|
617
|
+
trace.loops.sort((a, b) => (a.loopNumber || 0) - (b.loopNumber || 0) || String(a.turnRowId || '').localeCompare(String(b.turnRowId || '')));
|
|
618
|
+
trace.closedAt = loop.at;
|
|
619
|
+
trace.updatedAt = loop.at;
|
|
620
|
+
trace.active = info.stopReason ? !['end_turn', 'error', 'aborted'].includes(String(info.stopReason)) : false;
|
|
621
|
+
trace._lastSnapshot = snapshot;
|
|
622
|
+
this.#markDirty(trace, { dirtyLoops: 1, force: !trace.active });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
logTool(turnId, { toolName, toolCallId = null, toolInput = null, toolOutput = null, durationMs = null, isError = false } = {}) {
|
|
324
626
|
const id = randomUUID();
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
627
|
+
const ctx = this.#turnIndex.get(turnId);
|
|
628
|
+
if (!ctx) return id;
|
|
629
|
+
const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
|
|
630
|
+
if (!trace) return id;
|
|
631
|
+
if (!Array.isArray(trace.tools)) trace.tools = [];
|
|
632
|
+
trace.tools.push({
|
|
633
|
+
id,
|
|
634
|
+
turnRowId: turnId,
|
|
635
|
+
loopNumber: ctx.loopNumber || 0,
|
|
636
|
+
toolName: toolName || '?',
|
|
333
637
|
toolCallId,
|
|
334
|
-
|
|
335
|
-
|
|
638
|
+
toolInput: truncateText(toolInput == null ? null : String(toolInput), MAX_TOOL_INPUT),
|
|
639
|
+
toolOutput: truncateText(toolOutput == null ? null : String(toolOutput), MAX_TEXT_BYTES),
|
|
640
|
+
durationMs: Number(durationMs || 0),
|
|
641
|
+
isError: !!isError,
|
|
642
|
+
createdAt: Date.now(),
|
|
643
|
+
});
|
|
644
|
+
trace.updatedAt = Date.now();
|
|
645
|
+
this.#markDirty(trace, { dirtyLoops: 0, force: !trace.active });
|
|
336
646
|
return id;
|
|
337
647
|
}
|
|
338
648
|
|
|
339
|
-
|
|
340
|
-
* Log a freeform event.
|
|
341
|
-
* @param {{ traceId: string, eventType: string, eventData?: unknown }} info
|
|
342
|
-
* @returns {string} — event id
|
|
343
|
-
*/
|
|
344
|
-
logEvent({ traceId, eventType, eventData = null }) {
|
|
649
|
+
logEvent({ traceId, eventType, eventData = null } = {}) {
|
|
345
650
|
const id = randomUUID();
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
651
|
+
const file = join(this.#rootDir, 'events.json');
|
|
652
|
+
const existingEvents = readJson(file);
|
|
653
|
+
const events = Array.isArray(existingEvents) ? existingEvents : [];
|
|
654
|
+
events.push({
|
|
655
|
+
id,
|
|
656
|
+
traceId: traceId || String(eventType || 'event'),
|
|
657
|
+
eventType: eventType || 'event',
|
|
658
|
+
eventData: safeJsonValue(eventData),
|
|
659
|
+
createdAt: Date.now(),
|
|
660
|
+
});
|
|
661
|
+
const trimmed = events.slice(-MAX_DREAM_EVENTS);
|
|
662
|
+
try { atomicWriteJson(file, trimmed); }
|
|
663
|
+
catch (err) { console.warn('[Yeaft] debug trace event write failed:', err?.message || err); }
|
|
353
664
|
return id;
|
|
354
665
|
}
|
|
355
666
|
|
|
356
|
-
/**
|
|
357
|
-
* Compatibility helper used by older engine/dream call sites.
|
|
358
|
-
* @param {string} eventType
|
|
359
|
-
* @param {unknown} eventData
|
|
360
|
-
* @returns {string}
|
|
361
|
-
*/
|
|
362
667
|
event(eventType, eventData = null) {
|
|
363
668
|
const traceId = (eventData && typeof eventData === 'object' && (eventData.turnId || eventData.runId))
|
|
364
669
|
? String(eventData.turnId || eventData.runId)
|
|
@@ -366,470 +671,328 @@ export class DebugTrace {
|
|
|
366
671
|
return this.logEvent({ traceId, eventType, eventData });
|
|
367
672
|
}
|
|
368
673
|
|
|
369
|
-
// ─── Read API ────────────────────────────────────────────────
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Query all data for a specific message.
|
|
373
|
-
* @param {string} messageId
|
|
374
|
-
* @returns {{ turns: object[], tools: object[], events: object[] }}
|
|
375
|
-
*/
|
|
376
674
|
queryByMessage(messageId) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
675
|
+
this.#flushPendingSync();
|
|
676
|
+
const traces = this.#traceSummaries()
|
|
677
|
+
.filter(({ trace }) => trace.messageId === messageId)
|
|
678
|
+
.map(({ trace }) => trace);
|
|
679
|
+
return this.#expandLegacy(traces);
|
|
381
680
|
}
|
|
382
681
|
|
|
383
|
-
/**
|
|
384
|
-
* Query all data for a trace.
|
|
385
|
-
* @param {string} traceId
|
|
386
|
-
* @returns {{ turns: object[], tools: object[], events: object[] }}
|
|
387
|
-
*/
|
|
388
682
|
queryByTrace(traceId) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
`).all(traceId);
|
|
395
|
-
const turnIds = turns.map(t => t.id);
|
|
396
|
-
const tools = turnIds.length > 0
|
|
397
|
-
? this.#db.prepare(
|
|
398
|
-
`SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
|
|
399
|
-
).all(...turnIds)
|
|
400
|
-
: [];
|
|
401
|
-
return { turns, tools, events };
|
|
683
|
+
this.#flushPendingSync();
|
|
684
|
+
const traces = this.#traceSummaries()
|
|
685
|
+
.filter(({ trace }) => trace.traceId === traceId || trace.requestId === traceId)
|
|
686
|
+
.map(({ trace }) => trace);
|
|
687
|
+
return this.#expandLegacy(traces);
|
|
402
688
|
}
|
|
403
689
|
|
|
404
|
-
/**
|
|
405
|
-
* Query recent turns.
|
|
406
|
-
* @param {number} [limit=20]
|
|
407
|
-
* @returns {object[]}
|
|
408
|
-
*/
|
|
409
690
|
queryRecent(limit = 20) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
691
|
+
this.#flushPendingSync();
|
|
692
|
+
const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
|
|
693
|
+
return this.#traceSummaries()
|
|
694
|
+
.slice(-lim)
|
|
695
|
+
.reverse()
|
|
696
|
+
.flatMap(({ trace }) => traceToLegacyRows(trace));
|
|
413
697
|
}
|
|
414
698
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
* Default mode returns recent loop details for backward compatibility.
|
|
419
|
-
* `indexOnly` returns all matching request summaries without loop payloads
|
|
420
|
-
* so the panel can list every past request cheaply. `limit` is intentionally
|
|
421
|
-
* ignored in index-only mode; the returned `limit` only reports the requested
|
|
422
|
-
* retention window used by older/detail paths. `detailTurnId` returns the
|
|
423
|
-
* full loop/tool payload for one request on demand.
|
|
424
|
-
*
|
|
425
|
-
* @param {{ limit?: number, dreamLimit?: number, sessionId?: string|null, threadId?: string|null, indexOnly?: boolean, detailTurnId?: string|null }} [opts]
|
|
426
|
-
* @returns {{ loops: object[], turns: object[], dreamEvents: object[], hasMore?: boolean, indexOnly?: boolean, detailTurnId?: string|null }}
|
|
427
|
-
*/
|
|
428
|
-
fetchRecentDebugHistory({ limit = 100, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null } = {}) {
|
|
429
|
-
const lim = Math.max(1, Math.min(500, Number(limit) || 100));
|
|
430
|
-
const dreamLim = Number.isFinite(Number(dreamLimit))
|
|
431
|
-
? Math.max(0, Math.min(50, Number(dreamLimit)))
|
|
432
|
-
: 5;
|
|
699
|
+
fetchRecentDebugHistory({ limit = MAX_HISTORY_LIMIT, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null } = {}) {
|
|
700
|
+
this.#flushPendingSync();
|
|
701
|
+
const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
|
|
433
702
|
const requestedDetailTurnId = typeof detailTurnId === 'string' && detailTurnId ? detailTurnId : null;
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
: inputTokens + cacheReadTokens + cacheWriteTokens;
|
|
703
|
+
const traces = this.#traceSummaries(sessionId)
|
|
704
|
+
.filter(({ trace }) => !threadId || trace.threadId === threadId)
|
|
705
|
+
.map(({ trace }) => trace);
|
|
706
|
+
const dreamEvents = this.#readDreamEvents({ sessionId, dreamLimit });
|
|
707
|
+
if (requestedDetailTurnId) {
|
|
708
|
+
const trace = traces.find(t => t.requestId === requestedDetailTurnId || t.traceId === requestedDetailTurnId);
|
|
709
|
+
if (!trace) return { loops: [], turns: [], dreamEvents, hasMore: false, limit: 0, indexOnly: false, detailTurnId: requestedDetailTurnId };
|
|
710
|
+
const expanded = expandTrace(trace);
|
|
711
|
+
return { ...expanded, dreamEvents, hasMore: false, limit: expanded.loops.length, indexOnly: false, detailTurnId: requestedDetailTurnId };
|
|
712
|
+
}
|
|
713
|
+
const selected = traces.slice(-lim);
|
|
714
|
+
if (indexOnly) {
|
|
447
715
|
return {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
? Number(usage.totalTokens)
|
|
455
|
-
: totalInputTokens + outputTokens,
|
|
716
|
+
loops: [],
|
|
717
|
+
turns: selected.map(trace => summarizeTrace(trace, false)),
|
|
718
|
+
dreamEvents,
|
|
719
|
+
hasMore: traces.length > selected.length,
|
|
720
|
+
limit: lim,
|
|
721
|
+
indexOnly: true,
|
|
456
722
|
};
|
|
457
|
-
};
|
|
458
|
-
const scopedWhere = [];
|
|
459
|
-
const scopedArgs = [];
|
|
460
|
-
if (sessionId) { scopedWhere.push('group_id = ?'); scopedArgs.push(sessionId); }
|
|
461
|
-
if (threadId) { scopedWhere.push('thread_id = ?'); scopedArgs.push(threadId); }
|
|
462
|
-
const whereSql = scopedWhere.length ? `WHERE ${scopedWhere.join(' AND ')}` : '';
|
|
463
|
-
|
|
464
|
-
const dreamEvents = [];
|
|
465
|
-
if (dreamLim > 0) {
|
|
466
|
-
const eventRows = this.#db.prepare(`
|
|
467
|
-
SELECT * FROM trace_events
|
|
468
|
-
WHERE event_type IN ('dream_progress', 'dream_loop', 'dream_turn_open', 'dream_turn_close', 'dream_run')
|
|
469
|
-
ORDER BY created_at DESC, rowid DESC LIMIT ?
|
|
470
|
-
`).all(Math.max(dreamLim * 5, dreamLim));
|
|
471
|
-
for (const er of eventRows) {
|
|
472
|
-
const data = parseJsonSafe(er.event_data) || {};
|
|
473
|
-
const evtGroupId = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : null;
|
|
474
|
-
const target = typeof data.target === 'string' ? data.target : '';
|
|
475
|
-
if (sessionId) {
|
|
476
|
-
const isBroadcast = !evtGroupId && !target;
|
|
477
|
-
const isThisGroup = evtGroupId === sessionId || target === `sessions/${sessionId}`;
|
|
478
|
-
if (!isBroadcast && !isThisGroup) continue;
|
|
479
|
-
}
|
|
480
|
-
dreamEvents.push({
|
|
481
|
-
type: data.type || (er.event_type === 'dream_progress' ? 'dream_progress' : er.event_type),
|
|
482
|
-
...data,
|
|
483
|
-
at: er.created_at,
|
|
484
|
-
ts: data.ts || data.at || er.created_at,
|
|
485
|
-
});
|
|
486
|
-
if (dreamEvents.length >= dreamLim) break;
|
|
487
|
-
}
|
|
488
|
-
dreamEvents.reverse();
|
|
489
723
|
}
|
|
724
|
+
const expanded = selected.reduce((acc, trace) => {
|
|
725
|
+
const item = expandTrace(trace);
|
|
726
|
+
acc.loops.push(...item.loops);
|
|
727
|
+
acc.turns.push(...item.turns);
|
|
728
|
+
return acc;
|
|
729
|
+
}, { loops: [], turns: [] });
|
|
730
|
+
return { ...expanded, dreamEvents, hasMore: traces.length > selected.length, limit: lim, indexOnly: false };
|
|
731
|
+
}
|
|
490
732
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
if (
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
return duplicateLoopTraceIds;
|
|
501
|
-
};
|
|
502
|
-
const detailRequestedByRowId = (rows) => {
|
|
503
|
-
if (!requestedDetailTurnId || rows.length !== 1) return false;
|
|
504
|
-
const row = rows[0];
|
|
505
|
-
return row?.id === requestedDetailTurnId && row?.trace_id !== requestedDetailTurnId;
|
|
506
|
-
};
|
|
507
|
-
const summarizeRows = (rows, duplicateLoopTraceIds = duplicateLoopTraceIdsForRows(rows), forceRowId = false) => {
|
|
508
|
-
const turnKeyForRow = (r) => {
|
|
509
|
-
if (forceRowId) return r.id || r.trace_id;
|
|
510
|
-
const baseTurnId = r.trace_id || r.id;
|
|
511
|
-
return duplicateLoopTraceIds.has(baseTurnId) ? (r.id || baseTurnId) : baseTurnId;
|
|
512
|
-
};
|
|
513
|
-
const turnsById = new Map();
|
|
514
|
-
for (const r of rows) {
|
|
515
|
-
const hydratedTurnId = turnKeyForRow(r);
|
|
516
|
-
const parsedUsage = parseJsonSafe(r.usage_json);
|
|
517
|
-
const usage = normalizeUsage(parsedUsage, r);
|
|
518
|
-
if (!turnsById.has(hydratedTurnId)) {
|
|
519
|
-
turnsById.set(hydratedTurnId, {
|
|
520
|
-
turnId: hydratedTurnId,
|
|
521
|
-
userPrompt: r.user_prompt || '',
|
|
522
|
-
sessionId: r.group_id || null,
|
|
523
|
-
vpId: r.vp_id || null,
|
|
524
|
-
threadId: r.thread_id || null,
|
|
525
|
-
openedAt: r.started_at || 0,
|
|
526
|
-
closedAt: r.ended_at || null,
|
|
527
|
-
totalMs: 0,
|
|
528
|
-
totalTokens: 0,
|
|
529
|
-
summaryInputTokens: 0,
|
|
530
|
-
summaryOutputTokens: 0,
|
|
531
|
-
loopCount: 0,
|
|
532
|
-
memoryLoaded: null,
|
|
533
|
-
memoryAdjust: null,
|
|
534
|
-
tools: [],
|
|
535
|
-
detailsLoaded: false,
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
const t = turnsById.get(hydratedTurnId);
|
|
539
|
-
t.loopCount += 1;
|
|
540
|
-
t.totalMs += r.latency_ms || 0;
|
|
541
|
-
t.totalTokens += usage.totalTokens || 0;
|
|
542
|
-
t.summaryInputTokens += usage.totalInputTokens || 0;
|
|
543
|
-
t.summaryOutputTokens += usage.outputTokens || 0;
|
|
544
|
-
if (r.started_at && (!t.openedAt || r.started_at < t.openedAt)) t.openedAt = r.started_at;
|
|
545
|
-
if (r.ended_at && (!t.closedAt || r.ended_at > t.closedAt)) t.closedAt = r.ended_at;
|
|
546
|
-
if (!t.userPrompt && r.user_prompt) t.userPrompt = r.user_prompt;
|
|
547
|
-
}
|
|
548
|
-
return Array.from(turnsById.values()).sort((a, b) => (a.openedAt || 0) - (b.openedAt || 0));
|
|
549
|
-
};
|
|
550
|
-
const expandRows = (rows) => {
|
|
551
|
-
const duplicateLoopTraceIds = duplicateLoopTraceIdsForRows(rows);
|
|
552
|
-
const forceRowId = detailRequestedByRowId(rows);
|
|
553
|
-
const turnKeyForRow = (r) => {
|
|
554
|
-
if (forceRowId) return r.id || r.trace_id;
|
|
555
|
-
const baseTurnId = r.trace_id || r.id;
|
|
556
|
-
return duplicateLoopTraceIds.has(baseTurnId) ? (r.id || baseTurnId) : baseTurnId;
|
|
557
|
-
};
|
|
558
|
-
const loopInstanceIdForRow = (r) => {
|
|
559
|
-
const baseTurnId = r.trace_id || r.id;
|
|
560
|
-
return forceRowId || duplicateLoopTraceIds.has(baseTurnId) ? (r.id || `${baseTurnId}#${r.turn_number || 0}`) : null;
|
|
561
|
-
};
|
|
562
|
-
const turnsById = new Map(summarizeRows(rows, duplicateLoopTraceIds, forceRowId).map((t) => [t.turnId, { ...t, detailsLoaded: true }]));
|
|
563
|
-
const loops = rows.map((r) => {
|
|
564
|
-
const parsedMessages = parseJsonSafe(r.messages_json) || [];
|
|
565
|
-
const parsedUsage = parseJsonSafe(r.usage_json);
|
|
566
|
-
const hydratedTurnId = turnKeyForRow(r);
|
|
567
|
-
const loopInstanceId = loopInstanceIdForRow(r);
|
|
568
|
-
return {
|
|
569
|
-
turnId: hydratedTurnId,
|
|
570
|
-
...(loopInstanceId ? { loopInstanceId } : {}),
|
|
571
|
-
loopNumber: r.turn_number || 0,
|
|
572
|
-
model: r.model || null,
|
|
573
|
-
systemPrompt: r.system_prompt || '',
|
|
574
|
-
messages: parsedMessages,
|
|
575
|
-
response: r.response_text || '',
|
|
576
|
-
toolCalls: parseJsonSafe(r.tool_calls_json) || [],
|
|
577
|
-
usage: normalizeUsage(parsedUsage, r),
|
|
578
|
-
latencyMs: r.latency_ms || 0,
|
|
579
|
-
ttfbMs: r.ttfb_ms || null,
|
|
580
|
-
stopReason: r.stop_reason || null,
|
|
581
|
-
rawRequest: r.raw_request || null,
|
|
582
|
-
rawResponse: r.raw_response || null,
|
|
583
|
-
sessionId: r.group_id || null,
|
|
584
|
-
vpId: r.vp_id || null,
|
|
585
|
-
threadId: r.thread_id || null,
|
|
586
|
-
};
|
|
587
|
-
});
|
|
588
|
-
const turnIds = rows.map(r => r.id);
|
|
589
|
-
const tools = turnIds.length > 0
|
|
590
|
-
? this.#db.prepare(
|
|
591
|
-
`SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
|
|
592
|
-
).all(...turnIds)
|
|
593
|
-
: [];
|
|
594
|
-
for (const tool of tools) {
|
|
595
|
-
const owner = rows.find(r => r.id === tool.turn_id);
|
|
596
|
-
if (!owner) continue;
|
|
597
|
-
const t = turnsById.get(turnKeyForRow(owner));
|
|
598
|
-
if (!t) continue;
|
|
599
|
-
t.tools.push({
|
|
600
|
-
loopNumber: owner.turn_number || 0,
|
|
601
|
-
callId: tool.tool_call_id || tool.id,
|
|
602
|
-
traceToolId: tool.id,
|
|
603
|
-
name: tool.tool_name,
|
|
604
|
-
toolOutput: tool.tool_output == null ? null : String(tool.tool_output),
|
|
605
|
-
durationMs: tool.duration_ms || 0,
|
|
606
|
-
isError: !!tool.is_error,
|
|
607
|
-
});
|
|
733
|
+
queryTools({ name = null, since = null } = {}) {
|
|
734
|
+
this.#flushPendingSync();
|
|
735
|
+
const tools = [];
|
|
736
|
+
for (const { trace } of this.#traceSummaries()) {
|
|
737
|
+
for (const tool of Array.isArray(trace.tools) ? trace.tools : []) {
|
|
738
|
+
const row = traceToolToLegacy(trace, tool);
|
|
739
|
+
if (name && row.tool_name !== name) continue;
|
|
740
|
+
if (since && row.created_at < since) continue;
|
|
741
|
+
tools.push(row);
|
|
608
742
|
}
|
|
609
|
-
return { loops, turns: Array.from(turnsById.values()).sort((a, b) => (a.openedAt || 0) - (b.openedAt || 0)) };
|
|
610
|
-
};
|
|
611
|
-
|
|
612
|
-
if (requestedDetailTurnId) {
|
|
613
|
-
const detailWhere = [`(trace_id = ? OR id = ?)`];
|
|
614
|
-
const detailArgs = [requestedDetailTurnId, requestedDetailTurnId];
|
|
615
|
-
if (sessionId) { detailWhere.push('group_id = ?'); detailArgs.push(sessionId); }
|
|
616
|
-
if (threadId) { detailWhere.push('thread_id = ?'); detailArgs.push(threadId); }
|
|
617
|
-
detailArgs.push(5000);
|
|
618
|
-
const rows = this.#db.prepare(`
|
|
619
|
-
SELECT * FROM trace_turns
|
|
620
|
-
WHERE ${detailWhere.join(' AND ')}
|
|
621
|
-
ORDER BY started_at ASC, turn_number ASC, rowid ASC
|
|
622
|
-
LIMIT ?
|
|
623
|
-
`).all(...detailArgs);
|
|
624
|
-
const expanded = expandRows(rows);
|
|
625
|
-
return { ...expanded, dreamEvents, hasMore: false, limit: rows.length, indexOnly: false, detailTurnId: requestedDetailTurnId };
|
|
626
743
|
}
|
|
744
|
+
tools.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
|
745
|
+
return tools.slice(0, 100);
|
|
746
|
+
}
|
|
627
747
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
return { loops: [], turns: summarizeRows(rows), dreamEvents, hasMore: false, limit: lim, indexOnly: true };
|
|
639
|
-
}
|
|
748
|
+
search(keyword) {
|
|
749
|
+
this.#flushPendingSync();
|
|
750
|
+
const needle = String(keyword || '').toLowerCase();
|
|
751
|
+
if (!needle) return [];
|
|
752
|
+
return this.#traceSummaries()
|
|
753
|
+
.filter(({ trace }) => JSON.stringify(trace).toLowerCase().includes(needle))
|
|
754
|
+
.slice(-50)
|
|
755
|
+
.reverse()
|
|
756
|
+
.flatMap(({ trace }) => traceToLegacyRows(trace));
|
|
757
|
+
}
|
|
640
758
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
const expanded = expandRows(rows);
|
|
651
|
-
return { ...expanded, dreamEvents, hasMore, limit: lim, indexOnly: false };
|
|
759
|
+
stats() {
|
|
760
|
+
this.#flushPendingSync();
|
|
761
|
+
const traces = this.#traceSummaries().map(({ trace }) => trace);
|
|
762
|
+
const turnCount = traces.reduce((n, trace) => n + (Array.isArray(trace.loops) ? trace.loops.length : 0), 0);
|
|
763
|
+
const toolCount = traces.reduce((n, trace) => n + (Array.isArray(trace.tools) ? trace.tools.length : 0), 0);
|
|
764
|
+
const events = readJson(join(this.#rootDir, 'events.json'));
|
|
765
|
+
const eventCount = Array.isArray(events) ? events.length : 0;
|
|
766
|
+
const { bytes } = countDirFiles(this.#rootDir);
|
|
767
|
+
return { turnCount, toolCount, eventCount, dbSizeBytes: bytes, fileSizeBytes: bytes, requestCount: traces.length };
|
|
652
768
|
}
|
|
653
769
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
770
|
+
cleanup(retention = REQUEST_RETENTION) {
|
|
771
|
+
this.#flushPendingSync();
|
|
772
|
+
const keep = Math.max(1, Math.min(REQUEST_RETENTION, Number(retention) || REQUEST_RETENTION));
|
|
773
|
+
const before = readTraceSummaries(this.#rootDir).length;
|
|
774
|
+
this.#pruneAll(keep);
|
|
775
|
+
const after = readTraceSummaries(this.#rootDir).length;
|
|
776
|
+
return { deletedTurns: Math.max(0, before - after), deletedTools: 0, deletedEvents: 0, deletedRequests: Math.max(0, before - after) };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
compact() {
|
|
780
|
+
this.#flushPendingSync();
|
|
781
|
+
const before = countDirFiles(this.#rootDir).bytes;
|
|
782
|
+
this.cleanup(REQUEST_RETENTION);
|
|
783
|
+
const after = countDirFiles(this.#rootDir).bytes;
|
|
784
|
+
return { before, after };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
purge() {
|
|
788
|
+
this.#flushPendingSync();
|
|
789
|
+
try { rmSync(this.#rootDir, { recursive: true, force: true }); }
|
|
790
|
+
catch { /* ignore */ }
|
|
791
|
+
ensureDir(this.#rootDir);
|
|
792
|
+
this.#turnIndex.clear();
|
|
793
|
+
this.#requestCache.clear();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
close() { this.#flushPendingSync(); }
|
|
797
|
+
|
|
798
|
+
#getOrCreateRequest({ traceId, turnNumber, messageId, mode, sessionId, vpId, threadId, userPrompt, now, turnRowId }) {
|
|
799
|
+
const normalizedSessionId = sessionId || null;
|
|
800
|
+
let all = null;
|
|
801
|
+
const isUsableExisting = (t) => (
|
|
802
|
+
t?.sessionId === normalizedSessionId
|
|
803
|
+
&& t?.traceId === traceId
|
|
804
|
+
&& !(turnNumber === 1 && (t.loops || []).some(l => l.loopNumber === 1))
|
|
805
|
+
);
|
|
806
|
+
const newestTrace = (items) => items
|
|
807
|
+
.filter(isUsableExisting)
|
|
808
|
+
.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.requestKey || '').localeCompare(String(b.requestKey || '')))
|
|
809
|
+
.at(-1) || null;
|
|
810
|
+
let existing = newestTrace(Array.from(this.#requestCache.values()));
|
|
811
|
+
if (!existing) {
|
|
812
|
+
all = this.#traceSummaries(normalizedSessionId).map(({ trace }) => trace);
|
|
813
|
+
existing = newestTrace(all);
|
|
664
814
|
}
|
|
665
|
-
if (
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
815
|
+
if (existing) {
|
|
816
|
+
existing.updatedAt = now;
|
|
817
|
+
this.#requestCache.set(existing.requestKey, existing);
|
|
818
|
+
return existing;
|
|
669
819
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
820
|
+
const seq = (this.#sequence = (this.#sequence + 1) % 1_000_000);
|
|
821
|
+
const requestKey = `${String(now).padStart(13, '0')}-${String(seq).padStart(6, '0')}-${safeDirComponent(traceId || turnRowId, 'request')}-${turnRowId.slice(0, 8)}`;
|
|
822
|
+
const requestId = turnNumber === 1 && all.some(t => t.traceId === traceId) ? turnRowId : traceId;
|
|
823
|
+
const trace = {
|
|
824
|
+
version: TRACE_VERSION,
|
|
825
|
+
requestKey,
|
|
826
|
+
requestId,
|
|
827
|
+
traceId,
|
|
828
|
+
messageId,
|
|
829
|
+
mode,
|
|
830
|
+
sessionId: normalizedSessionId,
|
|
831
|
+
vpId: vpId || null,
|
|
832
|
+
threadId: threadId || null,
|
|
833
|
+
userPrompt: truncateText(userPrompt || '', MAX_TEXT_BYTES),
|
|
834
|
+
openedAt: now,
|
|
835
|
+
closedAt: null,
|
|
836
|
+
updatedAt: now,
|
|
837
|
+
active: true,
|
|
838
|
+
baseRequest: null,
|
|
839
|
+
loops: [],
|
|
840
|
+
tools: [],
|
|
841
|
+
};
|
|
842
|
+
this.#requestCache.set(requestKey, trace);
|
|
843
|
+
return trace;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#loadRequest(sessionId, requestKey) {
|
|
847
|
+
const cached = this.#requestCache.get(requestKey);
|
|
848
|
+
if (cached) return cached;
|
|
849
|
+
const file = tracePathFor(this.#rootDir, sessionId, requestKey);
|
|
850
|
+
const trace = readJson(file);
|
|
851
|
+
if (trace) this.#requestCache.set(requestKey, trace);
|
|
852
|
+
return trace;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
#traceSummaries(sessionId = null) {
|
|
856
|
+
const byKey = new Map(readTraceSummaries(this.#rootDir, sessionId).map(item => [item.trace.requestKey, item]));
|
|
857
|
+
for (const trace of this.#requestCache.values()) {
|
|
858
|
+
if (sessionId && trace.sessionId !== sessionId) continue;
|
|
859
|
+
if (!trace?.requestId || !trace?.requestKey) continue;
|
|
860
|
+
byKey.set(trace.requestKey, {
|
|
861
|
+
trace,
|
|
862
|
+
file: this.#traceFile(trace),
|
|
863
|
+
openedAt: Number(trace.openedAt || 0),
|
|
864
|
+
});
|
|
674
865
|
}
|
|
675
|
-
return
|
|
676
|
-
|
|
677
|
-
`).all();
|
|
866
|
+
return Array.from(byKey.values())
|
|
867
|
+
.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
|
|
678
868
|
}
|
|
679
869
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
* @param {string} keyword
|
|
683
|
-
* @returns {object[]}
|
|
684
|
-
*/
|
|
685
|
-
search(keyword) {
|
|
686
|
-
const like = `%${keyword}%`;
|
|
687
|
-
return this.#prepare('search', `
|
|
688
|
-
SELECT DISTINCT t.* FROM trace_turns t
|
|
689
|
-
LEFT JOIN trace_tools tt ON tt.turn_id = t.id
|
|
690
|
-
WHERE t.response_text LIKE ? OR tt.tool_output LIKE ?
|
|
691
|
-
ORDER BY t.started_at DESC LIMIT 50
|
|
692
|
-
`).all(like, like);
|
|
870
|
+
#traceWriteKey(trace) {
|
|
871
|
+
return `${trace.sessionId || ''}::${trace.requestKey}`;
|
|
693
872
|
}
|
|
694
873
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
* @returns {{ turnCount: number, toolCount: number, eventCount: number, dbSizeBytes: number }}
|
|
698
|
-
*/
|
|
699
|
-
stats() {
|
|
700
|
-
const turnCount = Number(this.#db.prepare('SELECT COUNT(*) as c FROM trace_turns').get().c);
|
|
701
|
-
const toolCount = Number(this.#db.prepare('SELECT COUNT(*) as c FROM trace_tools').get().c);
|
|
702
|
-
const eventCount = Number(this.#db.prepare('SELECT COUNT(*) as c FROM trace_events').get().c);
|
|
703
|
-
let dbSizeBytes = 0;
|
|
704
|
-
try {
|
|
705
|
-
dbSizeBytes = statSync(this.#dbPath).size;
|
|
706
|
-
} catch { /* ignore */ }
|
|
707
|
-
return { turnCount, toolCount, eventCount, dbSizeBytes };
|
|
874
|
+
#traceFile(trace) {
|
|
875
|
+
return tracePathFor(this.#rootDir, trace.sessionId || null, trace.requestKey);
|
|
708
876
|
}
|
|
709
877
|
|
|
710
|
-
|
|
878
|
+
#serializableTrace(trace) {
|
|
879
|
+
const toWrite = { ...trace };
|
|
880
|
+
delete toWrite._lastSnapshot;
|
|
881
|
+
return toWrite;
|
|
882
|
+
}
|
|
711
883
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
*
|
|
727
|
-
* @param {number} [retentionDays=10]
|
|
728
|
-
* @returns {{ deletedTurns: number, deletedTools: number, deletedEvents: number }}
|
|
729
|
-
*/
|
|
730
|
-
cleanup(retentionDays = 10) {
|
|
731
|
-
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
732
|
-
const deletedTools = Number(this.#db.prepare(`
|
|
733
|
-
DELETE FROM trace_tools WHERE turn_id IN (
|
|
734
|
-
SELECT id FROM trace_turns WHERE started_at < ?
|
|
735
|
-
)
|
|
736
|
-
`).run(cutoff).changes);
|
|
737
|
-
const deletedTurns = Number(this.#db.prepare(`
|
|
738
|
-
DELETE FROM trace_turns WHERE started_at < ?
|
|
739
|
-
`).run(cutoff).changes);
|
|
740
|
-
const deletedEvents = Number(this.#db.prepare(`
|
|
741
|
-
DELETE FROM trace_events WHERE created_at < ?
|
|
742
|
-
`).run(cutoff).changes);
|
|
743
|
-
// Reclaim freed pages (no-op on legacy auto_vacuum=NONE DBs). Wrapped so a
|
|
744
|
-
// vacuum failure can never mask a successful delete, but surfaced as a warn
|
|
745
|
-
// because this is the one operation the whole disk-growth fix relies on — a
|
|
746
|
-
// silent persistent failure would look exactly like "the fix works".
|
|
747
|
-
if (deletedTurns || deletedTools || deletedEvents) {
|
|
748
|
-
try { this.#db.exec('PRAGMA incremental_vacuum'); }
|
|
749
|
-
catch (err) { console.warn('[Yeaft] trace incremental_vacuum failed:', err?.message || err); }
|
|
884
|
+
#markDirty(trace, { dirtyLoops = 0, force = false } = {}) {
|
|
885
|
+
if (!trace?.requestKey) return;
|
|
886
|
+
const key = this.#traceWriteKey(trace);
|
|
887
|
+
const existing = this.#pendingWrites.get(key);
|
|
888
|
+
const now = Date.now();
|
|
889
|
+
const item = existing || { trace, dirtyLoops: 0, firstDirtyAt: now };
|
|
890
|
+
item.trace = trace;
|
|
891
|
+
item.dirtyLoops += Math.max(0, Number(dirtyLoops) || 0);
|
|
892
|
+
this.#pendingWrites.set(key, item);
|
|
893
|
+
this.#requestCache.set(trace.requestKey, trace);
|
|
894
|
+
|
|
895
|
+
if (force || item.dirtyLoops >= TRACE_FLUSH_DIRTY_LOOPS) {
|
|
896
|
+
this.#flushPendingSync();
|
|
897
|
+
return;
|
|
750
898
|
}
|
|
751
|
-
|
|
899
|
+
this.#scheduleFlushTimer(now);
|
|
752
900
|
}
|
|
753
901
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
*/
|
|
764
|
-
compact() {
|
|
765
|
-
let before = 0;
|
|
766
|
-
try { before = statSync(this.#dbPath).size; } catch { /* ignore */ }
|
|
767
|
-
this.#db.exec('PRAGMA auto_vacuum = INCREMENTAL');
|
|
768
|
-
this.#db.exec('VACUUM');
|
|
769
|
-
// In WAL mode VACUUM writes the rebuilt (smaller) DB into the -wal file;
|
|
770
|
-
// the main .db file does not shrink until a checkpoint folds the WAL back
|
|
771
|
-
// in. TRUNCATE checkpoints and resets the WAL so the on-disk size we report
|
|
772
|
-
// (and the user sees) reflects the reclaimed space immediately.
|
|
773
|
-
try { this.#db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* best-effort */ }
|
|
774
|
-
let after = 0;
|
|
775
|
-
try { after = statSync(this.#dbPath).size; } catch { /* ignore */ }
|
|
776
|
-
return { before, after };
|
|
902
|
+
#scheduleFlushTimer(now = Date.now()) {
|
|
903
|
+
if (this.#flushTimer || this.#pendingWrites.size === 0) return;
|
|
904
|
+
const oldestDirtyAt = Math.min(...Array.from(this.#pendingWrites.values()).map(item => item.firstDirtyAt || now));
|
|
905
|
+
const dueIn = Math.max(0, TRACE_FLUSH_INTERVAL_MS - (now - oldestDirtyAt));
|
|
906
|
+
this.#flushTimer = setTimeout(() => {
|
|
907
|
+
this.#flushTimer = null;
|
|
908
|
+
this.#flushPendingSync();
|
|
909
|
+
}, dueIn);
|
|
910
|
+
if (typeof this.#flushTimer.unref === 'function') this.#flushTimer.unref();
|
|
777
911
|
}
|
|
778
912
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
913
|
+
#flushPendingSync() {
|
|
914
|
+
if (this.#flushTimer) {
|
|
915
|
+
clearTimeout(this.#flushTimer);
|
|
916
|
+
this.#flushTimer = null;
|
|
917
|
+
}
|
|
918
|
+
const entries = Array.from(this.#pendingWrites.values());
|
|
919
|
+
if (entries.length === 0) return;
|
|
920
|
+
this.#pendingWrites.clear();
|
|
921
|
+
for (const { trace } of entries) {
|
|
922
|
+
try {
|
|
923
|
+
atomicWriteJson(this.#traceFile(trace), this.#serializableTrace(trace));
|
|
924
|
+
} catch (err) {
|
|
925
|
+
console.warn('[Yeaft] debug trace write failed:', err?.message || err);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
this.#pruneAll(REQUEST_RETENTION);
|
|
784
929
|
}
|
|
785
930
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
931
|
+
#reconstructLastSnapshot(trace) {
|
|
932
|
+
let snapshot = null;
|
|
933
|
+
for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
|
|
934
|
+
snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
|
|
935
|
+
}
|
|
936
|
+
return snapshot;
|
|
789
937
|
}
|
|
790
938
|
|
|
791
|
-
|
|
939
|
+
#readDreamEvents({ sessionId = null, dreamLimit = 5 } = {}) {
|
|
940
|
+
const limit = Number.isFinite(Number(dreamLimit)) ? Math.max(0, Math.min(50, Number(dreamLimit))) : 5;
|
|
941
|
+
if (limit <= 0) return [];
|
|
942
|
+
const storedEvents = readJson(join(this.#rootDir, 'events.json'));
|
|
943
|
+
const events = Array.isArray(storedEvents) ? storedEvents : [];
|
|
944
|
+
const out = [];
|
|
945
|
+
for (const event of events.slice().reverse()) {
|
|
946
|
+
const data = isPlainObject(event.eventData) ? event.eventData : {};
|
|
947
|
+
if (sessionId) {
|
|
948
|
+
const evtSessionId = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : null;
|
|
949
|
+
const target = typeof data.target === 'string' ? data.target : '';
|
|
950
|
+
const isBroadcast = !evtSessionId && !target;
|
|
951
|
+
const isThisSession = evtSessionId === sessionId || target === `sessions/${sessionId}` || target === `group/${sessionId}`;
|
|
952
|
+
if (!isBroadcast && !isThisSession) continue;
|
|
953
|
+
}
|
|
954
|
+
out.push({
|
|
955
|
+
type: data.type || event.eventType || 'event',
|
|
956
|
+
...data,
|
|
957
|
+
at: event.createdAt,
|
|
958
|
+
ts: data.ts || data.at || event.createdAt,
|
|
959
|
+
});
|
|
960
|
+
if (out.length >= limit) break;
|
|
961
|
+
}
|
|
962
|
+
return out.reverse();
|
|
963
|
+
}
|
|
792
964
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
#
|
|
800
|
-
|
|
801
|
-
|
|
965
|
+
#pruneAll(keep) {
|
|
966
|
+
const sessions = new Set([null]);
|
|
967
|
+
for (const { trace } of this.#traceSummaries()) sessions.add(trace.sessionId || null);
|
|
968
|
+
for (const sid of sessions) this.#pruneSession(sid, keep);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
#pruneSession(sessionId, keep = REQUEST_RETENTION) {
|
|
972
|
+
const traces = this.#traceSummaries(sessionId);
|
|
973
|
+
const activeCutoff = Date.now() - 6 * 60 * 60 * 1000;
|
|
974
|
+
const protectedItems = traces.filter(item => item.trace?.active && Number(item.trace?.updatedAt || 0) >= activeCutoff);
|
|
975
|
+
const pruneCandidates = traces.filter(item => !protectedItems.includes(item));
|
|
976
|
+
const stale = pruneCandidates.slice(0, Math.max(0, traces.length - protectedItems.length - keep));
|
|
977
|
+
for (const item of stale) {
|
|
978
|
+
try { rmSync(dirname(item.file), { recursive: true, force: true }); }
|
|
979
|
+
catch { /* ignore */ }
|
|
980
|
+
this.#requestCache.delete(item.trace.requestKey);
|
|
802
981
|
}
|
|
803
|
-
return this.#stmts[key];
|
|
804
982
|
}
|
|
805
983
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
? this.#db.prepare(
|
|
815
|
-
`SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
|
|
816
|
-
).all(...turnIds)
|
|
817
|
-
: [];
|
|
818
|
-
// Events need trace_ids from turns
|
|
819
|
-
const traceIds = [...new Set(turns.map(t => t.trace_id))];
|
|
820
|
-
const events = traceIds.length > 0
|
|
821
|
-
? this.#db.prepare(
|
|
822
|
-
`SELECT * FROM trace_events WHERE trace_id IN (${traceIds.map(() => '?').join(',')}) ORDER BY created_at`
|
|
823
|
-
).all(...traceIds)
|
|
824
|
-
: [];
|
|
984
|
+
#expandLegacy(traces) {
|
|
985
|
+
const turns = [];
|
|
986
|
+
const tools = [];
|
|
987
|
+
const events = [];
|
|
988
|
+
for (const trace of traces) {
|
|
989
|
+
turns.push(...traceToLegacyRows(trace));
|
|
990
|
+
for (const tool of Array.isArray(trace.tools) ? trace.tools : []) tools.push(traceToolToLegacy(trace, tool));
|
|
991
|
+
}
|
|
825
992
|
return { turns, tools, events };
|
|
826
993
|
}
|
|
827
994
|
}
|
|
828
995
|
|
|
829
|
-
/**
|
|
830
|
-
* NullTrace — No-op implementation with the same interface.
|
|
831
|
-
* Used when debug is disabled. Zero overhead.
|
|
832
|
-
*/
|
|
833
996
|
export class NullTrace {
|
|
834
997
|
startTurn() { return 'null'; }
|
|
835
998
|
endTurn() {}
|
|
@@ -841,22 +1004,16 @@ export class NullTrace {
|
|
|
841
1004
|
queryRecent() { return []; }
|
|
842
1005
|
queryTools() { return []; }
|
|
843
1006
|
search() { return []; }
|
|
844
|
-
stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0 }; }
|
|
845
|
-
cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
|
|
1007
|
+
stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0, fileSizeBytes: 0, requestCount: 0 }; }
|
|
1008
|
+
cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0, deletedRequests: 0 }; }
|
|
846
1009
|
compact() { return { before: 0, after: 0 }; }
|
|
847
1010
|
purge() {}
|
|
848
1011
|
close() {}
|
|
849
1012
|
fetchRecentDebugHistory() { return { loops: [], turns: [], dreamEvents: [] }; }
|
|
850
1013
|
}
|
|
851
1014
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
*/
|
|
857
|
-
export function createTrace({ enabled, dbPath }) {
|
|
858
|
-
if (!enabled || !dbPath) {
|
|
859
|
-
return new NullTrace();
|
|
860
|
-
}
|
|
861
|
-
return new DebugTrace(dbPath);
|
|
1015
|
+
export function createTrace({ enabled, dbPath, dirPath }) {
|
|
1016
|
+
const path = dirPath || dbPath;
|
|
1017
|
+
if (!enabled || !path) return new NullTrace();
|
|
1018
|
+
return new DebugTrace(path);
|
|
862
1019
|
}
|