agentacta 2026.4.8 → 2026.4.10

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/dist/index.js ADDED
@@ -0,0 +1,843 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_http_1 = __importDefault(require("node:http"));
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_events_1 = require("node:events");
10
+ const config_js_1 = require("./config.js");
11
+ const db_js_1 = require("./db.js");
12
+ const indexer_js_1 = require("./indexer.js");
13
+ const project_attribution_js_1 = require("./project-attribution.js");
14
+ const delta_attribution_context_js_1 = require("./delta-attribution-context.js");
15
+ const insights_js_1 = require("./insights.js");
16
+ // --version / -v flag: print version and exit
17
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
18
+ const pkg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(__dirname, '..', 'package.json'), 'utf8'));
19
+ console.log(`${pkg.name} v${pkg.version}`);
20
+ process.exit(0);
21
+ }
22
+ // --demo flag: use demo session data (must run before config load)
23
+ if (process.argv.includes('--demo')) {
24
+ const demoDir = node_path_1.default.join(__dirname, '..', 'demo');
25
+ if (!node_fs_1.default.existsSync(demoDir) || node_fs_1.default.readdirSync(demoDir).filter((f) => f.endsWith('.jsonl')).length === 0) {
26
+ console.error('Demo data not found. Run: node scripts/seed-demo.js');
27
+ process.exit(1);
28
+ }
29
+ process.env.AGENTACTA_SESSIONS_PATH = demoDir;
30
+ process.env.AGENTACTA_DB_PATH = node_path_1.default.join(demoDir, 'demo.db');
31
+ process.env.AGENTACTA_DEMO_MODE = '1'; // signal to config.js to skip file-based sessionsPath
32
+ console.log(`Demo mode: using sessions from ${demoDir}`);
33
+ }
34
+ const config = (0, config_js_1.loadConfig)();
35
+ const PORT = config.port;
36
+ const ARCHIVE_MODE = config.storage === 'archive';
37
+ console.log(`AgentActa running in ${config.storage} mode`);
38
+ const PUBLIC = node_path_1.default.join(__dirname, '..', 'public');
39
+ const MIME = {
40
+ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
41
+ '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
42
+ '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json'
43
+ };
44
+ function json(res, data, status = 200) {
45
+ res.writeHead(status, { 'Content-Type': 'application/json' });
46
+ res.end(JSON.stringify(data));
47
+ }
48
+ function download(res, data, filename, contentType) {
49
+ res.writeHead(200, {
50
+ 'Content-Type': contentType,
51
+ 'Content-Disposition': `attachment; filename="${filename}"`
52
+ });
53
+ res.end(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
54
+ }
55
+ function serveStatic(req, res) {
56
+ const reqUrl = req.url || '/';
57
+ let fp = node_path_1.default.join(PUBLIC, reqUrl.split('?')[0] === '/' ? 'index.html' : reqUrl.split('?')[0]);
58
+ fp = node_path_1.default.normalize(fp);
59
+ if (!fp.startsWith(PUBLIC)) {
60
+ res.writeHead(403);
61
+ res.end();
62
+ return true;
63
+ }
64
+ if (node_fs_1.default.existsSync(fp) && node_fs_1.default.statSync(fp).isFile()) {
65
+ const ext = node_path_1.default.extname(fp);
66
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
67
+ node_fs_1.default.createReadStream(fp).pipe(res);
68
+ return true;
69
+ }
70
+ return false;
71
+ }
72
+ function parseQuery(url) {
73
+ const u = new URL(url, 'http://localhost');
74
+ const o = {};
75
+ u.searchParams.forEach((v, k) => { o[k] = v; });
76
+ return { pathname: u.pathname, query: o };
77
+ }
78
+ function sessionToMarkdown(session, events) {
79
+ let md = `# Session: ${session.id}\n`;
80
+ md += `- **Start:** ${session.start_time}\n`;
81
+ md += `- **End:** ${session.end_time || 'N/A'}\n`;
82
+ md += `- **Model:** ${session.model || 'N/A'}\n`;
83
+ md += `- **Agent:** ${session.agent || 'main'}\n`;
84
+ md += `- **Messages:** ${session.message_count} | **Tools:** ${session.tool_count}\n`;
85
+ md += `- **Cost:** $${(session.total_cost || 0).toFixed(4)} | **Tokens:** ${(session.total_tokens || 0).toLocaleString()}\n\n`;
86
+ md += `## Summary\n${session.summary || 'No summary'}\n\n## Events\n\n`;
87
+ for (const e of events) {
88
+ const time = e.timestamp ? new Date(e.timestamp).toISOString() : '';
89
+ if (e.type === 'tool_call') {
90
+ md += `### [${time}] Tool: ${e.tool_name}\n\`\`\`json\n${e.tool_args || ''}\n\`\`\`\n\n`;
91
+ }
92
+ else if (e.type === 'tool_result') {
93
+ md += `### [${time}] Result: ${e.tool_name}\n\`\`\`\n${(e.content || '').slice(0, 2000)}\n\`\`\`\n\n`;
94
+ }
95
+ else {
96
+ md += `### [${time}] ${e.role || e.type}\n${e.content || ''}\n\n`;
97
+ }
98
+ }
99
+ return md;
100
+ }
101
+ function getDbSize() {
102
+ try {
103
+ const stat = node_fs_1.default.statSync(config.dbPath);
104
+ const mb = stat.size / (1024 * 1024);
105
+ return { bytes: stat.size, display: mb >= 1 ? `${mb.toFixed(1)} MB` : `${(stat.size / 1024).toFixed(1)} KB` };
106
+ }
107
+ catch {
108
+ return { bytes: 0, display: 'N/A' };
109
+ }
110
+ }
111
+ function relativeTime(ts) {
112
+ if (!ts)
113
+ return null;
114
+ const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
115
+ if (diff < 60)
116
+ return 'just now';
117
+ if (diff < 3600)
118
+ return `${Math.floor(diff / 60)}m ago`;
119
+ if (diff < 86400)
120
+ return `${Math.floor(diff / 3600)}h ago`;
121
+ return `${Math.floor(diff / 86400)}d ago`;
122
+ }
123
+ function normalizeAgentLabel(agent) {
124
+ if (!agent)
125
+ return agent;
126
+ if (agent === 'main')
127
+ return 'openclaw-main';
128
+ if (agent.startsWith('claude-') || agent.startsWith('claude--'))
129
+ return 'claude-code';
130
+ return agent;
131
+ }
132
+ function looksLikeSessionId(q) {
133
+ const s = (q || '').trim();
134
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
135
+ }
136
+ function toFtsQuery(q) {
137
+ const s = (q || '').trim();
138
+ if (!s)
139
+ return '';
140
+ // Quote each token so dashes and punctuation don't break FTS parsing.
141
+ // Example: abc-def -> "abc-def"
142
+ const tokens = s.match(/"[^"]+"|\S+/g) || [];
143
+ return tokens
144
+ .map((t) => t.replace(/^"|"$/g, '').replace(/"/g, '""'))
145
+ .filter(Boolean)
146
+ .map((t) => `"${t}"`)
147
+ .join(' AND ');
148
+ }
149
+ // Init DB and start watcher
150
+ (0, db_js_1.init)();
151
+ const db = (0, db_js_1.open)();
152
+ // Live re-indexing setup
153
+ const stmts = (0, db_js_1.createStmts)(db);
154
+ // SSE emitter: notifies connected clients when a session is re-indexed
155
+ const sseEmitter = new node_events_1.EventEmitter();
156
+ sseEmitter.setMaxListeners(100);
157
+ const sessionDirs = (0, indexer_js_1.discoverSessionDirs)(config);
158
+ // Initial indexing pass
159
+ for (const dir of sessionDirs) {
160
+ const files = (0, indexer_js_1.listJsonlFiles)(dir.path, !!dir.recursive);
161
+ for (const filePath of files) {
162
+ try {
163
+ const result = (0, indexer_js_1.indexFile)(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
164
+ if (!result.skipped)
165
+ console.log(`Indexed: ${node_path_1.default.basename(filePath)} (${dir.agent})`);
166
+ }
167
+ catch (err) {
168
+ console.error(`Error indexing ${node_path_1.default.basename(filePath)}:`, err.message);
169
+ }
170
+ }
171
+ }
172
+ // Compute insights for all indexed sessions
173
+ try {
174
+ (0, insights_js_1.analyzeAll)(db);
175
+ console.log('Insights computed for all sessions');
176
+ }
177
+ catch (err) {
178
+ console.error('Error computing insights:', err.message);
179
+ }
180
+ console.log(`Watching ${sessionDirs.length} session directories`);
181
+ // Debounce map: filePath -> timeout handle
182
+ const _reindexTimers = new Map();
183
+ const REINDEX_DEBOUNCE_MS = 2000;
184
+ const RECURSIVE_RESCAN_MS = 15000;
185
+ function reindexRecursiveDir(dir) {
186
+ try {
187
+ const files = (0, indexer_js_1.listJsonlFiles)(dir.path, true);
188
+ let changed = 0;
189
+ const upsert = db.prepare('INSERT OR REPLACE INTO session_insights (session_id, signals, confusion_score, flagged, computed_at) VALUES (?, ?, ?, ?, ?)');
190
+ for (const filePath of files) {
191
+ const result = (0, indexer_js_1.indexFile)(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
192
+ if (!result.skipped) {
193
+ changed++;
194
+ if (result.sessionId) {
195
+ try {
196
+ const insight = (0, insights_js_1.analyzeSession)(db, result.sessionId);
197
+ if (insight)
198
+ upsert.run(insight.session_id, JSON.stringify(insight.signals), insight.confusion_score, insight.flagged ? 1 : 0, insight.computed_at);
199
+ }
200
+ catch { /* ignore */ }
201
+ sseEmitter.emit('session-update', result.sessionId);
202
+ }
203
+ }
204
+ }
205
+ if (changed > 0)
206
+ console.log(`Live re-indexed ${changed} files (${dir.agent})`);
207
+ }
208
+ catch (err) {
209
+ console.error(`Error rescanning ${dir.path}:`, err.message);
210
+ }
211
+ }
212
+ for (const dir of sessionDirs) {
213
+ try {
214
+ node_fs_1.default.watch(dir.path, { persistent: false }, (_eventType, filename) => {
215
+ if (dir.recursive) {
216
+ if (_reindexTimers.has(dir.path))
217
+ clearTimeout(_reindexTimers.get(dir.path));
218
+ _reindexTimers.set(dir.path, setTimeout(() => {
219
+ _reindexTimers.delete(dir.path);
220
+ reindexRecursiveDir(dir);
221
+ }, REINDEX_DEBOUNCE_MS));
222
+ return;
223
+ }
224
+ if (!filename || !filename.endsWith('.jsonl'))
225
+ return;
226
+ const filePath = node_path_1.default.join(dir.path, filename);
227
+ if (!node_fs_1.default.existsSync(filePath))
228
+ return;
229
+ // Debounce: cancel pending re-index for this file, schedule a new one
230
+ if (_reindexTimers.has(filePath))
231
+ clearTimeout(_reindexTimers.get(filePath));
232
+ _reindexTimers.set(filePath, setTimeout(() => {
233
+ _reindexTimers.delete(filePath);
234
+ try {
235
+ const result = (0, indexer_js_1.indexFile)(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
236
+ if (!result.skipped) {
237
+ console.log(`Live re-indexed: ${filename} (${dir.agent})`);
238
+ if (result.sessionId) {
239
+ try {
240
+ const upsert = db.prepare('INSERT OR REPLACE INTO session_insights (session_id, signals, confusion_score, flagged, computed_at) VALUES (?, ?, ?, ?, ?)');
241
+ const insight = (0, insights_js_1.analyzeSession)(db, result.sessionId);
242
+ if (insight)
243
+ upsert.run(insight.session_id, JSON.stringify(insight.signals), insight.confusion_score, insight.flagged ? 1 : 0, insight.computed_at);
244
+ }
245
+ catch { /* ignore */ }
246
+ sseEmitter.emit('session-update', result.sessionId);
247
+ }
248
+ }
249
+ }
250
+ catch (err) {
251
+ console.error(`Error re-indexing ${filename}:`, err.message);
252
+ }
253
+ }, REINDEX_DEBOUNCE_MS));
254
+ });
255
+ console.log(` Watching: ${dir.path}`);
256
+ if (dir.recursive) {
257
+ const timer = setInterval(() => reindexRecursiveDir(dir), RECURSIVE_RESCAN_MS);
258
+ timer.unref?.();
259
+ }
260
+ }
261
+ catch (err) {
262
+ console.error(` Failed to watch ${dir.path}:`, err.message);
263
+ }
264
+ }
265
+ const server = node_http_1.default.createServer((req, res) => {
266
+ const { pathname, query } = parseQuery(req.url || '/');
267
+ try {
268
+ if (pathname === '/api/reindex') {
269
+ const result = (0, indexer_js_1.indexAll)(db, config);
270
+ try {
271
+ (0, insights_js_1.analyzeAll)(db);
272
+ }
273
+ catch (e) {
274
+ console.error('Insights recompute error:', e.message);
275
+ }
276
+ return json(res, { ok: true, sessions: result.sessions, events: result.events });
277
+ }
278
+ else if (pathname === '/api/health') {
279
+ const pkg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(__dirname, '..', 'package.json'), 'utf8'));
280
+ const sessions = db.prepare('SELECT COUNT(*) as c FROM sessions').get().c;
281
+ const dbSize = getDbSize();
282
+ return json(res, {
283
+ status: 'ok',
284
+ version: pkg.version,
285
+ uptime: Math.round(process.uptime()),
286
+ sessions,
287
+ dbSizeBytes: dbSize.bytes,
288
+ node: process.version
289
+ });
290
+ }
291
+ else if (pathname === '/api/config') {
292
+ const dbSize = getDbSize();
293
+ const archiveCount = db.prepare('SELECT COUNT(*) as c FROM archive').get().c;
294
+ json(res, {
295
+ storage: config.storage,
296
+ port: config.port,
297
+ dbPath: config.dbPath,
298
+ dbSize: dbSize,
299
+ sessionsPath: config.sessionsPath,
300
+ sessionDirs: sessionDirs.map((d) => ({ path: d.path, agent: d.agent })),
301
+ archiveEnabled: ARCHIVE_MODE,
302
+ archiveRows: archiveCount
303
+ });
304
+ }
305
+ else if (pathname === '/api/suggestions') {
306
+ // Top tool names (most used)
307
+ const tools = db.prepare("SELECT tool_name, COUNT(*) as c FROM events WHERE tool_name IS NOT NULL GROUP BY tool_name ORDER BY c DESC LIMIT 5").all().map((r) => r.tool_name);
308
+ // Most touched files (short basenames)
309
+ const files = db.prepare("SELECT file_path, COUNT(*) as c FROM file_activity GROUP BY file_path ORDER BY c DESC LIMIT 5").all().map((r) => {
310
+ const parts = r.file_path.split('/');
311
+ return parts[parts.length - 1];
312
+ }).filter((f) => f.length <= 25);
313
+ // Recent session summary words (crude topic extraction)
314
+ const summaries = db.prepare("SELECT summary FROM sessions WHERE summary IS NOT NULL ORDER BY start_time DESC LIMIT 20").all().map((r) => r.summary).join(' ');
315
+ const wordFreq = {};
316
+ const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'is', 'it', 'that', 'this', 'with', 'was', 'are', 'be', 'has', 'had', 'not', 'no', 'from', 'by', 'as', 'do', 'if', 'so', 'up', 'out', 'then', 'than', 'into', 'its', 'my', 'we', 'he', 'she', 'they', 'you', 'i', 'me', 'all', 'just', 'can', 'will', 'about', 'been', 'have', 'some', 'when', 'would', 'there', 'what', 'which', 'who', 'how', 'each', 'other', 'new', 'old', 'also', 'back', 'after', 'use', 'two', 'way', 'could', 'make', 'like', 'time', 'very', 'your', 'did', 'get', 'made', 'find', 'here', 'thing', 'many', 'well', 'only', 'any', 'those', 'over', 'such', 'our', 'them', 'his', 'her', 'one', 'file', 'files', 'session', 'sessions', 'agent', 'tool', 'message', 'messages', 'run', 'work', 'set', 'used', 'added', 'updated', 'using', 'based', 'check', 'cst', 'est', 'pst', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun', 'mon', 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec', 'via', 'per', 'yet', 'ago', 'etc', 'got']);
317
+ summaries.toLowerCase().replace(/[^a-z\s-]/g, '').split(/\s+/).filter((w) => w.length > 3 && w.length < 20 && !stopWords.has(w) && !/id$/.test(w)).forEach((w) => { wordFreq[w] = (wordFreq[w] || 0) + 1; });
318
+ const topics = Object.entries(wordFreq).sort((a, b) => b[1] - a[1]).slice(0, 5).map((e) => e[0]);
319
+ // Deduplicate and pick up to 8
320
+ const seen = new Set();
321
+ const suggestions = [];
322
+ for (const s of [...tools, ...topics, ...files]) {
323
+ const key = s.toLowerCase();
324
+ if (!seen.has(key) && suggestions.length < 8) {
325
+ seen.add(key);
326
+ suggestions.push(s);
327
+ }
328
+ }
329
+ json(res, { suggestions });
330
+ }
331
+ else if (pathname === '/api/stats') {
332
+ const sessions = db.prepare('SELECT COUNT(*) as c FROM sessions').get().c;
333
+ const events = db.prepare('SELECT COUNT(*) as c FROM events').get().c;
334
+ const messages = db.prepare("SELECT COUNT(*) as c FROM events WHERE type='message'").get().c;
335
+ const toolCalls = db.prepare("SELECT COUNT(*) as c FROM events WHERE type='tool_call'").get().c;
336
+ const tools = db.prepare("SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL").all().map((r) => r.tool_name);
337
+ const dateRange = db.prepare('SELECT MIN(start_time) as earliest, MAX(start_time) as latest FROM sessions').get();
338
+ const costData = db.prepare('SELECT SUM(total_cost) as cost, SUM(total_tokens) as tokens FROM sessions').get();
339
+ const agents = [...new Set(db.prepare('SELECT DISTINCT agent FROM sessions WHERE agent IS NOT NULL').all()
340
+ .map((r) => normalizeAgentLabel(r.agent))
341
+ .filter((a) => a !== null))];
342
+ const dbSize = getDbSize();
343
+ json(res, { sessions, events, messages, toolCalls, uniqueTools: tools.length, tools, dateRange, totalCost: costData.cost || 0, totalTokens: costData.tokens || 0, agents, storageMode: config.storage, dbSize, sessionDirs: sessionDirs.map((d) => ({ path: d.path, agent: d.agent })) });
344
+ }
345
+ else if (pathname === '/api/sessions') {
346
+ const limit = parseInt(query.limit) || 50;
347
+ const offset = parseInt(query.offset) || 0;
348
+ const agent = query.agent || '';
349
+ let sql = 'SELECT * FROM sessions';
350
+ const params = [];
351
+ if (agent) {
352
+ sql += ' WHERE agent = ?';
353
+ params.push(agent);
354
+ }
355
+ sql += ' ORDER BY COALESCE(end_time, start_time) DESC LIMIT ? OFFSET ?';
356
+ params.push(limit, offset);
357
+ const rows = db.prepare(sql).all(...params);
358
+ const countSql = agent ? 'SELECT COUNT(*) as c FROM sessions WHERE agent = ?' : 'SELECT COUNT(*) as c FROM sessions';
359
+ const total = agent ? db.prepare(countSql).get(agent).c : db.prepare(countSql).get().c;
360
+ json(res, { sessions: rows, total, limit, offset });
361
+ }
362
+ else if (pathname.match(/^\/api\/sessions\/[^/]+\/events$/)) {
363
+ const id = pathname.split('/')[3];
364
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
365
+ if (!session)
366
+ return json(res, { error: 'Not found' }, 404);
367
+ const after = query.after || '1970-01-01T00:00:00.000Z';
368
+ const afterId = query.afterId || '';
369
+ const limit = Math.min(parseInt(query.limit || '50', 10) || 50, 200);
370
+ const rows = db.prepare(`SELECT * FROM events
371
+ WHERE session_id = ?
372
+ AND (timestamp > ? OR (timestamp = ? AND id > ?))
373
+ ORDER BY timestamp ASC, id ASC
374
+ LIMIT ?`).all(id, after, after, afterId, limit);
375
+ const contextRows = (0, delta_attribution_context_js_1.loadDeltaAttributionContext)(db, id, rows);
376
+ const events = (0, project_attribution_js_1.attributeEventDelta)(session, rows, contextRows);
377
+ json(res, { events, after, afterId, count: events.length });
378
+ }
379
+ else if (pathname.match(/^\/api\/sessions\/[^/]+\/stream$/)) {
380
+ const id = pathname.split('/')[3];
381
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
382
+ if (!session)
383
+ return json(res, { error: 'Not found' }, 404);
384
+ res.writeHead(200, {
385
+ 'Content-Type': 'text/event-stream',
386
+ 'Cache-Control': 'no-cache',
387
+ 'Connection': 'keep-alive',
388
+ 'X-Accel-Buffering': 'no'
389
+ });
390
+ res.write(': connected\n\n');
391
+ let lastTs = req.headers['last-event-id'] || query.after || new Date().toISOString();
392
+ const onUpdate = (sessionId) => {
393
+ if (sessionId !== id)
394
+ return;
395
+ try {
396
+ const rows = db.prepare('SELECT * FROM events WHERE session_id = ? AND timestamp > ? ORDER BY timestamp ASC').all(id, lastTs);
397
+ if (rows.length) {
398
+ const contextRows = (0, delta_attribution_context_js_1.loadDeltaAttributionContext)(db, id, rows);
399
+ const attributedRows = (0, project_attribution_js_1.attributeEventDelta)(session, rows, contextRows);
400
+ lastTs = rows[rows.length - 1].timestamp;
401
+ res.write(`id: ${lastTs}\ndata: ${JSON.stringify(attributedRows)}\n\n`);
402
+ }
403
+ }
404
+ catch (err) {
405
+ console.error('SSE query error:', err.message);
406
+ }
407
+ };
408
+ sseEmitter.on('session-update', onUpdate);
409
+ const ping = setInterval(() => {
410
+ try {
411
+ res.write(': ping\n\n');
412
+ }
413
+ catch { /* ignore */ }
414
+ }, 30000);
415
+ req.on('close', () => {
416
+ sseEmitter.off('session-update', onUpdate);
417
+ clearInterval(ping);
418
+ });
419
+ }
420
+ else if (pathname.match(/^\/api\/sessions\/[^/]+$/) && !pathname.includes('export')) {
421
+ const id = pathname.split('/')[3];
422
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
423
+ if (!session) {
424
+ json(res, { error: 'Not found' }, 404);
425
+ }
426
+ else {
427
+ const events = db.prepare('SELECT * FROM events WHERE session_id = ? ORDER BY timestamp DESC').all(id);
428
+ const attributed = (0, project_attribution_js_1.attributeSessionEvents)(session, events);
429
+ const hasArchive = ARCHIVE_MODE && db.prepare('SELECT COUNT(*) as c FROM archive WHERE session_id = ?').get(id).c > 0;
430
+ json(res, { session, events: attributed.events, projectFilters: attributed.projectFilters, hasArchive });
431
+ }
432
+ }
433
+ else if (pathname.match(/^\/api\/archive\/session\/[^/]+$/)) {
434
+ const id = pathname.split('/')[4];
435
+ const rows = db.prepare('SELECT * FROM archive WHERE session_id = ? ORDER BY line_number ASC').all(id);
436
+ if (!rows.length) {
437
+ json(res, { error: 'No archive data for this session' }, 404);
438
+ return;
439
+ }
440
+ json(res, { session_id: id, lines: rows.map((r) => ({ line_number: r.line_number, data: JSON.parse(r.raw_json) })) });
441
+ }
442
+ else if (pathname.match(/^\/api\/archive\/export\/[^/]+$/)) {
443
+ const id = pathname.split('/')[4];
444
+ const rows = db.prepare('SELECT raw_json FROM archive WHERE session_id = ? ORDER BY line_number ASC').all(id);
445
+ if (!rows.length) {
446
+ json(res, { error: 'No archive data for this session' }, 404);
447
+ return;
448
+ }
449
+ const jsonl = rows.map((r) => r.raw_json).join('\n') + '\n';
450
+ download(res, jsonl, `session-${id.slice(0, 8)}.jsonl`, 'application/x-ndjson');
451
+ }
452
+ else if (pathname.match(/^\/api\/export\/session\/[^/]+$/)) {
453
+ const id = pathname.split('/')[4];
454
+ const format = query.format || 'json';
455
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
456
+ if (!session) {
457
+ json(res, { error: 'Not found' }, 404);
458
+ return;
459
+ }
460
+ const events = db.prepare('SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC').all(id);
461
+ if (format === 'md') {
462
+ download(res, sessionToMarkdown(session, events), `session-${id.slice(0, 8)}.md`, 'text/markdown');
463
+ }
464
+ else {
465
+ download(res, { session, events }, `session-${id.slice(0, 8)}.json`, 'application/json');
466
+ }
467
+ }
468
+ else if (pathname === '/api/export/search') {
469
+ const q = query.q || '';
470
+ const format = query.format || 'json';
471
+ if (!q) {
472
+ json(res, { error: 'No query' }, 400);
473
+ return;
474
+ }
475
+ let results;
476
+ try {
477
+ if (looksLikeSessionId(q)) {
478
+ results = db.prepare(`SELECT e.*, s.start_time as session_start, s.summary as session_summary FROM events e JOIN sessions s ON s.id = e.session_id WHERE e.session_id = ? ORDER BY e.timestamp DESC LIMIT 200`).all(q.trim());
479
+ }
480
+ else {
481
+ const ftsQuery = toFtsQuery(q);
482
+ results = db.prepare(`SELECT e.*, s.start_time as session_start, s.summary as session_summary FROM events_fts fts JOIN events e ON e.rowid = fts.rowid JOIN sessions s ON s.id = e.session_id WHERE events_fts MATCH ? ORDER BY e.timestamp DESC LIMIT 200`).all(ftsQuery);
483
+ }
484
+ }
485
+ catch {
486
+ json(res, { error: 'Invalid search query' }, 400);
487
+ return;
488
+ }
489
+ if (format === 'md') {
490
+ let md = `# Search Results: "${q}"\n\n${results.length} results\n\n`;
491
+ for (const r of results) {
492
+ md += `## [${r.timestamp}] ${r.type} (${r.role || ''})\n`;
493
+ md += `Session: ${r.session_id}\n\n`;
494
+ md += `${r.content || r.tool_args || r.tool_result || ''}\n\n---\n\n`;
495
+ }
496
+ download(res, md, `search-${q.slice(0, 20)}.md`, 'text/markdown');
497
+ }
498
+ else {
499
+ download(res, { query: q, results }, `search-${q.slice(0, 20)}.json`, 'application/json');
500
+ }
501
+ }
502
+ else if (pathname === '/api/search') {
503
+ const q = query.q || '';
504
+ const type = query.type || '';
505
+ const role = query.role || '';
506
+ const from = query.from || '';
507
+ const to = query.to || '';
508
+ const limit = Math.min(parseInt(query.limit) || 50, 200);
509
+ if (!q) {
510
+ json(res, { results: [], total: 0 });
511
+ }
512
+ else {
513
+ const isSessionLookup = looksLikeSessionId(q);
514
+ let sql;
515
+ const params = [];
516
+ if (isSessionLookup) {
517
+ sql = `SELECT e.*, s.start_time as session_start, s.summary as session_summary
518
+ FROM events e
519
+ JOIN sessions s ON s.id = e.session_id
520
+ WHERE e.session_id = ?`;
521
+ params.push(q.trim());
522
+ }
523
+ else {
524
+ sql = `SELECT e.*, s.start_time as session_start, s.summary as session_summary
525
+ FROM events_fts fts
526
+ JOIN events e ON e.rowid = fts.rowid
527
+ JOIN sessions s ON s.id = e.session_id
528
+ WHERE events_fts MATCH ?`;
529
+ params.push(toFtsQuery(q));
530
+ }
531
+ if (type) {
532
+ sql += ` AND e.type = ?`;
533
+ params.push(type);
534
+ }
535
+ if (role) {
536
+ sql += ` AND e.role = ?`;
537
+ params.push(role);
538
+ }
539
+ if (from) {
540
+ sql += ` AND e.timestamp >= ?`;
541
+ params.push(from);
542
+ }
543
+ if (to) {
544
+ sql += ` AND e.timestamp <= ?`;
545
+ params.push(to);
546
+ }
547
+ sql += ` ORDER BY e.timestamp DESC LIMIT ?`;
548
+ params.push(limit);
549
+ try {
550
+ const results = db.prepare(sql).all(...params);
551
+ json(res, { results, total: results.length });
552
+ }
553
+ catch (err) {
554
+ json(res, { error: err.message, results: [], total: 0 }, 400);
555
+ }
556
+ }
557
+ }
558
+ else if (pathname === '/api/timeline') {
559
+ const date = query.date || (() => { const n = new Date(); return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`; })();
560
+ const from = new Date(date + 'T00:00:00').toISOString();
561
+ const to = new Date(date + 'T23:59:59.999').toISOString();
562
+ const limit = Math.min(parseInt(query.limit || '100', 10) || 100, 500);
563
+ const offset = Math.max(parseInt(query.offset || '0', 10) || 0, 0);
564
+ const events = db.prepare(`SELECT e.*, s.summary as session_summary FROM events e
565
+ JOIN sessions s ON s.id = e.session_id
566
+ WHERE e.timestamp >= ? AND e.timestamp <= ?
567
+ ORDER BY e.timestamp DESC
568
+ LIMIT ? OFFSET ?`).all(from, to, limit, offset);
569
+ const total = db.prepare(`SELECT COUNT(*) as c FROM events e
570
+ WHERE e.timestamp >= ? AND e.timestamp <= ?`).get(from, to).c;
571
+ json(res, { date, events, total, limit, offset, hasMore: offset + events.length < total });
572
+ }
573
+ else if (pathname === '/api/timeline/stream') {
574
+ res.writeHead(200, {
575
+ 'Content-Type': 'text/event-stream',
576
+ 'Cache-Control': 'no-cache',
577
+ 'Connection': 'keep-alive',
578
+ 'X-Accel-Buffering': 'no'
579
+ });
580
+ res.write(': connected\n\n');
581
+ let lastTs = query.after || new Date().toISOString();
582
+ let lastId = query.afterId || '';
583
+ const onUpdate = () => {
584
+ try {
585
+ const rows = db.prepare(`SELECT e.*, s.summary as session_summary FROM events e
586
+ JOIN sessions s ON s.id = e.session_id
587
+ WHERE (e.timestamp > ?) OR (e.timestamp = ? AND e.id > ?)
588
+ ORDER BY e.timestamp ASC, e.id ASC`).all(lastTs, lastTs, lastId);
589
+ if (rows.length) {
590
+ const tail = rows[rows.length - 1];
591
+ lastTs = tail.timestamp || lastTs;
592
+ lastId = tail.id || lastId;
593
+ res.write(`id: ${lastTs}:${lastId}\ndata: ${JSON.stringify(rows)}\n\n`);
594
+ }
595
+ }
596
+ catch (err) {
597
+ console.error('Timeline SSE error:', err.message);
598
+ }
599
+ };
600
+ sseEmitter.on('session-update', onUpdate);
601
+ const ping = setInterval(() => {
602
+ try {
603
+ res.write(': ping\n\n');
604
+ }
605
+ catch { /* ignore */ }
606
+ }, 30000);
607
+ req.on('close', () => {
608
+ sseEmitter.off('session-update', onUpdate);
609
+ clearInterval(ping);
610
+ });
611
+ }
612
+ else if (pathname === '/api/maintenance') {
613
+ if (req.method !== 'POST')
614
+ return json(res, { error: 'Method not allowed' }, 405);
615
+ const sizeBefore = getDbSize();
616
+ db.pragma('wal_checkpoint(TRUNCATE)');
617
+ db.exec('VACUUM');
618
+ const sizeAfter = getDbSize();
619
+ json(res, { ok: true, sizeBefore, sizeAfter });
620
+ }
621
+ // --- Context API ---
622
+ else if (pathname === '/api/context/file') {
623
+ const fp = query.path || '';
624
+ if (!fp)
625
+ return json(res, { error: 'path parameter is required' }, 400);
626
+ const sessionCount = db.prepare('SELECT COUNT(DISTINCT session_id) as c FROM file_activity WHERE file_path = ?').get(fp).c;
627
+ if (sessionCount === 0) {
628
+ return json(res, { file: fp, sessionCount: 0, lastModified: null, recentChanges: [], operations: {}, relatedFiles: [], recentErrors: [] });
629
+ }
630
+ const lastTouched = db.prepare('SELECT MAX(timestamp) as t FROM file_activity WHERE file_path = ?').get(fp).t;
631
+ const recentChanges = db.prepare(`SELECT DISTINCT s.summary FROM file_activity fa
632
+ JOIN sessions s ON s.id = fa.session_id
633
+ WHERE fa.file_path = ? AND s.summary IS NOT NULL
634
+ ORDER BY s.start_time DESC LIMIT 5`).all(fp).map((r) => r.summary);
635
+ const opsRows = db.prepare('SELECT operation, COUNT(*) as c FROM file_activity WHERE file_path = ? GROUP BY operation').all(fp);
636
+ const operations = {};
637
+ for (const r of opsRows)
638
+ operations[r.operation] = r.c;
639
+ const relatedFiles = db.prepare(`SELECT fa2.file_path, COUNT(DISTINCT fa1.session_id) as c
640
+ FROM file_activity fa1
641
+ JOIN file_activity fa2 ON fa1.session_id = fa2.session_id
642
+ WHERE fa1.file_path = ? AND fa2.file_path != ?
643
+ GROUP BY fa2.file_path
644
+ ORDER BY c DESC LIMIT 5`).all(fp, fp).map((r) => ({ path: r.file_path, count: r.c }));
645
+ const sessionIds = db.prepare('SELECT DISTINCT session_id FROM file_activity WHERE file_path = ?').all(fp).map((r) => r.session_id);
646
+ let recentErrors = [];
647
+ if (sessionIds.length) {
648
+ const placeholders = sessionIds.map(() => '?').join(',');
649
+ recentErrors = db.prepare(`SELECT tool_result FROM events
650
+ WHERE session_id IN (${placeholders})
651
+ AND tool_result IS NOT NULL
652
+ AND (tool_result LIKE '%error%' OR tool_result LIKE '%Error%' OR tool_result LIKE '%ERROR%')
653
+ ORDER BY timestamp DESC LIMIT 3`).all(...sessionIds).map((r) => r.tool_result.slice(0, 200));
654
+ }
655
+ return json(res, {
656
+ file: fp, sessionCount,
657
+ lastModified: relativeTime(lastTouched),
658
+ recentChanges, operations, relatedFiles, recentErrors
659
+ });
660
+ }
661
+ else if (pathname === '/api/context/repo') {
662
+ const repoPath = query.path || '';
663
+ if (!repoPath)
664
+ return json(res, { error: 'path parameter is required' }, 400);
665
+ // Find sessions matching the repo path via file_activity or initial_prompt
666
+ const sessionIds = db.prepare(`SELECT DISTINCT session_id FROM file_activity WHERE file_path = ? OR file_path LIKE ?`).all(repoPath, repoPath + '/%').map((r) => r.session_id);
667
+ const promptSessions = db.prepare(`SELECT id FROM sessions WHERE initial_prompt LIKE ?`).all('%' + repoPath + '%').map((r) => r.id);
668
+ const allIds = [...new Set([...sessionIds, ...promptSessions])];
669
+ if (allIds.length === 0) {
670
+ return json(res, { repo: repoPath, sessionCount: 0, totalCost: 0, totalTokens: 0, agents: [], topFiles: [], recentSessions: [], commonTools: [], commonErrors: [] });
671
+ }
672
+ const ph = allIds.map(() => '?').join(',');
673
+ const agg = db.prepare(`SELECT COUNT(*) as c, SUM(total_cost) as cost, SUM(total_tokens) as tokens
674
+ FROM sessions WHERE id IN (${ph})`).get(...allIds);
675
+ const agents = [...new Set(db.prepare(`SELECT DISTINCT agent FROM sessions WHERE id IN (${ph}) AND agent IS NOT NULL`).all(...allIds)
676
+ .map((r) => normalizeAgentLabel(r.agent)).filter((a) => a !== null))];
677
+ const topFiles = db.prepare(`SELECT file_path, COUNT(*) as c FROM file_activity
678
+ WHERE session_id IN (${ph})
679
+ GROUP BY file_path ORDER BY c DESC LIMIT 10`).all(...allIds).map((r) => ({ path: r.file_path, count: r.c }));
680
+ const recentSessions = db.prepare(`SELECT id, summary, agent, start_time, end_time FROM sessions
681
+ WHERE id IN (${ph})
682
+ ORDER BY start_time DESC LIMIT 5`).all(...allIds).map((r) => ({
683
+ id: r.id, summary: r.summary, agent: normalizeAgentLabel(r.agent),
684
+ timestamp: r.start_time, status: r.end_time ? 'completed' : 'in-progress'
685
+ }));
686
+ const commonTools = db.prepare(`SELECT tool_name, COUNT(*) as c FROM events
687
+ WHERE session_id IN (${ph}) AND tool_name IS NOT NULL
688
+ GROUP BY tool_name ORDER BY c DESC LIMIT 10`).all(...allIds).map((r) => ({ tool: r.tool_name, count: r.c }));
689
+ const commonErrors = db.prepare(`SELECT DISTINCT SUBSTR(tool_result, 1, 200) as err FROM events
690
+ WHERE session_id IN (${ph})
691
+ AND tool_result IS NOT NULL
692
+ AND (tool_result LIKE '%error%' OR tool_result LIKE '%Error%' OR tool_result LIKE '%ERROR%')
693
+ ORDER BY timestamp DESC LIMIT 5`).all(...allIds).map((r) => r.err);
694
+ return json(res, {
695
+ repo: repoPath, sessionCount: allIds.length,
696
+ totalCost: agg.cost || 0, totalTokens: agg.tokens || 0,
697
+ agents, topFiles, recentSessions, commonTools, commonErrors
698
+ });
699
+ }
700
+ else if (pathname === '/api/context/agent') {
701
+ const name = query.name || '';
702
+ if (!name)
703
+ return json(res, { error: 'name parameter is required' }, 400);
704
+ // Try exact match first, then check all sessions with normalized label match
705
+ let sessions = db.prepare('SELECT * FROM sessions WHERE agent = ?').all(name);
706
+ if (sessions.length === 0) {
707
+ sessions = db.prepare('SELECT * FROM sessions WHERE agent IS NOT NULL').all()
708
+ .filter((s) => normalizeAgentLabel(s.agent) === name);
709
+ }
710
+ if (sessions.length === 0) {
711
+ return json(res, { agent: name, sessionCount: 0, totalCost: 0, avgDuration: 0, topTools: [], recentSessions: [], successRate: 0 });
712
+ }
713
+ const totalCost = sessions.reduce((sum, r) => sum + (r.total_cost || 0), 0);
714
+ let totalDuration = 0;
715
+ let durationCount = 0;
716
+ for (const s of sessions) {
717
+ if (s.start_time && s.end_time) {
718
+ totalDuration += (new Date(s.end_time).getTime() - new Date(s.start_time).getTime()) / 1000;
719
+ durationCount++;
720
+ }
721
+ }
722
+ const avgDuration = durationCount > 0 ? Math.round(totalDuration / durationCount) : 0;
723
+ const withSummary = sessions.filter((s) => s.summary).length;
724
+ const successRate = Math.round((withSummary / sessions.length) * 100);
725
+ const ids = sessions.map((s) => s.id);
726
+ const ph = ids.map(() => '?').join(',');
727
+ const topTools = db.prepare(`SELECT tool_name, COUNT(*) as c FROM events
728
+ WHERE session_id IN (${ph}) AND tool_name IS NOT NULL
729
+ GROUP BY tool_name ORDER BY c DESC LIMIT 10`).all(...ids).map((r) => ({ tool: r.tool_name, count: r.c }));
730
+ const recentSessions = sessions
731
+ .sort((a, b) => (b.start_time || '').localeCompare(a.start_time || ''))
732
+ .slice(0, 5)
733
+ .map((s) => ({ id: s.id, summary: s.summary, timestamp: s.start_time }));
734
+ return json(res, {
735
+ agent: name, sessionCount: sessions.length,
736
+ totalCost, avgDuration, topTools, recentSessions, successRate
737
+ });
738
+ }
739
+ else if (pathname === '/api/files') {
740
+ const limit = parseInt(query.limit) || 100;
741
+ const offset = parseInt(query.offset) || 0;
742
+ const rows = db.prepare(`
743
+ SELECT file_path, COUNT(*) as touch_count, COUNT(DISTINCT session_id) as session_count,
744
+ MAX(timestamp) as last_touched,
745
+ GROUP_CONCAT(DISTINCT operation) as operations
746
+ FROM file_activity
747
+ GROUP BY file_path
748
+ ORDER BY touch_count DESC
749
+ LIMIT ? OFFSET ?
750
+ `).all(limit, offset);
751
+ const total = db.prepare('SELECT COUNT(DISTINCT file_path) as c FROM file_activity').get().c;
752
+ json(res, { files: rows, total, limit, offset });
753
+ }
754
+ else if (pathname === '/api/files/sessions') {
755
+ const fp = query.path || '';
756
+ if (!fp) {
757
+ json(res, { sessions: [] });
758
+ return;
759
+ }
760
+ const rows = db.prepare(`
761
+ SELECT DISTINCT s.*, fa.operation, fa.timestamp as touch_time
762
+ FROM file_activity fa
763
+ JOIN sessions s ON s.id = fa.session_id
764
+ WHERE fa.file_path = ?
765
+ ORDER BY fa.timestamp DESC
766
+ `).all(fp);
767
+ json(res, { file: fp, sessions: rows });
768
+ }
769
+ else if (pathname === '/api/insights') {
770
+ const summary = (0, insights_js_1.getInsightsSummary)(db);
771
+ return json(res, summary);
772
+ }
773
+ else if (pathname.match(/^\/api\/insights\/session\/[^/]+$/)) {
774
+ const id = pathname.split('/')[4];
775
+ const row = db.prepare('SELECT * FROM session_insights WHERE session_id = ?').get(id);
776
+ if (!row) {
777
+ // Compute on-the-fly if not yet analyzed
778
+ const result = (0, insights_js_1.analyzeSession)(db, id);
779
+ if (!result)
780
+ return json(res, { error: 'Session not found' }, 404);
781
+ return json(res, result);
782
+ }
783
+ return json(res, {
784
+ session_id: row.session_id,
785
+ signals: JSON.parse(row.signals || '[]'),
786
+ confusion_score: row.confusion_score,
787
+ flagged: !!row.flagged,
788
+ computed_at: row.computed_at
789
+ });
790
+ }
791
+ else if (!serveStatic(req, res)) {
792
+ const index = node_path_1.default.join(PUBLIC, 'index.html');
793
+ if (node_fs_1.default.existsSync(index)) {
794
+ res.writeHead(200, { 'Content-Type': 'text/html' });
795
+ node_fs_1.default.createReadStream(index).pipe(res);
796
+ }
797
+ else {
798
+ json(res, { error: 'Not found' }, 404);
799
+ }
800
+ }
801
+ }
802
+ catch (err) {
803
+ console.error(err);
804
+ json(res, { error: err.message }, 500);
805
+ }
806
+ });
807
+ const HOST = process.env.AGENTACTA_HOST || '127.0.0.1';
808
+ server.on('error', (err) => {
809
+ if (err.code === 'EADDRINUSE') {
810
+ console.error(`Port ${PORT} in use, retrying in 2s...`);
811
+ setTimeout(() => {
812
+ server.close();
813
+ server.listen(PORT, HOST);
814
+ }, 2000);
815
+ }
816
+ else {
817
+ throw err;
818
+ }
819
+ });
820
+ server.listen(PORT, HOST, () => console.log(`AgentActa running on http://${HOST}:${PORT}`));
821
+ // Graceful shutdown
822
+ function shutdown(signal) {
823
+ console.log(`\n${signal} received, shutting down...`);
824
+ server.close(() => {
825
+ try {
826
+ db.close();
827
+ }
828
+ catch { /* ignore */ }
829
+ console.log('AgentActa stopped.');
830
+ process.exit(0);
831
+ });
832
+ // Force exit after 5s if server doesn't close
833
+ setTimeout(() => {
834
+ try {
835
+ db.close();
836
+ }
837
+ catch { /* ignore */ }
838
+ process.exit(1);
839
+ }, 5000);
840
+ }
841
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
842
+ process.on('SIGINT', () => shutdown('SIGINT'));
843
+ //# sourceMappingURL=index.js.map