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