@yeaft/webchat-agent 0.1.398 → 0.1.408
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/crew/role-query.js +10 -6
- package/package.json +3 -1
- package/sdk/query.js +3 -1
- package/unify/cli.js +537 -0
- package/unify/config.js +256 -0
- package/unify/debug-trace.js +398 -0
- package/unify/engine.js +319 -0
- package/unify/index.js +21 -0
- package/unify/init.js +147 -0
- package/unify/llm/adapter.js +186 -0
- package/unify/llm/anthropic.js +322 -0
- package/unify/llm/chat-completions.js +315 -0
- package/unify/models.js +167 -0
- package/unify/prompts.js +61 -0
package/unify/config.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js — Yeaft configuration management
|
|
3
|
+
*
|
|
4
|
+
* Priority (high → low): CLI overrides > ENV vars > .env file > config.md frontmatter > defaults
|
|
5
|
+
*
|
|
6
|
+
* Note: "model" in Yeaft always means a model ID (e.g. "gpt-5", "claude-sonnet-4-20250514").
|
|
7
|
+
* Yeaft does not provide its own models — it routes to external LLM providers via adapters.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { DEFAULT_YEAFT_DIR } from './init.js';
|
|
13
|
+
import { resolveModel } from './models.js';
|
|
14
|
+
|
|
15
|
+
/** Default configuration values. */
|
|
16
|
+
const DEFAULTS = {
|
|
17
|
+
model: 'claude-sonnet-4-20250514',
|
|
18
|
+
fallbackModel: null,
|
|
19
|
+
language: 'en', // 'en' | 'zh'
|
|
20
|
+
apiKey: null,
|
|
21
|
+
openaiApiKey: null,
|
|
22
|
+
proxyUrl: 'http://localhost:6628',
|
|
23
|
+
baseUrl: null,
|
|
24
|
+
adapter: null, // auto-detect: 'anthropic' | 'openai' | 'proxy'
|
|
25
|
+
debug: false,
|
|
26
|
+
dir: DEFAULT_YEAFT_DIR,
|
|
27
|
+
maxContextTokens: 200000,
|
|
28
|
+
maxOutputTokens: 16384,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse YAML frontmatter from a markdown file.
|
|
33
|
+
* Simple parser — handles key: value pairs, no nested objects.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} content — File content
|
|
36
|
+
* @returns {Record<string, string>} — Parsed frontmatter
|
|
37
|
+
*/
|
|
38
|
+
export function parseFrontmatter(content) {
|
|
39
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
40
|
+
if (!match) return {};
|
|
41
|
+
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const line of match[1].split('\n')) {
|
|
44
|
+
const trimmed = line.trim();
|
|
45
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
46
|
+
const colonIdx = trimmed.indexOf(':');
|
|
47
|
+
if (colonIdx === -1) continue;
|
|
48
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
49
|
+
let value = trimmed.slice(colonIdx + 1).trim();
|
|
50
|
+
// Parse booleans and numbers
|
|
51
|
+
if (value === 'true') value = true;
|
|
52
|
+
else if (value === 'false') value = false;
|
|
53
|
+
else if (value === 'null') value = null;
|
|
54
|
+
else if (/^\d+$/.test(value)) value = parseInt(value, 10);
|
|
55
|
+
result[key] = value;
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load configuration from config.md file.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} dir — Yeaft data directory
|
|
64
|
+
* @returns {Record<string, unknown>} — Config from file
|
|
65
|
+
*/
|
|
66
|
+
function loadConfigFile(dir) {
|
|
67
|
+
const configPath = join(dir, 'config.md');
|
|
68
|
+
if (!existsSync(configPath)) return {};
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const content = readFileSync(configPath, 'utf8');
|
|
72
|
+
return parseFrontmatter(content);
|
|
73
|
+
} catch {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load .env file from a directory. Sets process.env for any keys
|
|
80
|
+
* not already defined (env vars take precedence over .env file).
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ Side-effect: mutates process.env globally. Values set by a previous
|
|
83
|
+
* call persist across subsequent loadConfig() calls within the same process.
|
|
84
|
+
* This is by design (matches dotenv behavior), but callers that need isolation
|
|
85
|
+
* (e.g. tests) must manually delete keys from process.env between calls.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} dir — Directory containing .env file
|
|
88
|
+
*/
|
|
89
|
+
function loadEnvFile(dir) {
|
|
90
|
+
const envPath = join(dir, '.env');
|
|
91
|
+
if (!existsSync(envPath)) return;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const content = readFileSync(envPath, 'utf8');
|
|
95
|
+
for (const line of content.split('\n')) {
|
|
96
|
+
let trimmed = line.trim();
|
|
97
|
+
// Skip empty lines and comments
|
|
98
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
99
|
+
// Strip optional 'export ' prefix (common in .env files)
|
|
100
|
+
if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7);
|
|
101
|
+
const eqIdx = trimmed.indexOf('=');
|
|
102
|
+
if (eqIdx === -1) continue;
|
|
103
|
+
|
|
104
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
105
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
106
|
+
// Remove surrounding quotes if present
|
|
107
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
108
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
109
|
+
value = value.slice(1, -1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Only set if not already defined (shell env takes precedence)
|
|
113
|
+
if (process.env[key] === undefined) {
|
|
114
|
+
process.env[key] = value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Silently ignore .env read errors
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Helper: check if an env var is truthy.
|
|
124
|
+
* @param {string|undefined} val
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
function isTruthy(val) {
|
|
128
|
+
return val === '1' || val === 'true' || val === 'yes';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Load full configuration.
|
|
133
|
+
*
|
|
134
|
+
* @param {Record<string, unknown>} [overrides] — CLI overrides
|
|
135
|
+
* @returns {object} — Merged configuration
|
|
136
|
+
*/
|
|
137
|
+
export function loadConfig(overrides = {}) {
|
|
138
|
+
const env = process.env;
|
|
139
|
+
|
|
140
|
+
// Determine data directory first (needed to load .env and config.md)
|
|
141
|
+
const dir = overrides.dir || env.YEAFT_DIR || DEFAULTS.dir;
|
|
142
|
+
|
|
143
|
+
// Load .env file (sets process.env for undefined keys — shell env takes precedence)
|
|
144
|
+
loadEnvFile(dir);
|
|
145
|
+
|
|
146
|
+
// Load from config.md
|
|
147
|
+
const fileConfig = loadConfigFile(dir);
|
|
148
|
+
|
|
149
|
+
// Build merged config: defaults < file < env < overrides
|
|
150
|
+
const config = {
|
|
151
|
+
model:
|
|
152
|
+
overrides.model ||
|
|
153
|
+
env.YEAFT_MODEL ||
|
|
154
|
+
fileConfig.model ||
|
|
155
|
+
DEFAULTS.model,
|
|
156
|
+
|
|
157
|
+
fallbackModel:
|
|
158
|
+
overrides.fallbackModel ||
|
|
159
|
+
env.YEAFT_FALLBACK_MODEL ||
|
|
160
|
+
fileConfig.fallbackModel ||
|
|
161
|
+
DEFAULTS.fallbackModel,
|
|
162
|
+
|
|
163
|
+
language:
|
|
164
|
+
overrides.language ||
|
|
165
|
+
env.YEAFT_LANGUAGE ||
|
|
166
|
+
fileConfig.language ||
|
|
167
|
+
DEFAULTS.language,
|
|
168
|
+
|
|
169
|
+
apiKey:
|
|
170
|
+
overrides.apiKey ||
|
|
171
|
+
env.YEAFT_API_KEY ||
|
|
172
|
+
fileConfig.apiKey ||
|
|
173
|
+
DEFAULTS.apiKey,
|
|
174
|
+
|
|
175
|
+
openaiApiKey:
|
|
176
|
+
overrides.openaiApiKey ||
|
|
177
|
+
env.YEAFT_OPENAI_API_KEY ||
|
|
178
|
+
fileConfig.openaiApiKey ||
|
|
179
|
+
DEFAULTS.openaiApiKey,
|
|
180
|
+
|
|
181
|
+
proxyUrl:
|
|
182
|
+
overrides.proxyUrl ||
|
|
183
|
+
env.YEAFT_PROXY_URL ||
|
|
184
|
+
fileConfig.proxyUrl ||
|
|
185
|
+
DEFAULTS.proxyUrl,
|
|
186
|
+
|
|
187
|
+
baseUrl:
|
|
188
|
+
overrides.baseUrl ||
|
|
189
|
+
env.YEAFT_BASE_URL ||
|
|
190
|
+
fileConfig.baseUrl ||
|
|
191
|
+
DEFAULTS.baseUrl,
|
|
192
|
+
|
|
193
|
+
adapter:
|
|
194
|
+
overrides.adapter ||
|
|
195
|
+
env.YEAFT_ADAPTER ||
|
|
196
|
+
fileConfig.adapter ||
|
|
197
|
+
DEFAULTS.adapter,
|
|
198
|
+
|
|
199
|
+
debug:
|
|
200
|
+
overrides.debug !== undefined
|
|
201
|
+
? overrides.debug
|
|
202
|
+
: env.YEAFT_DEBUG !== undefined
|
|
203
|
+
? isTruthy(env.YEAFT_DEBUG)
|
|
204
|
+
: fileConfig.debug !== undefined
|
|
205
|
+
? fileConfig.debug
|
|
206
|
+
: DEFAULTS.debug,
|
|
207
|
+
|
|
208
|
+
dir,
|
|
209
|
+
|
|
210
|
+
maxContextTokens:
|
|
211
|
+
overrides.maxContextTokens ??
|
|
212
|
+
(env.YEAFT_MAX_CONTEXT ? parseInt(env.YEAFT_MAX_CONTEXT, 10) : null) ??
|
|
213
|
+
fileConfig.maxContextTokens ??
|
|
214
|
+
DEFAULTS.maxContextTokens,
|
|
215
|
+
|
|
216
|
+
maxOutputTokens:
|
|
217
|
+
overrides.maxOutputTokens ??
|
|
218
|
+
fileConfig.maxOutputTokens ??
|
|
219
|
+
DEFAULTS.maxOutputTokens,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Auto-detect adapter using model registry + credential fallback
|
|
223
|
+
if (!config.adapter) {
|
|
224
|
+
const modelInfo = resolveModel(config.model);
|
|
225
|
+
if (modelInfo) {
|
|
226
|
+
// Known model → set adapter from registry
|
|
227
|
+
config.adapter = modelInfo.adapter === 'anthropic' ? 'anthropic' : 'openai';
|
|
228
|
+
// Use registry baseUrl if not explicitly overridden
|
|
229
|
+
if (!config.baseUrl) {
|
|
230
|
+
config.baseUrl = modelInfo.baseUrl;
|
|
231
|
+
}
|
|
232
|
+
// Use registry contextWindow if still at default
|
|
233
|
+
if (config.maxContextTokens === DEFAULTS.maxContextTokens) {
|
|
234
|
+
config.maxContextTokens = modelInfo.contextWindow;
|
|
235
|
+
}
|
|
236
|
+
// Use registry maxOutputTokens if still at default
|
|
237
|
+
if (config.maxOutputTokens === DEFAULTS.maxOutputTokens) {
|
|
238
|
+
config.maxOutputTokens = modelInfo.maxOutputTokens;
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
// Unknown model → fallback to credential-based detection
|
|
242
|
+
if (config.apiKey) {
|
|
243
|
+
config.adapter = 'anthropic';
|
|
244
|
+
} else if (config.openaiApiKey) {
|
|
245
|
+
config.adapter = 'openai';
|
|
246
|
+
} else if (config.proxyUrl) {
|
|
247
|
+
config.adapter = 'proxy';
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Store resolved model info for reference
|
|
253
|
+
config.modelInfo = resolveModel(config.model) || null;
|
|
254
|
+
|
|
255
|
+
return config;
|
|
256
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* debug-trace.js — SQLite-backed debug trace for Yeaft
|
|
3
|
+
*
|
|
4
|
+
* Records every LLM turn, tool call, and event for debugging and analytics.
|
|
5
|
+
* When disabled, uses NullTrace (same interface, zero overhead).
|
|
6
|
+
*
|
|
7
|
+
* Reference: server/db/connection.js — Database(path), pragma WAL
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import Database from 'better-sqlite3';
|
|
11
|
+
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
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS trace_tools (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
turn_id TEXT NOT NULL,
|
|
37
|
+
tool_name TEXT NOT NULL,
|
|
38
|
+
tool_input TEXT,
|
|
39
|
+
tool_output TEXT,
|
|
40
|
+
duration_ms INTEGER,
|
|
41
|
+
is_error INTEGER DEFAULT 0,
|
|
42
|
+
created_at INTEGER NOT NULL,
|
|
43
|
+
FOREIGN KEY (turn_id) REFERENCES trace_turns(id)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS trace_events (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
trace_id TEXT NOT NULL,
|
|
49
|
+
event_type TEXT NOT NULL,
|
|
50
|
+
event_data TEXT,
|
|
51
|
+
created_at INTEGER NOT NULL
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_turns_trace_id ON trace_turns(trace_id);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_turns_message_id ON trace_turns(message_id);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_turns_started_at ON trace_turns(started_at);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_turns_model ON trace_turns(model);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_tools_turn_id ON trace_tools(turn_id);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_tools_name ON trace_tools(tool_name);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_events_trace_id ON trace_events(trace_id);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_events_type ON trace_events(event_type);
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
/** Max tool_output size stored (10KB). Longer outputs are truncated. */
|
|
65
|
+
const MAX_TOOL_OUTPUT = 10240;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Truncate a string to a max length, appending "... [truncated]" if needed.
|
|
69
|
+
* @param {string|null|undefined} str
|
|
70
|
+
* @param {number} max
|
|
71
|
+
* @returns {string|null}
|
|
72
|
+
*/
|
|
73
|
+
function truncate(str, max) {
|
|
74
|
+
if (!str) return str ?? null;
|
|
75
|
+
if (str.length <= max) return str;
|
|
76
|
+
return str.slice(0, max) + '... [truncated]';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* DebugTrace — SQLite-backed debug trace.
|
|
81
|
+
*/
|
|
82
|
+
export class DebugTrace {
|
|
83
|
+
/** @type {Database.Database} */
|
|
84
|
+
#db;
|
|
85
|
+
|
|
86
|
+
/** @type {string} */
|
|
87
|
+
#dbPath;
|
|
88
|
+
|
|
89
|
+
// Prepared statements (created lazily)
|
|
90
|
+
#stmts = {};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} dbPath — Path to the SQLite database file.
|
|
94
|
+
*/
|
|
95
|
+
constructor(dbPath) {
|
|
96
|
+
this.#dbPath = dbPath;
|
|
97
|
+
this.#db = new Database(dbPath);
|
|
98
|
+
this.#db.pragma('journal_mode = WAL');
|
|
99
|
+
this.#db.pragma('foreign_keys = ON');
|
|
100
|
+
this.#db.exec(SCHEMA);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Write API ───────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Start a new turn.
|
|
107
|
+
* @param {{ traceId: string, messageId?: string, mode?: string, turnNumber?: number }} opts
|
|
108
|
+
* @returns {string} — turnId
|
|
109
|
+
*/
|
|
110
|
+
startTurn({ traceId, messageId = null, mode = null, turnNumber = null }) {
|
|
111
|
+
const id = randomUUID();
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
this.#prepare('insertTurn', `
|
|
114
|
+
INSERT INTO trace_turns (id, trace_id, message_id, mode, turn_number, started_at)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
116
|
+
`).run(id, traceId, messageId, mode, turnNumber, now);
|
|
117
|
+
return id;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* End a turn with model response info.
|
|
122
|
+
* @param {string} turnId
|
|
123
|
+
* @param {{ model?: string, inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, stopReason?: string, latencyMs?: number, responseText?: string }} info
|
|
124
|
+
*/
|
|
125
|
+
endTurn(turnId, {
|
|
126
|
+
model = null,
|
|
127
|
+
inputTokens = null,
|
|
128
|
+
outputTokens = null,
|
|
129
|
+
cacheReadTokens = 0,
|
|
130
|
+
cacheWriteTokens = 0,
|
|
131
|
+
stopReason = null,
|
|
132
|
+
latencyMs = null,
|
|
133
|
+
responseText = null,
|
|
134
|
+
} = {}) {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
this.#prepare('endTurn', `
|
|
137
|
+
UPDATE trace_turns SET
|
|
138
|
+
model = ?, input_tokens = ?, output_tokens = ?,
|
|
139
|
+
cache_read_tokens = ?, cache_write_tokens = ?,
|
|
140
|
+
stop_reason = ?, latency_ms = ?, response_text = ?, ended_at = ?
|
|
141
|
+
WHERE id = ?
|
|
142
|
+
`).run(
|
|
143
|
+
model, inputTokens, outputTokens,
|
|
144
|
+
cacheReadTokens, cacheWriteTokens,
|
|
145
|
+
stopReason, latencyMs, truncate(responseText, MAX_TOOL_OUTPUT),
|
|
146
|
+
now, turnId,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Log a tool call within a turn.
|
|
152
|
+
* @param {string} turnId
|
|
153
|
+
* @param {{ toolName: string, toolInput?: string, toolOutput?: string, durationMs?: number, isError?: boolean }} info
|
|
154
|
+
* @returns {string} — tool record id
|
|
155
|
+
*/
|
|
156
|
+
logTool(turnId, {
|
|
157
|
+
toolName,
|
|
158
|
+
toolInput = null,
|
|
159
|
+
toolOutput = null,
|
|
160
|
+
durationMs = null,
|
|
161
|
+
isError = false,
|
|
162
|
+
}) {
|
|
163
|
+
const id = randomUUID();
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
this.#prepare('insertTool', `
|
|
166
|
+
INSERT INTO trace_tools (id, turn_id, tool_name, tool_input, tool_output, duration_ms, is_error, created_at)
|
|
167
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
168
|
+
`).run(
|
|
169
|
+
id, turnId, toolName,
|
|
170
|
+
truncate(toolInput, MAX_TOOL_OUTPUT),
|
|
171
|
+
truncate(toolOutput, MAX_TOOL_OUTPUT),
|
|
172
|
+
durationMs, isError ? 1 : 0, now,
|
|
173
|
+
);
|
|
174
|
+
return id;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Log a freeform event.
|
|
179
|
+
* @param {{ traceId: string, eventType: string, eventData?: unknown }} info
|
|
180
|
+
* @returns {string} — event id
|
|
181
|
+
*/
|
|
182
|
+
logEvent({ traceId, eventType, eventData = null }) {
|
|
183
|
+
const id = randomUUID();
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
const data = eventData != null ? JSON.stringify(eventData) : null;
|
|
186
|
+
this.#prepare('insertEvent', `
|
|
187
|
+
INSERT INTO trace_events (id, trace_id, event_type, event_data, created_at)
|
|
188
|
+
VALUES (?, ?, ?, ?, ?)
|
|
189
|
+
`).run(id, traceId, eventType, data, now);
|
|
190
|
+
return id;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Read API ────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Query all data for a specific message.
|
|
197
|
+
* @param {string} messageId
|
|
198
|
+
* @returns {{ turns: object[], tools: object[], events: object[] }}
|
|
199
|
+
*/
|
|
200
|
+
queryByMessage(messageId) {
|
|
201
|
+
const turns = this.#prepare('turnsByMessage', `
|
|
202
|
+
SELECT * FROM trace_turns WHERE message_id = ? ORDER BY started_at
|
|
203
|
+
`).all(messageId);
|
|
204
|
+
return this.#expandTurns(turns);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Query all data for a trace.
|
|
209
|
+
* @param {string} traceId
|
|
210
|
+
* @returns {{ turns: object[], tools: object[], events: object[] }}
|
|
211
|
+
*/
|
|
212
|
+
queryByTrace(traceId) {
|
|
213
|
+
const turns = this.#prepare('turnsByTrace', `
|
|
214
|
+
SELECT * FROM trace_turns WHERE trace_id = ? ORDER BY started_at
|
|
215
|
+
`).all(traceId);
|
|
216
|
+
const events = this.#prepare('eventsByTrace', `
|
|
217
|
+
SELECT * FROM trace_events WHERE trace_id = ? ORDER BY created_at
|
|
218
|
+
`).all(traceId);
|
|
219
|
+
const turnIds = turns.map(t => t.id);
|
|
220
|
+
const tools = turnIds.length > 0
|
|
221
|
+
? this.#db.prepare(
|
|
222
|
+
`SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
|
|
223
|
+
).all(...turnIds)
|
|
224
|
+
: [];
|
|
225
|
+
return { turns, tools, events };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Query recent turns.
|
|
230
|
+
* @param {number} [limit=20]
|
|
231
|
+
* @returns {object[]}
|
|
232
|
+
*/
|
|
233
|
+
queryRecent(limit = 20) {
|
|
234
|
+
return this.#prepare('recentTurns', `
|
|
235
|
+
SELECT * FROM trace_turns ORDER BY started_at DESC LIMIT ?
|
|
236
|
+
`).all(limit);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Query tool calls with optional filters.
|
|
241
|
+
* @param {{ name?: string, since?: number }} [filters={}]
|
|
242
|
+
* @returns {object[]}
|
|
243
|
+
*/
|
|
244
|
+
queryTools({ name = null, since = null } = {}) {
|
|
245
|
+
if (name && since) {
|
|
246
|
+
return this.#prepare('toolsByNameSince', `
|
|
247
|
+
SELECT * FROM trace_tools WHERE tool_name = ? AND created_at >= ? ORDER BY created_at DESC
|
|
248
|
+
`).all(name, since);
|
|
249
|
+
}
|
|
250
|
+
if (name) {
|
|
251
|
+
return this.#prepare('toolsByName', `
|
|
252
|
+
SELECT * FROM trace_tools WHERE tool_name = ? ORDER BY created_at DESC
|
|
253
|
+
`).all(name);
|
|
254
|
+
}
|
|
255
|
+
if (since) {
|
|
256
|
+
return this.#prepare('toolsSince', `
|
|
257
|
+
SELECT * FROM trace_tools WHERE created_at >= ? ORDER BY created_at DESC
|
|
258
|
+
`).all(since);
|
|
259
|
+
}
|
|
260
|
+
return this.#prepare('allTools', `
|
|
261
|
+
SELECT * FROM trace_tools ORDER BY created_at DESC LIMIT 100
|
|
262
|
+
`).all();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Full-text search across response_text and tool_output.
|
|
267
|
+
* @param {string} keyword
|
|
268
|
+
* @returns {object[]}
|
|
269
|
+
*/
|
|
270
|
+
search(keyword) {
|
|
271
|
+
const like = `%${keyword}%`;
|
|
272
|
+
return this.#prepare('search', `
|
|
273
|
+
SELECT DISTINCT t.* FROM trace_turns t
|
|
274
|
+
LEFT JOIN trace_tools tt ON tt.turn_id = t.id
|
|
275
|
+
WHERE t.response_text LIKE ? OR tt.tool_output LIKE ?
|
|
276
|
+
ORDER BY t.started_at DESC LIMIT 50
|
|
277
|
+
`).all(like, like);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get trace statistics.
|
|
282
|
+
* @returns {{ turnCount: number, toolCount: number, eventCount: number, dbSizeBytes: number }}
|
|
283
|
+
*/
|
|
284
|
+
stats() {
|
|
285
|
+
const turnCount = this.#db.prepare('SELECT COUNT(*) as c FROM trace_turns').get().c;
|
|
286
|
+
const toolCount = this.#db.prepare('SELECT COUNT(*) as c FROM trace_tools').get().c;
|
|
287
|
+
const eventCount = this.#db.prepare('SELECT COUNT(*) as c FROM trace_events').get().c;
|
|
288
|
+
let dbSizeBytes = 0;
|
|
289
|
+
try {
|
|
290
|
+
dbSizeBytes = statSync(this.#dbPath).size;
|
|
291
|
+
} catch { /* ignore */ }
|
|
292
|
+
return { turnCount, toolCount, eventCount, dbSizeBytes };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── Maintenance ─────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Delete data older than retentionDays.
|
|
299
|
+
* @param {number} [retentionDays=30]
|
|
300
|
+
* @returns {{ deletedTurns: number, deletedTools: number, deletedEvents: number }}
|
|
301
|
+
*/
|
|
302
|
+
cleanup(retentionDays = 30) {
|
|
303
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
304
|
+
const deletedTools = this.#db.prepare(`
|
|
305
|
+
DELETE FROM trace_tools WHERE turn_id IN (
|
|
306
|
+
SELECT id FROM trace_turns WHERE started_at < ?
|
|
307
|
+
)
|
|
308
|
+
`).run(cutoff).changes;
|
|
309
|
+
const deletedTurns = this.#db.prepare(`
|
|
310
|
+
DELETE FROM trace_turns WHERE started_at < ?
|
|
311
|
+
`).run(cutoff).changes;
|
|
312
|
+
const deletedEvents = this.#db.prepare(`
|
|
313
|
+
DELETE FROM trace_events WHERE created_at < ?
|
|
314
|
+
`).run(cutoff).changes;
|
|
315
|
+
return { deletedTurns, deletedTools, deletedEvents };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Delete all trace data. */
|
|
319
|
+
purge() {
|
|
320
|
+
this.#db.exec('DELETE FROM trace_tools');
|
|
321
|
+
this.#db.exec('DELETE FROM trace_turns');
|
|
322
|
+
this.#db.exec('DELETE FROM trace_events');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Close the database connection. */
|
|
326
|
+
close() {
|
|
327
|
+
this.#db.close();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─── Internal ────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get or create a prepared statement.
|
|
334
|
+
* @param {string} key
|
|
335
|
+
* @param {string} sql
|
|
336
|
+
* @returns {Database.Statement}
|
|
337
|
+
*/
|
|
338
|
+
#prepare(key, sql) {
|
|
339
|
+
if (!this.#stmts[key]) {
|
|
340
|
+
this.#stmts[key] = this.#db.prepare(sql);
|
|
341
|
+
}
|
|
342
|
+
return this.#stmts[key];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Expand turns with their tools.
|
|
347
|
+
* @param {object[]} turns
|
|
348
|
+
* @returns {{ turns: object[], tools: object[], events: object[] }}
|
|
349
|
+
*/
|
|
350
|
+
#expandTurns(turns) {
|
|
351
|
+
const turnIds = turns.map(t => t.id);
|
|
352
|
+
const tools = turnIds.length > 0
|
|
353
|
+
? this.#db.prepare(
|
|
354
|
+
`SELECT * FROM trace_tools WHERE turn_id IN (${turnIds.map(() => '?').join(',')}) ORDER BY created_at`
|
|
355
|
+
).all(...turnIds)
|
|
356
|
+
: [];
|
|
357
|
+
// Events need trace_ids from turns
|
|
358
|
+
const traceIds = [...new Set(turns.map(t => t.trace_id))];
|
|
359
|
+
const events = traceIds.length > 0
|
|
360
|
+
? this.#db.prepare(
|
|
361
|
+
`SELECT * FROM trace_events WHERE trace_id IN (${traceIds.map(() => '?').join(',')}) ORDER BY created_at`
|
|
362
|
+
).all(...traceIds)
|
|
363
|
+
: [];
|
|
364
|
+
return { turns, tools, events };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* NullTrace — No-op implementation with the same interface.
|
|
370
|
+
* Used when debug is disabled. Zero overhead.
|
|
371
|
+
*/
|
|
372
|
+
export class NullTrace {
|
|
373
|
+
startTurn() { return 'null'; }
|
|
374
|
+
endTurn() {}
|
|
375
|
+
logTool() { return 'null'; }
|
|
376
|
+
logEvent() { return 'null'; }
|
|
377
|
+
queryByMessage() { return { turns: [], tools: [], events: [] }; }
|
|
378
|
+
queryByTrace() { return { turns: [], tools: [], events: [] }; }
|
|
379
|
+
queryRecent() { return []; }
|
|
380
|
+
queryTools() { return []; }
|
|
381
|
+
search() { return []; }
|
|
382
|
+
stats() { return { turnCount: 0, toolCount: 0, eventCount: 0, dbSizeBytes: 0 }; }
|
|
383
|
+
cleanup() { return { deletedTurns: 0, deletedTools: 0, deletedEvents: 0 }; }
|
|
384
|
+
purge() {}
|
|
385
|
+
close() {}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Create a DebugTrace or NullTrace based on config.
|
|
390
|
+
* @param {{ enabled: boolean, dbPath?: string }} opts
|
|
391
|
+
* @returns {DebugTrace | NullTrace}
|
|
392
|
+
*/
|
|
393
|
+
export function createTrace({ enabled, dbPath }) {
|
|
394
|
+
if (!enabled || !dbPath) {
|
|
395
|
+
return new NullTrace();
|
|
396
|
+
}
|
|
397
|
+
return new DebugTrace(dbPath);
|
|
398
|
+
}
|