@yeaft/webchat-agent 1.0.17 → 1.0.19
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 +899 -740
- package/yeaft/engine.js +127 -96
- package/yeaft/llm/adapter.js +7 -3
- package/yeaft/llm/anthropic.js +16 -4
- package/yeaft/llm/openai-responses.js +22 -12
- package/yeaft/session.js +9 -17
- package/yeaft/web-bridge.js +3 -3
package/yeaft/debug-trace.js
CHANGED
|
@@ -1,364 +1,676 @@
|
|
|
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 { rename as renameAsync, writeFile as writeFileAsync } from 'fs/promises';
|
|
25
|
+
import { basename, dirname, extname, join } from 'path';
|
|
11
26
|
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
27
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
28
|
+
const TRACE_VERSION = 2;
|
|
29
|
+
const REQUEST_RETENTION = 10;
|
|
30
|
+
const MAX_HISTORY_LIMIT = 10;
|
|
31
|
+
const MAX_DREAM_EVENTS = 100;
|
|
32
|
+
const MAX_TEXT_BYTES = 1024 * 1024;
|
|
33
|
+
const MAX_TOOL_INPUT = 10 * 1024;
|
|
34
|
+
const MAX_INLINE_VALUE_BYTES = 1024 * 1024;
|
|
35
|
+
const MAX_RAW_REQUEST_BYTES = 2 * 1024 * 1024;
|
|
36
|
+
const TRACE_FLUSH_DELAY_MS = 150;
|
|
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
|
+
async function atomicWriteJsonAsync(filePath, value) {
|
|
70
|
+
ensureDir(dirname(filePath));
|
|
71
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
72
|
+
await writeFileAsync(tmp, JSON.stringify(value), 'utf8');
|
|
73
|
+
await renameAsync(tmp, filePath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readJson(filePath) {
|
|
96
77
|
try {
|
|
97
|
-
|
|
98
|
-
} catch
|
|
99
|
-
|
|
100
|
-
throw err;
|
|
101
|
-
}
|
|
78
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
102
81
|
}
|
|
103
82
|
}
|
|
104
83
|
|
|
105
|
-
|
|
106
|
-
|
|
84
|
+
function truncateText(value, maxBytes = MAX_TEXT_BYTES) {
|
|
85
|
+
if (value == null) return value ?? null;
|
|
86
|
+
const str = String(value);
|
|
87
|
+
if (Buffer.byteLength(str, 'utf8') <= maxBytes) return str;
|
|
88
|
+
let out = str.slice(0, maxBytes);
|
|
89
|
+
while (Buffer.byteLength(out, 'utf8') > maxBytes && out.length > 0) out = out.slice(0, -1);
|
|
90
|
+
return `${out}\n... [truncated to ${maxBytes} bytes]`;
|
|
91
|
+
}
|
|
107
92
|
|
|
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;
|
|
93
|
+
function cloneJsonValue(value) {
|
|
94
|
+
if (value == null) return value;
|
|
95
|
+
try { return JSON.parse(JSON.stringify(value)); }
|
|
96
|
+
catch { return null; }
|
|
97
|
+
}
|
|
116
98
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
99
|
+
function safeJsonValue(value, maxBytes = MAX_INLINE_VALUE_BYTES) {
|
|
100
|
+
if (value == null) return value;
|
|
101
|
+
try {
|
|
102
|
+
const json = JSON.stringify(value);
|
|
103
|
+
if (Buffer.byteLength(json, 'utf8') <= maxBytes) return JSON.parse(json);
|
|
104
|
+
return {
|
|
105
|
+
__truncated: true,
|
|
106
|
+
originalBytes: Buffer.byteLength(json, 'utf8'),
|
|
107
|
+
maxBytes,
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
127
112
|
}
|
|
128
113
|
|
|
129
|
-
function
|
|
114
|
+
function normalizeUsage(usage = {}, fallback = {}) {
|
|
115
|
+
const inputTokens = Number.isFinite(Number(usage?.inputTokens)) ? Number(usage.inputTokens) : Number(fallback.inputTokens || 0);
|
|
116
|
+
const outputTokens = Number.isFinite(Number(usage?.outputTokens)) ? Number(usage.outputTokens) : Number(fallback.outputTokens || 0);
|
|
117
|
+
const cacheReadTokens = Number.isFinite(Number(usage?.cacheReadTokens)) ? Number(usage.cacheReadTokens) : Number(fallback.cacheReadTokens || 0);
|
|
118
|
+
const cacheWriteTokens = Number.isFinite(Number(usage?.cacheWriteTokens)) ? Number(usage.cacheWriteTokens) : Number(fallback.cacheWriteTokens || 0);
|
|
119
|
+
const totalInputTokens = Number.isFinite(Number(usage?.totalInputTokens))
|
|
120
|
+
? Number(usage.totalInputTokens)
|
|
121
|
+
: inputTokens + cacheReadTokens + cacheWriteTokens;
|
|
122
|
+
return {
|
|
123
|
+
inputTokens,
|
|
124
|
+
outputTokens,
|
|
125
|
+
cacheReadTokens,
|
|
126
|
+
cacheWriteTokens,
|
|
127
|
+
totalInputTokens,
|
|
128
|
+
totalTokens: Number.isFinite(Number(usage?.totalTokens))
|
|
129
|
+
? Number(usage.totalTokens)
|
|
130
|
+
: totalInputTokens + outputTokens,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stableEqual(a, b) {
|
|
135
|
+
try { return JSON.stringify(a) === JSON.stringify(b); }
|
|
136
|
+
catch { return false; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function jsonByteLength(value) {
|
|
140
|
+
try { return Buffer.byteLength(JSON.stringify(value), 'utf8'); }
|
|
141
|
+
catch { return Infinity; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function rawRequestSentinel(reason, value = null, maxBytes = MAX_RAW_REQUEST_BYTES) {
|
|
145
|
+
const preview = typeof value === 'string'
|
|
146
|
+
? truncateText(value, Math.min(64 * 1024, maxBytes))
|
|
147
|
+
: null;
|
|
148
|
+
const originalBytes = value == null ? null : jsonByteLength(value);
|
|
130
149
|
return {
|
|
131
150
|
__truncated: true,
|
|
132
|
-
|
|
133
|
-
|
|
151
|
+
reason,
|
|
152
|
+
...(originalBytes != null ? { originalBytes } : {}),
|
|
153
|
+
maxBytes,
|
|
154
|
+
...(preview ? { preview } : {}),
|
|
134
155
|
};
|
|
135
156
|
}
|
|
136
157
|
|
|
137
|
-
function
|
|
158
|
+
function boundRawValue(value, reason = 'raw_request_budget') {
|
|
138
159
|
if (value == null) return value;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
160
|
+
const cloned = typeof value === 'string' ? value : cloneJsonValue(value);
|
|
161
|
+
if (cloned == null) return null;
|
|
162
|
+
if (jsonByteLength(cloned) <= MAX_RAW_REQUEST_BYTES) return cloned;
|
|
163
|
+
return rawRequestSentinel(reason, value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildRawRequestBase(value) {
|
|
167
|
+
if (value == null) return null;
|
|
168
|
+
if (typeof value === 'string') return truncateText(value, MAX_RAW_REQUEST_BYTES);
|
|
169
|
+
if (!isPlainObject(value)) return boundRawValue(value);
|
|
170
|
+
const base = {};
|
|
171
|
+
for (const [key, item] of Object.entries(value)) {
|
|
172
|
+
if (key === 'body' && isPlainObject(item)) {
|
|
173
|
+
const body = {};
|
|
174
|
+
for (const [bodyKey, bodyValue] of Object.entries(item)) {
|
|
175
|
+
body[bodyKey] = boundRawValue(bodyValue, `raw_request_body_${bodyKey}_budget`);
|
|
176
|
+
}
|
|
177
|
+
base.body = body;
|
|
178
|
+
} else {
|
|
179
|
+
base[key] = boundRawValue(item, `raw_request_${key}_budget`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return base;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildRawMessagesDelta(previousMessages, nextMessages) {
|
|
186
|
+
if (!Array.isArray(previousMessages) || !Array.isArray(nextMessages)) return null;
|
|
187
|
+
const prefix = messagesPrefixLength(previousMessages, nextMessages);
|
|
188
|
+
if (prefix === previousMessages.length && prefix <= nextMessages.length) {
|
|
189
|
+
return { messagesFrom: prefix, messagesAppend: boundRawValue(nextMessages.slice(prefix), 'raw_request_messages_append_budget') };
|
|
190
|
+
}
|
|
191
|
+
return { messages: boundRawValue(nextMessages, 'raw_request_messages_budget') };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function rawComparableRequest(value) {
|
|
195
|
+
if (value == null) return null;
|
|
196
|
+
if (!isPlainObject(value)) return value;
|
|
197
|
+
const out = { ...value };
|
|
198
|
+
if (isPlainObject(value.body)) out.body = { ...value.body };
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildRawRequestDelta(previous, next) {
|
|
203
|
+
if (next == null) return previous == null ? null : { replacement: null };
|
|
204
|
+
if (previous == null) return { base: buildRawRequestBase(next) };
|
|
205
|
+
const comparablePrevious = rawComparableRequest(previous);
|
|
206
|
+
const comparableNext = rawComparableRequest(next);
|
|
207
|
+
if (typeof comparablePrevious === 'string' || typeof comparableNext === 'string') {
|
|
208
|
+
return comparablePrevious === comparableNext ? null : { replacement: rawRequestSentinel('raw_request_string_replaced', comparableNext) };
|
|
209
|
+
}
|
|
210
|
+
if (!isPlainObject(comparablePrevious) || !isPlainObject(comparableNext)) {
|
|
211
|
+
return stableEqual(comparablePrevious, comparableNext) ? null : { replacement: rawRequestSentinel('raw_request_replaced') };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const delta = { set: {}, body: {} };
|
|
215
|
+
for (const key of Object.keys(comparableNext)) {
|
|
216
|
+
if (key === 'body') continue;
|
|
217
|
+
if (!stableEqual(comparablePrevious[key], comparableNext[key])) delta.set[key] = boundRawValue(comparableNext[key], `raw_request_${key}_budget`);
|
|
218
|
+
}
|
|
219
|
+
for (const key of Object.keys(comparablePrevious)) {
|
|
220
|
+
if (key !== 'body' && !Object.prototype.hasOwnProperty.call(comparableNext, key)) delta.set[key] = null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const prevBody = isPlainObject(comparablePrevious.body) ? comparablePrevious.body : null;
|
|
224
|
+
const nextBody = isPlainObject(comparableNext.body) ? comparableNext.body : null;
|
|
225
|
+
if (prevBody && nextBody) {
|
|
226
|
+
for (const key of Object.keys(nextBody)) {
|
|
227
|
+
if (key === 'messages') continue;
|
|
228
|
+
if (!stableEqual(prevBody[key], nextBody[key])) delta.body[key] = boundRawValue(nextBody[key], `raw_request_body_${key}_budget`);
|
|
229
|
+
}
|
|
230
|
+
for (const key of Object.keys(prevBody)) {
|
|
231
|
+
if (key !== 'messages' && !Object.prototype.hasOwnProperty.call(nextBody, key)) delta.body[key] = null;
|
|
232
|
+
}
|
|
233
|
+
const msgDelta = buildRawMessagesDelta(prevBody.messages, nextBody.messages);
|
|
234
|
+
if (msgDelta) Object.assign(delta.body, msgDelta);
|
|
235
|
+
} else if (!stableEqual(previous.body, next.body)) {
|
|
236
|
+
delta.set.body = rawRequestSentinel('raw_request_body_replaced');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (Object.keys(delta.set).length === 0) delete delta.set;
|
|
240
|
+
if (Object.keys(delta.body).length === 0) delete delta.body;
|
|
241
|
+
if (!delta.set && !delta.body) return null;
|
|
242
|
+
if (jsonByteLength(delta) > MAX_RAW_REQUEST_BYTES) {
|
|
243
|
+
return { replacement: rawRequestSentinel('raw_request_delta_budget') };
|
|
146
244
|
}
|
|
245
|
+
return delta;
|
|
147
246
|
}
|
|
148
247
|
|
|
149
|
-
function
|
|
150
|
-
if (
|
|
248
|
+
function applyRawRequestDelta(previous, delta) {
|
|
249
|
+
if (!delta) return previous ?? null;
|
|
250
|
+
if (Object.prototype.hasOwnProperty.call(delta, 'base')) return cloneJsonValue(delta.base) ?? delta.base ?? null;
|
|
251
|
+
if (Object.prototype.hasOwnProperty.call(delta, 'replacement')) return cloneJsonValue(delta.replacement) ?? delta.replacement ?? null;
|
|
252
|
+
const next = isPlainObject(previous) ? cloneJsonValue(previous) || {} : {};
|
|
253
|
+
if (isPlainObject(delta.set)) {
|
|
254
|
+
for (const [key, value] of Object.entries(delta.set)) next[key] = cloneJsonValue(value) ?? value;
|
|
255
|
+
}
|
|
256
|
+
if (isPlainObject(delta.body)) {
|
|
257
|
+
const body = isPlainObject(next.body) ? { ...next.body } : {};
|
|
258
|
+
for (const [key, value] of Object.entries(delta.body)) {
|
|
259
|
+
if (key === 'messagesFrom' || key === 'messagesAppend' || key === 'messages') continue;
|
|
260
|
+
body[key] = cloneJsonValue(value) ?? value;
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(delta.body.messages)) {
|
|
263
|
+
body.messages = cloneJsonValue(delta.body.messages) || [];
|
|
264
|
+
} else if (Array.isArray(delta.body.messagesAppend)) {
|
|
265
|
+
const from = Number.isFinite(Number(delta.body.messagesFrom)) ? Number(delta.body.messagesFrom) : (Array.isArray(body.messages) ? body.messages.length : 0);
|
|
266
|
+
body.messages = (Array.isArray(body.messages) ? body.messages.slice(0, from) : []).concat(cloneJsonValue(delta.body.messagesAppend) || []);
|
|
267
|
+
}
|
|
268
|
+
next.body = body;
|
|
269
|
+
}
|
|
270
|
+
return next;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function messagesPrefixLength(prevMessages, nextMessages) {
|
|
274
|
+
if (!Array.isArray(prevMessages) || !Array.isArray(nextMessages)) return 0;
|
|
275
|
+
const max = Math.min(prevMessages.length, nextMessages.length);
|
|
276
|
+
let i = 0;
|
|
277
|
+
for (; i < max; i++) {
|
|
278
|
+
if (!stableEqual(prevMessages[i], nextMessages[i])) break;
|
|
279
|
+
}
|
|
280
|
+
return i;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildRequestSnapshot(info = {}) {
|
|
151
284
|
return {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
response: truncateJsonValue(eventData.response),
|
|
156
|
-
rawRequest: truncateJsonValue(eventData.rawRequest),
|
|
157
|
-
rawResponse: truncateJsonValue(eventData.rawResponse),
|
|
285
|
+
systemPrompt: truncateText(info.systemPrompt || '', MAX_TEXT_BYTES),
|
|
286
|
+
messages: Array.isArray(info.messages) ? cloneJsonValue(info.messages) : [],
|
|
287
|
+
rawRequest: info.rawRequest ?? null,
|
|
158
288
|
};
|
|
159
289
|
}
|
|
160
290
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
291
|
+
function buildRequestDelta(previous, next) {
|
|
292
|
+
if (!previous) {
|
|
293
|
+
return {
|
|
294
|
+
base: true,
|
|
295
|
+
systemPrompt: next.systemPrompt || '',
|
|
296
|
+
messages: Array.isArray(next.messages) ? next.messages : [],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const delta = {};
|
|
300
|
+
if ((next.systemPrompt || '') !== (previous.systemPrompt || '')) {
|
|
301
|
+
delta.systemPrompt = next.systemPrompt || '';
|
|
302
|
+
}
|
|
303
|
+
const prevMessages = Array.isArray(previous.messages) ? previous.messages : [];
|
|
304
|
+
const nextMessages = Array.isArray(next.messages) ? next.messages : [];
|
|
305
|
+
const prefix = messagesPrefixLength(prevMessages, nextMessages);
|
|
306
|
+
if (prefix === prevMessages.length && prefix <= nextMessages.length) {
|
|
307
|
+
const appended = nextMessages.slice(prefix);
|
|
308
|
+
delta.messagesFrom = prefix;
|
|
309
|
+
delta.messagesAppend = appended;
|
|
310
|
+
} else {
|
|
311
|
+
delta.messages = nextMessages;
|
|
312
|
+
}
|
|
313
|
+
const rawRequestDelta = buildRawRequestDelta(previous.rawRequest, next.rawRequest);
|
|
314
|
+
if (rawRequestDelta) delta.rawRequestDelta = rawRequestDelta;
|
|
315
|
+
return delta;
|
|
316
|
+
}
|
|
167
317
|
|
|
168
|
-
|
|
169
|
-
|
|
318
|
+
function applyRequestDelta(previous, delta = {}) {
|
|
319
|
+
const base = previous || { systemPrompt: '', messages: [], rawRequest: null };
|
|
320
|
+
const next = {
|
|
321
|
+
systemPrompt: base.systemPrompt || '',
|
|
322
|
+
messages: Array.isArray(base.messages) ? [...base.messages] : [],
|
|
323
|
+
rawRequest: base.rawRequest ?? null,
|
|
324
|
+
};
|
|
325
|
+
if (delta.base) {
|
|
326
|
+
return {
|
|
327
|
+
systemPrompt: delta.systemPrompt || '',
|
|
328
|
+
messages: Array.isArray(delta.messages) ? delta.messages : [],
|
|
329
|
+
rawRequest: base.rawRequest ?? null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (typeof delta.systemPrompt === 'string') next.systemPrompt = delta.systemPrompt;
|
|
333
|
+
if (Array.isArray(delta.messages)) {
|
|
334
|
+
next.messages = delta.messages;
|
|
335
|
+
} else if (Array.isArray(delta.messagesAppend)) {
|
|
336
|
+
const from = Number.isFinite(Number(delta.messagesFrom)) ? Number(delta.messagesFrom) : next.messages.length;
|
|
337
|
+
next.messages = next.messages.slice(0, from).concat(delta.messagesAppend);
|
|
338
|
+
}
|
|
339
|
+
if (Object.prototype.hasOwnProperty.call(delta, 'rawRequestDelta')) next.rawRequest = applyRawRequestDelta(next.rawRequest, delta.rawRequestDelta);
|
|
340
|
+
return next;
|
|
341
|
+
}
|
|
170
342
|
|
|
171
|
-
|
|
172
|
-
|
|
343
|
+
function sessionRequestsDir(rootDir, sessionId) {
|
|
344
|
+
if (sessionId) return join(rootDir, 'sessions', safeDirComponent(sessionId), 'debug', 'requests');
|
|
345
|
+
return join(rootDir, 'debug', 'requests');
|
|
346
|
+
}
|
|
173
347
|
|
|
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 ───────────────────────────────────────────────
|
|
348
|
+
function requestFilePath(requestDir) {
|
|
349
|
+
return join(requestDir, 'trace.json');
|
|
350
|
+
}
|
|
219
351
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return
|
|
352
|
+
function tracePathFor(rootDir, sessionId, requestKey) {
|
|
353
|
+
return requestFilePath(join(sessionRequestsDir(rootDir, sessionId), safeDirComponent(requestKey, 'request')));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function summarizeTrace(trace, detailsLoaded = false) {
|
|
357
|
+
const loops = Array.isArray(trace?.loops) ? trace.loops : [];
|
|
358
|
+
const usage = loops.reduce((acc, loop) => {
|
|
359
|
+
const u = normalizeUsage(loop?.usage || {});
|
|
360
|
+
acc.totalMs += Number(loop?.latencyMs || 0);
|
|
361
|
+
acc.totalTokens += u.totalTokens || 0;
|
|
362
|
+
acc.summaryInputTokens += u.totalInputTokens || 0;
|
|
363
|
+
acc.summaryOutputTokens += u.outputTokens || 0;
|
|
364
|
+
return acc;
|
|
365
|
+
}, { totalMs: 0, totalTokens: 0, summaryInputTokens: 0, summaryOutputTokens: 0 });
|
|
366
|
+
return {
|
|
367
|
+
turnId: trace?.requestId || trace?.traceId || '',
|
|
368
|
+
userPrompt: trace?.userPrompt || '',
|
|
369
|
+
sessionId: trace?.sessionId || null,
|
|
370
|
+
vpId: trace?.vpId || null,
|
|
371
|
+
threadId: trace?.threadId || null,
|
|
372
|
+
openedAt: trace?.openedAt || 0,
|
|
373
|
+
closedAt: trace?.closedAt || null,
|
|
374
|
+
totalMs: usage.totalMs,
|
|
375
|
+
totalTokens: usage.totalTokens,
|
|
376
|
+
summaryInputTokens: usage.summaryInputTokens,
|
|
377
|
+
summaryOutputTokens: usage.summaryOutputTokens,
|
|
378
|
+
loopCount: loops.length,
|
|
379
|
+
memoryLoaded: null,
|
|
380
|
+
memoryAdjust: null,
|
|
381
|
+
tools: Array.isArray(trace?.tools) ? trace.tools.map(t => ({
|
|
382
|
+
loopNumber: t.loopNumber || 0,
|
|
383
|
+
callId: t.toolCallId || t.id || null,
|
|
384
|
+
traceToolId: t.id || null,
|
|
385
|
+
name: t.toolName || t.name || '?',
|
|
386
|
+
toolOutput: t.toolOutput == null ? null : String(t.toolOutput),
|
|
387
|
+
durationMs: t.durationMs || 0,
|
|
388
|
+
isError: !!t.isError,
|
|
389
|
+
})) : [],
|
|
390
|
+
detailsLoaded,
|
|
391
|
+
requestBase: trace?.baseRequest || null,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function expandTrace(trace) {
|
|
396
|
+
const turnsById = new Map([[trace.requestId || trace.traceId, summarizeTrace(trace, true)]]);
|
|
397
|
+
let snapshot = null;
|
|
398
|
+
const loops = [];
|
|
399
|
+
for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
|
|
400
|
+
snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
|
|
401
|
+
const usage = normalizeUsage(loop?.usage || {});
|
|
402
|
+
loops.push({
|
|
403
|
+
turnId: trace.requestId || trace.traceId,
|
|
404
|
+
loopInstanceId: loop.loopInstanceId || loop.turnRowId || null,
|
|
405
|
+
loopNumber: loop.loopNumber || 0,
|
|
406
|
+
model: loop.model || null,
|
|
407
|
+
systemPrompt: snapshot.systemPrompt || '',
|
|
408
|
+
messages: Array.isArray(snapshot.messages) ? snapshot.messages : [],
|
|
409
|
+
response: loop.response || '',
|
|
410
|
+
toolCalls: Array.isArray(loop.toolCalls) ? loop.toolCalls : [],
|
|
411
|
+
usage,
|
|
412
|
+
latencyMs: loop.latencyMs || 0,
|
|
413
|
+
ttfbMs: loop.ttfbMs || null,
|
|
414
|
+
stopReason: loop.stopReason || null,
|
|
415
|
+
at: loop.at || null,
|
|
416
|
+
rawRequest: snapshot.rawRequest ?? null,
|
|
417
|
+
rawResponse: loop.rawResponse ?? null,
|
|
418
|
+
requestDelta: loop.requestDelta || {},
|
|
419
|
+
requestBase: trace.baseRequest || null,
|
|
420
|
+
sessionId: trace.sessionId || null,
|
|
421
|
+
vpId: trace.vpId || null,
|
|
422
|
+
threadId: trace.threadId || null,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return { loops, turns: Array.from(turnsById.values()) };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function traceToLegacyRows(trace) {
|
|
429
|
+
let snapshot = null;
|
|
430
|
+
return (Array.isArray(trace?.loops) ? trace.loops : []).map((loop) => {
|
|
431
|
+
snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
|
|
432
|
+
const u = normalizeUsage(loop?.usage || {});
|
|
433
|
+
return {
|
|
434
|
+
id: loop.turnRowId || loop.loopInstanceId || randomUUID(),
|
|
435
|
+
trace_id: trace.traceId || trace.requestId,
|
|
436
|
+
message_id: trace.messageId || null,
|
|
437
|
+
mode: trace.mode || null,
|
|
438
|
+
turn_number: loop.loopNumber || 0,
|
|
439
|
+
model: loop.model || null,
|
|
440
|
+
input_tokens: u.inputTokens || 0,
|
|
441
|
+
output_tokens: u.outputTokens || 0,
|
|
442
|
+
cache_read_tokens: u.cacheReadTokens || 0,
|
|
443
|
+
cache_write_tokens: u.cacheWriteTokens || 0,
|
|
444
|
+
stop_reason: loop.stopReason || null,
|
|
445
|
+
latency_ms: loop.latencyMs || 0,
|
|
446
|
+
response_text: loop.response || '',
|
|
447
|
+
started_at: loop.startedAt || trace.openedAt || 0,
|
|
448
|
+
ended_at: loop.at || trace.closedAt || null,
|
|
449
|
+
group_id: trace.sessionId || null,
|
|
450
|
+
vp_id: trace.vpId || null,
|
|
451
|
+
thread_id: trace.threadId || null,
|
|
452
|
+
system_prompt: snapshot.systemPrompt || '',
|
|
453
|
+
messages_json: JSON.stringify(snapshot.messages || []),
|
|
454
|
+
tool_calls_json: JSON.stringify(loop.toolCalls || []),
|
|
455
|
+
usage_json: JSON.stringify(u),
|
|
456
|
+
ttfb_ms: loop.ttfbMs || null,
|
|
457
|
+
raw_request: typeof snapshot.rawRequest === 'string' ? snapshot.rawRequest : JSON.stringify(snapshot.rawRequest ?? null),
|
|
458
|
+
raw_response: typeof loop.rawResponse === 'string' ? loop.rawResponse : JSON.stringify(loop.rawResponse ?? null),
|
|
459
|
+
user_prompt: trace.userPrompt || '',
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function traceToolToLegacy(trace, tool) {
|
|
465
|
+
return {
|
|
466
|
+
id: tool.id || randomUUID(),
|
|
467
|
+
turn_id: tool.turnRowId || null,
|
|
468
|
+
tool_name: tool.toolName || tool.name || '?',
|
|
469
|
+
tool_input: tool.toolInput == null ? null : String(tool.toolInput),
|
|
470
|
+
tool_output: tool.toolOutput == null ? null : String(tool.toolOutput),
|
|
471
|
+
tool_call_id: tool.toolCallId || null,
|
|
472
|
+
duration_ms: tool.durationMs || 0,
|
|
473
|
+
is_error: tool.isError ? 1 : 0,
|
|
474
|
+
created_at: tool.createdAt || trace.openedAt || 0,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function collectTraceFiles(rootDir, sessionId = null) {
|
|
479
|
+
const files = [];
|
|
480
|
+
const addFromRequestsDir = (requestsDir) => {
|
|
481
|
+
let entries = [];
|
|
482
|
+
try { entries = readdirSync(requestsDir, { withFileTypes: true }); }
|
|
483
|
+
catch { return; }
|
|
484
|
+
for (const entry of entries) {
|
|
485
|
+
if (!entry.isDirectory()) continue;
|
|
486
|
+
const file = requestFilePath(join(requestsDir, entry.name));
|
|
487
|
+
if (existsSync(file)) files.push(file);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
if (sessionId) {
|
|
491
|
+
addFromRequestsDir(sessionRequestsDir(rootDir, sessionId));
|
|
492
|
+
return files;
|
|
493
|
+
}
|
|
494
|
+
addFromRequestsDir(sessionRequestsDir(rootDir, null));
|
|
495
|
+
const sessionsRoot = join(rootDir, 'sessions');
|
|
496
|
+
let sessionEntries = [];
|
|
497
|
+
try { sessionEntries = readdirSync(sessionsRoot, { withFileTypes: true }); }
|
|
498
|
+
catch { return files; }
|
|
499
|
+
for (const entry of sessionEntries) {
|
|
500
|
+
if (!entry.isDirectory()) continue;
|
|
501
|
+
addFromRequestsDir(join(sessionsRoot, entry.name, 'debug', 'requests'));
|
|
233
502
|
}
|
|
503
|
+
return files;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function readTraceSummaries(rootDir, sessionId = null) {
|
|
507
|
+
const traces = [];
|
|
508
|
+
for (const file of collectTraceFiles(rootDir, sessionId)) {
|
|
509
|
+
const trace = readJson(file);
|
|
510
|
+
if (!trace || !trace.requestId) continue;
|
|
511
|
+
traces.push({ trace, file, openedAt: Number(trace.openedAt || 0) });
|
|
512
|
+
}
|
|
513
|
+
traces.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
|
|
514
|
+
return traces;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function countDirFiles(rootDir) {
|
|
518
|
+
let files = 0;
|
|
519
|
+
let bytes = 0;
|
|
520
|
+
const walk = (dir) => {
|
|
521
|
+
let entries = [];
|
|
522
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
523
|
+
catch { return; }
|
|
524
|
+
for (const entry of entries) {
|
|
525
|
+
const p = join(dir, entry.name);
|
|
526
|
+
if (entry.isDirectory()) walk(p);
|
|
527
|
+
else {
|
|
528
|
+
files += 1;
|
|
529
|
+
try { bytes += statSync(p).size; } catch { /* ignore */ }
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
walk(rootDir);
|
|
534
|
+
return { files, bytes };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export class DebugTrace {
|
|
538
|
+
/** @type {string} */
|
|
539
|
+
#rootDir;
|
|
540
|
+
/** @type {Map<string, { requestKey: string, sessionId: string|null, traceId: string, loopNumber: number }>} */
|
|
541
|
+
#turnIndex = new Map();
|
|
542
|
+
/** @type {Map<string, object>} */
|
|
543
|
+
#requestCache = new Map();
|
|
544
|
+
/** @type {Map<string, object>} */
|
|
545
|
+
#pendingWrites = new Map();
|
|
546
|
+
/** @type {Map<string, NodeJS.Timeout>} */
|
|
547
|
+
#flushTimers = new Map();
|
|
548
|
+
/** @type {number} */
|
|
549
|
+
#sequence = 0;
|
|
234
550
|
|
|
235
551
|
/**
|
|
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
|
|
552
|
+
* @param {string} tracePath — Back-compatible path. If it looks like a DB
|
|
553
|
+
* file, traces are stored in a sibling `debug/` directory.
|
|
239
554
|
*/
|
|
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
|
-
} = {}) {
|
|
555
|
+
constructor(tracePath) {
|
|
556
|
+
const rootDir = fileTraceRoot(tracePath);
|
|
557
|
+
if (!rootDir) throw new Error('DebugTrace requires a storage path');
|
|
558
|
+
this.#rootDir = rootDir;
|
|
559
|
+
ensureDir(rootDir);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null } = {}) {
|
|
563
|
+
const turnRowId = randomUUID();
|
|
257
564
|
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),
|
|
565
|
+
const request = this.#getOrCreateRequest({
|
|
566
|
+
traceId: traceId || turnRowId,
|
|
567
|
+
turnNumber: Number(turnNumber || 0),
|
|
568
|
+
messageId,
|
|
569
|
+
mode,
|
|
570
|
+
sessionId,
|
|
571
|
+
vpId,
|
|
572
|
+
threadId,
|
|
573
|
+
userPrompt,
|
|
298
574
|
now,
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
575
|
+
turnRowId,
|
|
576
|
+
});
|
|
577
|
+
this.#turnIndex.set(turnRowId, {
|
|
578
|
+
requestKey: request.requestKey,
|
|
579
|
+
sessionId: request.sessionId || null,
|
|
580
|
+
traceId: request.traceId,
|
|
581
|
+
loopNumber: Number(turnNumber || 0),
|
|
582
|
+
});
|
|
583
|
+
return turnRowId;
|
|
308
584
|
}
|
|
309
585
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
586
|
+
endTurn(turnId, info = {}) {
|
|
587
|
+
const ctx = this.#turnIndex.get(turnId);
|
|
588
|
+
if (!ctx) return;
|
|
589
|
+
const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
|
|
590
|
+
if (!trace) return;
|
|
591
|
+
const loopNumber = ctx.loopNumber || Number(info.turnNumber || 0);
|
|
592
|
+
const snapshot = buildRequestSnapshot(info);
|
|
593
|
+
const previousSnapshot = trace._lastSnapshot || this.#reconstructLastSnapshot(trace);
|
|
594
|
+
if (!trace.baseRequest) {
|
|
595
|
+
const rawRequestBaseDelta = buildRawRequestDelta(null, snapshot.rawRequest);
|
|
596
|
+
trace.baseRequest = { ...snapshot, rawRequest: applyRawRequestDelta(null, rawRequestBaseDelta) };
|
|
597
|
+
}
|
|
598
|
+
const loopIndex = (trace.loops || []).findIndex(l => l.turnRowId === turnId);
|
|
599
|
+
const loop = {
|
|
600
|
+
loopInstanceId: turnId,
|
|
601
|
+
turnRowId: turnId,
|
|
602
|
+
loopNumber,
|
|
603
|
+
startedAt: trace.openedAt || Date.now(),
|
|
604
|
+
model: info.model || null,
|
|
605
|
+
response: truncateText(info.responseText || '', MAX_TEXT_BYTES),
|
|
606
|
+
toolCalls: cloneJsonValue(Array.isArray(info.toolCalls) ? info.toolCalls : []),
|
|
607
|
+
usage: normalizeUsage(info.usage || {}, {
|
|
608
|
+
inputTokens: info.inputTokens || 0,
|
|
609
|
+
outputTokens: info.outputTokens || 0,
|
|
610
|
+
cacheReadTokens: info.cacheReadTokens || 0,
|
|
611
|
+
cacheWriteTokens: info.cacheWriteTokens || 0,
|
|
612
|
+
}),
|
|
613
|
+
latencyMs: Number(info.latencyMs || 0),
|
|
614
|
+
ttfbMs: Number.isFinite(Number(info.ttfbMs)) ? Number(info.ttfbMs) : null,
|
|
615
|
+
stopReason: info.stopReason || null,
|
|
616
|
+
at: Date.now(),
|
|
617
|
+
rawResponse: typeof info.rawResponse === 'string'
|
|
618
|
+
? truncateText(info.rawResponse, MAX_TEXT_BYTES)
|
|
619
|
+
: safeJsonValue(info.rawResponse),
|
|
620
|
+
requestDelta: buildRequestDelta(previousSnapshot, snapshot),
|
|
621
|
+
};
|
|
622
|
+
if (loopIndex >= 0) trace.loops[loopIndex] = loop;
|
|
623
|
+
else trace.loops.push(loop);
|
|
624
|
+
trace.loops.sort((a, b) => (a.loopNumber || 0) - (b.loopNumber || 0) || String(a.turnRowId || '').localeCompare(String(b.turnRowId || '')));
|
|
625
|
+
trace.closedAt = loop.at;
|
|
626
|
+
trace.updatedAt = loop.at;
|
|
627
|
+
trace.active = info.stopReason ? !['end_turn', 'error', 'aborted'].includes(String(info.stopReason)) : false;
|
|
628
|
+
trace._lastSnapshot = snapshot;
|
|
629
|
+
if (!trace.active) this.#scheduleSave(trace);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
logTool(turnId, { toolName, toolCallId = null, toolInput = null, toolOutput = null, durationMs = null, isError = false } = {}) {
|
|
324
633
|
const id = randomUUID();
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
634
|
+
const ctx = this.#turnIndex.get(turnId);
|
|
635
|
+
if (!ctx) return id;
|
|
636
|
+
const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
|
|
637
|
+
if (!trace) return id;
|
|
638
|
+
if (!Array.isArray(trace.tools)) trace.tools = [];
|
|
639
|
+
trace.tools.push({
|
|
640
|
+
id,
|
|
641
|
+
turnRowId: turnId,
|
|
642
|
+
loopNumber: ctx.loopNumber || 0,
|
|
643
|
+
toolName: toolName || '?',
|
|
333
644
|
toolCallId,
|
|
334
|
-
|
|
335
|
-
|
|
645
|
+
toolInput: truncateText(toolInput == null ? null : String(toolInput), MAX_TOOL_INPUT),
|
|
646
|
+
toolOutput: truncateText(toolOutput == null ? null : String(toolOutput), MAX_TEXT_BYTES),
|
|
647
|
+
durationMs: Number(durationMs || 0),
|
|
648
|
+
isError: !!isError,
|
|
649
|
+
createdAt: Date.now(),
|
|
650
|
+
});
|
|
651
|
+
trace.updatedAt = Date.now();
|
|
652
|
+
if (!trace.active) this.#scheduleSave(trace);
|
|
336
653
|
return id;
|
|
337
654
|
}
|
|
338
655
|
|
|
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 }) {
|
|
656
|
+
logEvent({ traceId, eventType, eventData = null } = {}) {
|
|
345
657
|
const id = randomUUID();
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
658
|
+
const file = join(this.#rootDir, 'events.json');
|
|
659
|
+
const existingEvents = readJson(file);
|
|
660
|
+
const events = Array.isArray(existingEvents) ? existingEvents : [];
|
|
661
|
+
events.push({
|
|
662
|
+
id,
|
|
663
|
+
traceId: traceId || String(eventType || 'event'),
|
|
664
|
+
eventType: eventType || 'event',
|
|
665
|
+
eventData: safeJsonValue(eventData),
|
|
666
|
+
createdAt: Date.now(),
|
|
667
|
+
});
|
|
668
|
+
const trimmed = events.slice(-MAX_DREAM_EVENTS);
|
|
669
|
+
try { atomicWriteJson(file, trimmed); }
|
|
670
|
+
catch (err) { console.warn('[Yeaft] debug trace event write failed:', err?.message || err); }
|
|
353
671
|
return id;
|
|
354
672
|
}
|
|
355
673
|
|
|
356
|
-
/**
|
|
357
|
-
* Compatibility helper used by older engine/dream call sites.
|
|
358
|
-
* @param {string} eventType
|
|
359
|
-
* @param {unknown} eventData
|
|
360
|
-
* @returns {string}
|
|
361
|
-
*/
|
|
362
674
|
event(eventType, eventData = null) {
|
|
363
675
|
const traceId = (eventData && typeof eventData === 'object' && (eventData.turnId || eventData.runId))
|
|
364
676
|
? String(eventData.turnId || eventData.runId)
|
|
@@ -366,470 +678,323 @@ export class DebugTrace {
|
|
|
366
678
|
return this.logEvent({ traceId, eventType, eventData });
|
|
367
679
|
}
|
|
368
680
|
|
|
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
681
|
queryByMessage(messageId) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
682
|
+
this.#flushPendingSync();
|
|
683
|
+
const traces = this.#traceSummaries()
|
|
684
|
+
.filter(({ trace }) => trace.messageId === messageId)
|
|
685
|
+
.map(({ trace }) => trace);
|
|
686
|
+
return this.#expandLegacy(traces);
|
|
381
687
|
}
|
|
382
688
|
|
|
383
|
-
/**
|
|
384
|
-
* Query all data for a trace.
|
|
385
|
-
* @param {string} traceId
|
|
386
|
-
* @returns {{ turns: object[], tools: object[], events: object[] }}
|
|
387
|
-
*/
|
|
388
689
|
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 };
|
|
690
|
+
this.#flushPendingSync();
|
|
691
|
+
const traces = this.#traceSummaries()
|
|
692
|
+
.filter(({ trace }) => trace.traceId === traceId || trace.requestId === traceId)
|
|
693
|
+
.map(({ trace }) => trace);
|
|
694
|
+
return this.#expandLegacy(traces);
|
|
402
695
|
}
|
|
403
696
|
|
|
404
|
-
/**
|
|
405
|
-
* Query recent turns.
|
|
406
|
-
* @param {number} [limit=20]
|
|
407
|
-
* @returns {object[]}
|
|
408
|
-
*/
|
|
409
697
|
queryRecent(limit = 20) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
698
|
+
this.#flushPendingSync();
|
|
699
|
+
const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
|
|
700
|
+
return this.#traceSummaries()
|
|
701
|
+
.slice(-lim)
|
|
702
|
+
.reverse()
|
|
703
|
+
.flatMap(({ trace }) => traceToLegacyRows(trace));
|
|
413
704
|
}
|
|
414
705
|
|
|
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;
|
|
706
|
+
fetchRecentDebugHistory({ limit = MAX_HISTORY_LIMIT, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null } = {}) {
|
|
707
|
+
this.#flushPendingSync();
|
|
708
|
+
const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
|
|
433
709
|
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;
|
|
710
|
+
const traces = this.#traceSummaries(sessionId)
|
|
711
|
+
.filter(({ trace }) => !threadId || trace.threadId === threadId)
|
|
712
|
+
.map(({ trace }) => trace);
|
|
713
|
+
const dreamEvents = this.#readDreamEvents({ sessionId, dreamLimit });
|
|
714
|
+
if (requestedDetailTurnId) {
|
|
715
|
+
const trace = traces.find(t => t.requestId === requestedDetailTurnId || t.traceId === requestedDetailTurnId);
|
|
716
|
+
if (!trace) return { loops: [], turns: [], dreamEvents, hasMore: false, limit: 0, indexOnly: false, detailTurnId: requestedDetailTurnId };
|
|
717
|
+
const expanded = expandTrace(trace);
|
|
718
|
+
return { ...expanded, dreamEvents, hasMore: false, limit: expanded.loops.length, indexOnly: false, detailTurnId: requestedDetailTurnId };
|
|
719
|
+
}
|
|
720
|
+
const selected = traces.slice(-lim);
|
|
721
|
+
if (indexOnly) {
|
|
447
722
|
return {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
? Number(usage.totalTokens)
|
|
455
|
-
: totalInputTokens + outputTokens,
|
|
723
|
+
loops: [],
|
|
724
|
+
turns: selected.map(trace => summarizeTrace(trace, false)),
|
|
725
|
+
dreamEvents,
|
|
726
|
+
hasMore: traces.length > selected.length,
|
|
727
|
+
limit: lim,
|
|
728
|
+
indexOnly: true,
|
|
456
729
|
};
|
|
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
730
|
}
|
|
731
|
+
const expanded = selected.reduce((acc, trace) => {
|
|
732
|
+
const item = expandTrace(trace);
|
|
733
|
+
acc.loops.push(...item.loops);
|
|
734
|
+
acc.turns.push(...item.turns);
|
|
735
|
+
return acc;
|
|
736
|
+
}, { loops: [], turns: [] });
|
|
737
|
+
return { ...expanded, dreamEvents, hasMore: traces.length > selected.length, limit: lim, indexOnly: false };
|
|
738
|
+
}
|
|
490
739
|
|
|
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
|
-
});
|
|
740
|
+
queryTools({ name = null, since = null } = {}) {
|
|
741
|
+
this.#flushPendingSync();
|
|
742
|
+
const tools = [];
|
|
743
|
+
for (const { trace } of this.#traceSummaries()) {
|
|
744
|
+
for (const tool of Array.isArray(trace.tools) ? trace.tools : []) {
|
|
745
|
+
const row = traceToolToLegacy(trace, tool);
|
|
746
|
+
if (name && row.tool_name !== name) continue;
|
|
747
|
+
if (since && row.created_at < since) continue;
|
|
748
|
+
tools.push(row);
|
|
608
749
|
}
|
|
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
750
|
}
|
|
751
|
+
tools.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
|
752
|
+
return tools.slice(0, 100);
|
|
753
|
+
}
|
|
627
754
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
return { loops: [], turns: summarizeRows(rows), dreamEvents, hasMore: false, limit: lim, indexOnly: true };
|
|
639
|
-
}
|
|
755
|
+
search(keyword) {
|
|
756
|
+
this.#flushPendingSync();
|
|
757
|
+
const needle = String(keyword || '').toLowerCase();
|
|
758
|
+
if (!needle) return [];
|
|
759
|
+
return this.#traceSummaries()
|
|
760
|
+
.filter(({ trace }) => JSON.stringify(trace).toLowerCase().includes(needle))
|
|
761
|
+
.slice(-50)
|
|
762
|
+
.reverse()
|
|
763
|
+
.flatMap(({ trace }) => traceToLegacyRows(trace));
|
|
764
|
+
}
|
|
640
765
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
const expanded = expandRows(rows);
|
|
651
|
-
return { ...expanded, dreamEvents, hasMore, limit: lim, indexOnly: false };
|
|
766
|
+
stats() {
|
|
767
|
+
this.#flushPendingSync();
|
|
768
|
+
const traces = this.#traceSummaries().map(({ trace }) => trace);
|
|
769
|
+
const turnCount = traces.reduce((n, trace) => n + (Array.isArray(trace.loops) ? trace.loops.length : 0), 0);
|
|
770
|
+
const toolCount = traces.reduce((n, trace) => n + (Array.isArray(trace.tools) ? trace.tools.length : 0), 0);
|
|
771
|
+
const events = readJson(join(this.#rootDir, 'events.json'));
|
|
772
|
+
const eventCount = Array.isArray(events) ? events.length : 0;
|
|
773
|
+
const { bytes } = countDirFiles(this.#rootDir);
|
|
774
|
+
return { turnCount, toolCount, eventCount, dbSizeBytes: bytes, fileSizeBytes: bytes, requestCount: traces.length };
|
|
652
775
|
}
|
|
653
776
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
777
|
+
cleanup(retention = REQUEST_RETENTION) {
|
|
778
|
+
this.#flushPendingSync();
|
|
779
|
+
const keep = Math.max(1, Math.min(REQUEST_RETENTION, Number(retention) || REQUEST_RETENTION));
|
|
780
|
+
const before = readTraceSummaries(this.#rootDir).length;
|
|
781
|
+
this.#pruneAll(keep);
|
|
782
|
+
const after = readTraceSummaries(this.#rootDir).length;
|
|
783
|
+
return { deletedTurns: Math.max(0, before - after), deletedTools: 0, deletedEvents: 0, deletedRequests: Math.max(0, before - after) };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
compact() {
|
|
787
|
+
this.#flushPendingSync();
|
|
788
|
+
const before = countDirFiles(this.#rootDir).bytes;
|
|
789
|
+
this.cleanup(REQUEST_RETENTION);
|
|
790
|
+
const after = countDirFiles(this.#rootDir).bytes;
|
|
791
|
+
return { before, after };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
purge() {
|
|
795
|
+
this.#flushPendingSync();
|
|
796
|
+
try { rmSync(this.#rootDir, { recursive: true, force: true }); }
|
|
797
|
+
catch { /* ignore */ }
|
|
798
|
+
ensureDir(this.#rootDir);
|
|
799
|
+
this.#turnIndex.clear();
|
|
800
|
+
this.#requestCache.clear();
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
close() { this.#flushPendingSync(); }
|
|
804
|
+
|
|
805
|
+
#getOrCreateRequest({ traceId, turnNumber, messageId, mode, sessionId, vpId, threadId, userPrompt, now, turnRowId }) {
|
|
806
|
+
const normalizedSessionId = sessionId || null;
|
|
807
|
+
let all = null;
|
|
808
|
+
const isUsableExisting = (t) => (
|
|
809
|
+
t?.sessionId === normalizedSessionId
|
|
810
|
+
&& t?.traceId === traceId
|
|
811
|
+
&& !(turnNumber === 1 && (t.loops || []).some(l => l.loopNumber === 1))
|
|
812
|
+
);
|
|
813
|
+
const newestTrace = (items) => items
|
|
814
|
+
.filter(isUsableExisting)
|
|
815
|
+
.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.requestKey || '').localeCompare(String(b.requestKey || '')))
|
|
816
|
+
.at(-1) || null;
|
|
817
|
+
let existing = newestTrace(Array.from(this.#requestCache.values()));
|
|
818
|
+
if (!existing) {
|
|
819
|
+
all = this.#traceSummaries(normalizedSessionId).map(({ trace }) => trace);
|
|
820
|
+
existing = newestTrace(all);
|
|
664
821
|
}
|
|
665
|
-
if (
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
822
|
+
if (existing) {
|
|
823
|
+
existing.updatedAt = now;
|
|
824
|
+
this.#requestCache.set(existing.requestKey, existing);
|
|
825
|
+
return existing;
|
|
669
826
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
827
|
+
const seq = (this.#sequence = (this.#sequence + 1) % 1_000_000);
|
|
828
|
+
const requestKey = `${String(now).padStart(13, '0')}-${String(seq).padStart(6, '0')}-${safeDirComponent(traceId || turnRowId, 'request')}-${turnRowId.slice(0, 8)}`;
|
|
829
|
+
const requestId = turnNumber === 1 && all.some(t => t.traceId === traceId) ? turnRowId : traceId;
|
|
830
|
+
const trace = {
|
|
831
|
+
version: TRACE_VERSION,
|
|
832
|
+
requestKey,
|
|
833
|
+
requestId,
|
|
834
|
+
traceId,
|
|
835
|
+
messageId,
|
|
836
|
+
mode,
|
|
837
|
+
sessionId: normalizedSessionId,
|
|
838
|
+
vpId: vpId || null,
|
|
839
|
+
threadId: threadId || null,
|
|
840
|
+
userPrompt: truncateText(userPrompt || '', MAX_TEXT_BYTES),
|
|
841
|
+
openedAt: now,
|
|
842
|
+
closedAt: null,
|
|
843
|
+
updatedAt: now,
|
|
844
|
+
active: true,
|
|
845
|
+
baseRequest: null,
|
|
846
|
+
loops: [],
|
|
847
|
+
tools: [],
|
|
848
|
+
};
|
|
849
|
+
this.#requestCache.set(requestKey, trace);
|
|
850
|
+
return trace;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
#loadRequest(sessionId, requestKey) {
|
|
854
|
+
const cached = this.#requestCache.get(requestKey);
|
|
855
|
+
if (cached) return cached;
|
|
856
|
+
const file = tracePathFor(this.#rootDir, sessionId, requestKey);
|
|
857
|
+
const trace = readJson(file);
|
|
858
|
+
if (trace) this.#requestCache.set(requestKey, trace);
|
|
859
|
+
return trace;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
#traceSummaries(sessionId = null) {
|
|
863
|
+
const byKey = new Map(readTraceSummaries(this.#rootDir, sessionId).map(item => [item.trace.requestKey, item]));
|
|
864
|
+
for (const trace of this.#requestCache.values()) {
|
|
865
|
+
if (sessionId && trace.sessionId !== sessionId) continue;
|
|
866
|
+
if (!trace?.requestId || !trace?.requestKey) continue;
|
|
867
|
+
byKey.set(trace.requestKey, {
|
|
868
|
+
trace,
|
|
869
|
+
file: this.#traceFile(trace),
|
|
870
|
+
openedAt: Number(trace.openedAt || 0),
|
|
871
|
+
});
|
|
674
872
|
}
|
|
675
|
-
return
|
|
676
|
-
|
|
677
|
-
`).all();
|
|
873
|
+
return Array.from(byKey.values())
|
|
874
|
+
.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
|
|
678
875
|
}
|
|
679
876
|
|
|
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);
|
|
877
|
+
#traceWriteKey(trace) {
|
|
878
|
+
return `${trace.sessionId || ''}::${trace.requestKey}`;
|
|
693
879
|
}
|
|
694
880
|
|
|
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 };
|
|
881
|
+
#traceFile(trace) {
|
|
882
|
+
return tracePathFor(this.#rootDir, trace.sessionId || null, trace.requestKey);
|
|
708
883
|
}
|
|
709
884
|
|
|
710
|
-
|
|
885
|
+
#serializableTrace(trace) {
|
|
886
|
+
const toWrite = { ...trace };
|
|
887
|
+
delete toWrite._lastSnapshot;
|
|
888
|
+
return toWrite;
|
|
889
|
+
}
|
|
711
890
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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); }
|
|
891
|
+
#scheduleSave(trace) {
|
|
892
|
+
if (!trace?.requestKey) return;
|
|
893
|
+
const key = this.#traceWriteKey(trace);
|
|
894
|
+
this.#pendingWrites.set(key, trace);
|
|
895
|
+
this.#requestCache.set(trace.requestKey, trace);
|
|
896
|
+
if (this.#flushTimers.has(key)) return;
|
|
897
|
+
const timer = setTimeout(() => this.#flushOneAsync(key), TRACE_FLUSH_DELAY_MS);
|
|
898
|
+
this.#flushTimers.set(key, timer);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async #flushOneAsync(key) {
|
|
902
|
+
const timer = this.#flushTimers.get(key);
|
|
903
|
+
if (timer) clearTimeout(timer);
|
|
904
|
+
this.#flushTimers.delete(key);
|
|
905
|
+
const trace = this.#pendingWrites.get(key);
|
|
906
|
+
if (!trace) return;
|
|
907
|
+
this.#pendingWrites.delete(key);
|
|
908
|
+
try {
|
|
909
|
+
await atomicWriteJsonAsync(this.#traceFile(trace), this.#serializableTrace(trace));
|
|
910
|
+
this.#pruneSession(trace.sessionId || null, REQUEST_RETENTION);
|
|
911
|
+
} catch (err) {
|
|
912
|
+
console.warn('[Yeaft] debug trace write failed:', err?.message || err);
|
|
750
913
|
}
|
|
751
|
-
return { deletedTurns, deletedTools, deletedEvents };
|
|
752
914
|
}
|
|
753
915
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
this.#
|
|
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 };
|
|
916
|
+
#flushPendingSync() {
|
|
917
|
+
const entries = Array.from(this.#pendingWrites.entries());
|
|
918
|
+
if (entries.length === 0) return;
|
|
919
|
+
for (const [key, trace] of entries) {
|
|
920
|
+
const timer = this.#flushTimers.get(key);
|
|
921
|
+
if (timer) clearTimeout(timer);
|
|
922
|
+
this.#flushTimers.delete(key);
|
|
923
|
+
this.#pendingWrites.delete(key);
|
|
924
|
+
try {
|
|
925
|
+
atomicWriteJson(this.#traceFile(trace), this.#serializableTrace(trace));
|
|
926
|
+
} catch (err) {
|
|
927
|
+
console.warn('[Yeaft] debug trace write failed:', err?.message || err);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
this.#pruneAll(REQUEST_RETENTION);
|
|
777
931
|
}
|
|
778
932
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
933
|
+
#reconstructLastSnapshot(trace) {
|
|
934
|
+
let snapshot = null;
|
|
935
|
+
for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
|
|
936
|
+
snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
|
|
937
|
+
}
|
|
938
|
+
return snapshot;
|
|
784
939
|
}
|
|
785
940
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
941
|
+
#readDreamEvents({ sessionId = null, dreamLimit = 5 } = {}) {
|
|
942
|
+
const limit = Number.isFinite(Number(dreamLimit)) ? Math.max(0, Math.min(50, Number(dreamLimit))) : 5;
|
|
943
|
+
if (limit <= 0) return [];
|
|
944
|
+
const storedEvents = readJson(join(this.#rootDir, 'events.json'));
|
|
945
|
+
const events = Array.isArray(storedEvents) ? storedEvents : [];
|
|
946
|
+
const out = [];
|
|
947
|
+
for (const event of events.slice().reverse()) {
|
|
948
|
+
const data = isPlainObject(event.eventData) ? event.eventData : {};
|
|
949
|
+
if (sessionId) {
|
|
950
|
+
const evtSessionId = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : null;
|
|
951
|
+
const target = typeof data.target === 'string' ? data.target : '';
|
|
952
|
+
const isBroadcast = !evtSessionId && !target;
|
|
953
|
+
const isThisSession = evtSessionId === sessionId || target === `sessions/${sessionId}` || target === `group/${sessionId}`;
|
|
954
|
+
if (!isBroadcast && !isThisSession) continue;
|
|
955
|
+
}
|
|
956
|
+
out.push({
|
|
957
|
+
type: data.type || event.eventType || 'event',
|
|
958
|
+
...data,
|
|
959
|
+
at: event.createdAt,
|
|
960
|
+
ts: data.ts || data.at || event.createdAt,
|
|
961
|
+
});
|
|
962
|
+
if (out.length >= limit) break;
|
|
963
|
+
}
|
|
964
|
+
return out.reverse();
|
|
789
965
|
}
|
|
790
966
|
|
|
791
|
-
|
|
967
|
+
#pruneAll(keep) {
|
|
968
|
+
const sessions = new Set([null]);
|
|
969
|
+
for (const { trace } of this.#traceSummaries()) sessions.add(trace.sessionId || null);
|
|
970
|
+
for (const sid of sessions) this.#pruneSession(sid, keep);
|
|
971
|
+
}
|
|
792
972
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
973
|
+
#pruneSession(sessionId, keep = REQUEST_RETENTION) {
|
|
974
|
+
const traces = this.#traceSummaries(sessionId);
|
|
975
|
+
const activeCutoff = Date.now() - 6 * 60 * 60 * 1000;
|
|
976
|
+
const protectedItems = traces.filter(item => item.trace?.active && Number(item.trace?.updatedAt || 0) >= activeCutoff);
|
|
977
|
+
const pruneCandidates = traces.filter(item => !protectedItems.includes(item));
|
|
978
|
+
const stale = pruneCandidates.slice(0, Math.max(0, traces.length - protectedItems.length - keep));
|
|
979
|
+
for (const item of stale) {
|
|
980
|
+
try { rmSync(dirname(item.file), { recursive: true, force: true }); }
|
|
981
|
+
catch { /* ignore */ }
|
|
982
|
+
this.#requestCache.delete(item.trace.requestKey);
|
|
802
983
|
}
|
|
803
|
-
return this.#stmts[key];
|
|
804
984
|
}
|
|
805
985
|
|
|
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
|
-
: [];
|
|
986
|
+
#expandLegacy(traces) {
|
|
987
|
+
const turns = [];
|
|
988
|
+
const tools = [];
|
|
989
|
+
const events = [];
|
|
990
|
+
for (const trace of traces) {
|
|
991
|
+
turns.push(...traceToLegacyRows(trace));
|
|
992
|
+
for (const tool of Array.isArray(trace.tools) ? trace.tools : []) tools.push(traceToolToLegacy(trace, tool));
|
|
993
|
+
}
|
|
825
994
|
return { turns, tools, events };
|
|
826
995
|
}
|
|
827
996
|
}
|
|
828
997
|
|
|
829
|
-
/**
|
|
830
|
-
* NullTrace — No-op implementation with the same interface.
|
|
831
|
-
* Used when debug is disabled. Zero overhead.
|
|
832
|
-
*/
|
|
833
998
|
export class NullTrace {
|
|
834
999
|
startTurn() { return 'null'; }
|
|
835
1000
|
endTurn() {}
|
|
@@ -841,22 +1006,16 @@ export class NullTrace {
|
|
|
841
1006
|
queryRecent() { return []; }
|
|
842
1007
|
queryTools() { return []; }
|
|
843
1008
|
search() { return []; }
|
|
844
|
-
stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0 }; }
|
|
845
|
-
cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
|
|
1009
|
+
stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0, fileSizeBytes: 0, requestCount: 0 }; }
|
|
1010
|
+
cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0, deletedRequests: 0 }; }
|
|
846
1011
|
compact() { return { before: 0, after: 0 }; }
|
|
847
1012
|
purge() {}
|
|
848
1013
|
close() {}
|
|
849
1014
|
fetchRecentDebugHistory() { return { loops: [], turns: [], dreamEvents: [] }; }
|
|
850
1015
|
}
|
|
851
1016
|
|
|
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);
|
|
1017
|
+
export function createTrace({ enabled, dbPath, dirPath }) {
|
|
1018
|
+
const path = dirPath || dbPath;
|
|
1019
|
+
if (!enabled || !path) return new NullTrace();
|
|
1020
|
+
return new DebugTrace(path);
|
|
862
1021
|
}
|