agentacta 1.0.0
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/LICENSE +21 -0
- package/README.md +216 -0
- package/config.js +55 -0
- package/db.js +137 -0
- package/index.js +376 -0
- package/indexer.js +330 -0
- package/package.json +51 -0
- package/public/app.js +658 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +72 -0
- package/public/index.html +50 -0
- package/public/manifest.json +26 -0
- package/public/style.css +562 -0
- package/public/sw.js +42 -0
package/index.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// --demo flag: use demo session data (must run before config load)
|
|
7
|
+
if (process.argv.includes('--demo')) {
|
|
8
|
+
const demoDir = path.join(__dirname, 'demo');
|
|
9
|
+
if (!fs.existsSync(demoDir) || fs.readdirSync(demoDir).filter(f => f.endsWith('.jsonl')).length === 0) {
|
|
10
|
+
console.error('Demo data not found. Run: node scripts/seed-demo.js');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
process.env.AGENTACTA_SESSIONS_PATH = demoDir;
|
|
14
|
+
process.env.AGENTACTA_DB_PATH = path.join(demoDir, 'demo.db');
|
|
15
|
+
console.log(`Demo mode: using sessions from ${demoDir}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { loadConfig } = require('./config');
|
|
19
|
+
const { open, init, createStmts } = require('./db');
|
|
20
|
+
const { discoverSessionDirs, indexFile } = require('./indexer');
|
|
21
|
+
|
|
22
|
+
const config = loadConfig();
|
|
23
|
+
const PORT = config.port;
|
|
24
|
+
const ARCHIVE_MODE = config.storage === 'archive';
|
|
25
|
+
|
|
26
|
+
console.log(`AgentActa running in ${config.storage} mode`);
|
|
27
|
+
|
|
28
|
+
const PUBLIC = path.join(__dirname, 'public');
|
|
29
|
+
const MIME = {
|
|
30
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
31
|
+
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
|
|
32
|
+
'.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function json(res, data, status = 200) {
|
|
36
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
37
|
+
res.end(JSON.stringify(data));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function download(res, data, filename, contentType) {
|
|
41
|
+
res.writeHead(200, {
|
|
42
|
+
'Content-Type': contentType,
|
|
43
|
+
'Content-Disposition': `attachment; filename="${filename}"`
|
|
44
|
+
});
|
|
45
|
+
res.end(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function serveStatic(req, res) {
|
|
49
|
+
let fp = path.join(PUBLIC, req.url.split('?')[0] === '/' ? 'index.html' : req.url.split('?')[0]);
|
|
50
|
+
fp = path.normalize(fp);
|
|
51
|
+
if (!fp.startsWith(PUBLIC)) { res.writeHead(403); res.end(); return true; }
|
|
52
|
+
if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
|
|
53
|
+
const ext = path.extname(fp);
|
|
54
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
55
|
+
fs.createReadStream(fp).pipe(res);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseQuery(url) {
|
|
62
|
+
const u = new URL(url, 'http://localhost');
|
|
63
|
+
const o = {};
|
|
64
|
+
u.searchParams.forEach((v, k) => o[k] = v);
|
|
65
|
+
return { pathname: u.pathname, query: o };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sessionToMarkdown(session, events) {
|
|
69
|
+
let md = `# Session: ${session.id}\n`;
|
|
70
|
+
md += `- **Start:** ${session.start_time}\n`;
|
|
71
|
+
md += `- **End:** ${session.end_time || 'N/A'}\n`;
|
|
72
|
+
md += `- **Model:** ${session.model || 'N/A'}\n`;
|
|
73
|
+
md += `- **Agent:** ${session.agent || 'main'}\n`;
|
|
74
|
+
md += `- **Messages:** ${session.message_count} | **Tools:** ${session.tool_count}\n`;
|
|
75
|
+
md += `- **Cost:** $${(session.total_cost || 0).toFixed(4)} | **Tokens:** ${(session.total_tokens || 0).toLocaleString()}\n\n`;
|
|
76
|
+
md += `## Summary\n${session.summary || 'No summary'}\n\n## Events\n\n`;
|
|
77
|
+
for (const e of events) {
|
|
78
|
+
const time = e.timestamp ? new Date(e.timestamp).toISOString() : '';
|
|
79
|
+
if (e.type === 'tool_call') {
|
|
80
|
+
md += `### [${time}] Tool: ${e.tool_name}\n\`\`\`json\n${e.tool_args || ''}\n\`\`\`\n\n`;
|
|
81
|
+
} else if (e.type === 'tool_result') {
|
|
82
|
+
md += `### [${time}] Result: ${e.tool_name}\n\`\`\`\n${(e.content || '').slice(0, 2000)}\n\`\`\`\n\n`;
|
|
83
|
+
} else {
|
|
84
|
+
md += `### [${time}] ${e.role || e.type}\n${e.content || ''}\n\n`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return md;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getDbSize() {
|
|
91
|
+
try {
|
|
92
|
+
const stat = fs.statSync(config.dbPath);
|
|
93
|
+
const mb = stat.size / (1024 * 1024);
|
|
94
|
+
return { bytes: stat.size, display: mb >= 1 ? `${mb.toFixed(1)} MB` : `${(stat.size / 1024).toFixed(1)} KB` };
|
|
95
|
+
} catch {
|
|
96
|
+
return { bytes: 0, display: 'N/A' };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Init DB and start watcher
|
|
101
|
+
init();
|
|
102
|
+
const db = open();
|
|
103
|
+
|
|
104
|
+
// Live re-indexing setup
|
|
105
|
+
const stmts = createStmts(db);
|
|
106
|
+
|
|
107
|
+
const sessionDirs = discoverSessionDirs(config);
|
|
108
|
+
|
|
109
|
+
// Initial indexing pass
|
|
110
|
+
for (const dir of sessionDirs) {
|
|
111
|
+
const files = fs.readdirSync(dir.path).filter(f => f.endsWith('.jsonl'));
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
try {
|
|
114
|
+
const result = indexFile(db, path.join(dir.path, file), dir.agent, stmts, ARCHIVE_MODE);
|
|
115
|
+
if (!result.skipped) console.log(`Indexed: ${file} (${dir.agent})`);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`Error indexing ${file}:`, err.message);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`Watching ${sessionDirs.length} session directories`);
|
|
123
|
+
|
|
124
|
+
for (const dir of sessionDirs) {
|
|
125
|
+
try {
|
|
126
|
+
fs.watch(dir.path, { persistent: false }, (eventType, filename) => {
|
|
127
|
+
if (!filename || !filename.endsWith('.jsonl')) return;
|
|
128
|
+
const filePath = path.join(dir.path, filename);
|
|
129
|
+
if (!fs.existsSync(filePath)) return;
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
try {
|
|
132
|
+
const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE);
|
|
133
|
+
if (!result.skipped) console.log(`Live re-indexed: ${filename} (${dir.agent})`);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error(`Error re-indexing ${filename}:`, err.message);
|
|
136
|
+
}
|
|
137
|
+
}, 500);
|
|
138
|
+
});
|
|
139
|
+
console.log(` Watching: ${dir.path}`);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(` Failed to watch ${dir.path}:`, err.message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const server = http.createServer((req, res) => {
|
|
146
|
+
const { pathname, query } = parseQuery(req.url);
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (pathname === '/api/reindex') {
|
|
150
|
+
const { indexAll } = require('./indexer');
|
|
151
|
+
const result = indexAll(db, config);
|
|
152
|
+
return json(res, { ok: true, sessions: result.sessions, events: result.events });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
else if (pathname === '/api/config') {
|
|
156
|
+
const dbSize = getDbSize();
|
|
157
|
+
const archiveCount = db.prepare('SELECT COUNT(*) as c FROM archive').get().c;
|
|
158
|
+
json(res, {
|
|
159
|
+
storage: config.storage,
|
|
160
|
+
port: config.port,
|
|
161
|
+
dbPath: config.dbPath,
|
|
162
|
+
dbSize: dbSize,
|
|
163
|
+
sessionsPath: config.sessionsPath,
|
|
164
|
+
sessionDirs: sessionDirs.map(d => ({ path: d.path, agent: d.agent })),
|
|
165
|
+
archiveEnabled: ARCHIVE_MODE,
|
|
166
|
+
archiveRows: archiveCount
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else if (pathname === '/api/suggestions') {
|
|
170
|
+
// Top tool names (most used)
|
|
171
|
+
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);
|
|
172
|
+
// Most touched files (short basenames)
|
|
173
|
+
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 => {
|
|
174
|
+
const parts = r.file_path.split('/');
|
|
175
|
+
return parts[parts.length - 1];
|
|
176
|
+
}).filter(f => f.length <= 25);
|
|
177
|
+
// Recent session summary words (crude topic extraction)
|
|
178
|
+
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(' ');
|
|
179
|
+
const wordFreq = {};
|
|
180
|
+
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']);
|
|
181
|
+
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; });
|
|
182
|
+
const topics = Object.entries(wordFreq).sort((a, b) => b[1] - a[1]).slice(0, 5).map(e => e[0]);
|
|
183
|
+
// Deduplicate and pick up to 8
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
const suggestions = [];
|
|
186
|
+
for (const s of [...tools, ...topics, ...files]) {
|
|
187
|
+
const key = s.toLowerCase();
|
|
188
|
+
if (!seen.has(key) && suggestions.length < 8) { seen.add(key); suggestions.push(s); }
|
|
189
|
+
}
|
|
190
|
+
json(res, { suggestions });
|
|
191
|
+
}
|
|
192
|
+
else if (pathname === '/api/stats') {
|
|
193
|
+
const sessions = db.prepare('SELECT COUNT(*) as c FROM sessions').get().c;
|
|
194
|
+
const events = db.prepare('SELECT COUNT(*) as c FROM events').get().c;
|
|
195
|
+
const messages = db.prepare("SELECT COUNT(*) as c FROM events WHERE type='message'").get().c;
|
|
196
|
+
const toolCalls = db.prepare("SELECT COUNT(*) as c FROM events WHERE type='tool_call'").get().c;
|
|
197
|
+
const tools = db.prepare("SELECT DISTINCT tool_name FROM events WHERE tool_name IS NOT NULL").all().map(r => r.tool_name);
|
|
198
|
+
const dateRange = db.prepare('SELECT MIN(start_time) as earliest, MAX(start_time) as latest FROM sessions').get();
|
|
199
|
+
const costData = db.prepare('SELECT SUM(total_cost) as cost, SUM(total_tokens) as tokens FROM sessions').get();
|
|
200
|
+
const agents = db.prepare('SELECT DISTINCT agent FROM sessions WHERE agent IS NOT NULL').all().map(r => r.agent);
|
|
201
|
+
const dbSize = getDbSize();
|
|
202
|
+
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 })) });
|
|
203
|
+
}
|
|
204
|
+
else if (pathname === '/api/sessions') {
|
|
205
|
+
const limit = parseInt(query.limit) || 50;
|
|
206
|
+
const offset = parseInt(query.offset) || 0;
|
|
207
|
+
const agent = query.agent || '';
|
|
208
|
+
let sql = 'SELECT * FROM sessions';
|
|
209
|
+
const params = [];
|
|
210
|
+
if (agent) { sql += ' WHERE agent = ?'; params.push(agent); }
|
|
211
|
+
sql += ' ORDER BY COALESCE(end_time, start_time) DESC LIMIT ? OFFSET ?';
|
|
212
|
+
params.push(limit, offset);
|
|
213
|
+
const rows = db.prepare(sql).all(...params);
|
|
214
|
+
const countSql = agent ? 'SELECT COUNT(*) as c FROM sessions WHERE agent = ?' : 'SELECT COUNT(*) as c FROM sessions';
|
|
215
|
+
const total = agent ? db.prepare(countSql).get(agent).c : db.prepare(countSql).get().c;
|
|
216
|
+
json(res, { sessions: rows, total, limit, offset });
|
|
217
|
+
}
|
|
218
|
+
else if (pathname.match(/^\/api\/sessions\/[^/]+$/) && !pathname.includes('export')) {
|
|
219
|
+
const id = pathname.split('/')[3];
|
|
220
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
|
|
221
|
+
if (!session) { json(res, { error: 'Not found' }, 404); }
|
|
222
|
+
else {
|
|
223
|
+
const events = db.prepare('SELECT * FROM events WHERE session_id = ? ORDER BY timestamp DESC').all(id);
|
|
224
|
+
const hasArchive = ARCHIVE_MODE && db.prepare('SELECT COUNT(*) as c FROM archive WHERE session_id = ?').get(id).c > 0;
|
|
225
|
+
json(res, { session, events, hasArchive });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else if (pathname.match(/^\/api\/archive\/session\/[^/]+$/)) {
|
|
229
|
+
const id = pathname.split('/')[4];
|
|
230
|
+
const rows = db.prepare('SELECT * FROM archive WHERE session_id = ? ORDER BY line_number ASC').all(id);
|
|
231
|
+
if (!rows.length) { json(res, { error: 'No archive data for this session' }, 404); return; }
|
|
232
|
+
json(res, { session_id: id, lines: rows.map(r => ({ line_number: r.line_number, data: JSON.parse(r.raw_json) })) });
|
|
233
|
+
}
|
|
234
|
+
else if (pathname.match(/^\/api\/archive\/export\/[^/]+$/)) {
|
|
235
|
+
const id = pathname.split('/')[4];
|
|
236
|
+
const rows = db.prepare('SELECT raw_json FROM archive WHERE session_id = ? ORDER BY line_number ASC').all(id);
|
|
237
|
+
if (!rows.length) { json(res, { error: 'No archive data for this session' }, 404); return; }
|
|
238
|
+
const jsonl = rows.map(r => r.raw_json).join('\n') + '\n';
|
|
239
|
+
download(res, jsonl, `session-${id.slice(0,8)}.jsonl`, 'application/x-ndjson');
|
|
240
|
+
}
|
|
241
|
+
else if (pathname.match(/^\/api\/export\/session\/[^/]+$/)) {
|
|
242
|
+
const id = pathname.split('/')[4];
|
|
243
|
+
const format = query.format || 'json';
|
|
244
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
|
|
245
|
+
if (!session) { json(res, { error: 'Not found' }, 404); return; }
|
|
246
|
+
const events = db.prepare('SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC').all(id);
|
|
247
|
+
if (format === 'md') {
|
|
248
|
+
download(res, sessionToMarkdown(session, events), `session-${id.slice(0,8)}.md`, 'text/markdown');
|
|
249
|
+
} else {
|
|
250
|
+
download(res, { session, events }, `session-${id.slice(0,8)}.json`, 'application/json');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else if (pathname === '/api/export/search') {
|
|
254
|
+
const q = query.q || '';
|
|
255
|
+
const format = query.format || 'json';
|
|
256
|
+
if (!q) { json(res, { error: 'No query' }, 400); return; }
|
|
257
|
+
let results;
|
|
258
|
+
try {
|
|
259
|
+
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(q);
|
|
260
|
+
} catch (err) { json(res, { error: 'Invalid search query' }, 400); return; }
|
|
261
|
+
if (format === 'md') {
|
|
262
|
+
let md = `# Search Results: "${q}"\n\n${results.length} results\n\n`;
|
|
263
|
+
for (const r of results) {
|
|
264
|
+
md += `## [${r.timestamp}] ${r.type} (${r.role || ''})\n`;
|
|
265
|
+
md += `Session: ${r.session_id}\n\n`;
|
|
266
|
+
md += `${r.content || r.tool_args || r.tool_result || ''}\n\n---\n\n`;
|
|
267
|
+
}
|
|
268
|
+
download(res, md, `search-${q.slice(0,20)}.md`, 'text/markdown');
|
|
269
|
+
} else {
|
|
270
|
+
download(res, { query: q, results }, `search-${q.slice(0,20)}.json`, 'application/json');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else if (pathname === '/api/search') {
|
|
274
|
+
const q = query.q || '';
|
|
275
|
+
const type = query.type || '';
|
|
276
|
+
const role = query.role || '';
|
|
277
|
+
const from = query.from || '';
|
|
278
|
+
const to = query.to || '';
|
|
279
|
+
const limit = Math.min(parseInt(query.limit) || 50, 200);
|
|
280
|
+
|
|
281
|
+
if (!q) { json(res, { results: [], total: 0 }); }
|
|
282
|
+
else {
|
|
283
|
+
let sql = `SELECT e.*, s.start_time as session_start, s.summary as session_summary
|
|
284
|
+
FROM events_fts fts
|
|
285
|
+
JOIN events e ON e.rowid = fts.rowid
|
|
286
|
+
JOIN sessions s ON s.id = e.session_id
|
|
287
|
+
WHERE events_fts MATCH ?`;
|
|
288
|
+
const params = [q];
|
|
289
|
+
if (type) { sql += ` AND e.type = ?`; params.push(type); }
|
|
290
|
+
if (role) { sql += ` AND e.role = ?`; params.push(role); }
|
|
291
|
+
if (from) { sql += ` AND e.timestamp >= ?`; params.push(from); }
|
|
292
|
+
if (to) { sql += ` AND e.timestamp <= ?`; params.push(to); }
|
|
293
|
+
sql += ` ORDER BY e.timestamp DESC LIMIT ?`;
|
|
294
|
+
params.push(limit);
|
|
295
|
+
try {
|
|
296
|
+
const results = db.prepare(sql).all(...params);
|
|
297
|
+
json(res, { results, total: results.length });
|
|
298
|
+
} catch (err) {
|
|
299
|
+
json(res, { error: err.message, results: [], total: 0 }, 400);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else if (pathname === '/api/timeline') {
|
|
304
|
+
const date = query.date || new Date().toISOString().slice(0, 10);
|
|
305
|
+
const from = date + 'T00:00:00.000Z';
|
|
306
|
+
const to = date + 'T23:59:59.999Z';
|
|
307
|
+
const events = db.prepare(
|
|
308
|
+
`SELECT e.*, s.summary as session_summary FROM events e
|
|
309
|
+
JOIN sessions s ON s.id = e.session_id
|
|
310
|
+
WHERE e.timestamp >= ? AND e.timestamp <= ?
|
|
311
|
+
ORDER BY e.timestamp DESC`
|
|
312
|
+
).all(from, to);
|
|
313
|
+
json(res, { date, events, total: events.length });
|
|
314
|
+
}
|
|
315
|
+
else if (pathname === '/api/files') {
|
|
316
|
+
const limit = parseInt(query.limit) || 100;
|
|
317
|
+
const offset = parseInt(query.offset) || 0;
|
|
318
|
+
const rows = db.prepare(`
|
|
319
|
+
SELECT file_path, COUNT(*) as touch_count, COUNT(DISTINCT session_id) as session_count,
|
|
320
|
+
MAX(timestamp) as last_touched,
|
|
321
|
+
GROUP_CONCAT(DISTINCT operation) as operations
|
|
322
|
+
FROM file_activity
|
|
323
|
+
GROUP BY file_path
|
|
324
|
+
ORDER BY touch_count DESC
|
|
325
|
+
LIMIT ? OFFSET ?
|
|
326
|
+
`).all(limit, offset);
|
|
327
|
+
const total = db.prepare('SELECT COUNT(DISTINCT file_path) as c FROM file_activity').get().c;
|
|
328
|
+
json(res, { files: rows, total, limit, offset });
|
|
329
|
+
}
|
|
330
|
+
else if (pathname === '/api/files/sessions') {
|
|
331
|
+
const fp = query.path || '';
|
|
332
|
+
if (!fp) { json(res, { sessions: [] }); return; }
|
|
333
|
+
const rows = db.prepare(`
|
|
334
|
+
SELECT DISTINCT s.*, fa.operation, fa.timestamp as touch_time
|
|
335
|
+
FROM file_activity fa
|
|
336
|
+
JOIN sessions s ON s.id = fa.session_id
|
|
337
|
+
WHERE fa.file_path = ?
|
|
338
|
+
ORDER BY fa.timestamp DESC
|
|
339
|
+
`).all(fp);
|
|
340
|
+
json(res, { file: fp, sessions: rows });
|
|
341
|
+
}
|
|
342
|
+
else if (!serveStatic(req, res)) {
|
|
343
|
+
const index = path.join(PUBLIC, 'index.html');
|
|
344
|
+
if (fs.existsSync(index)) {
|
|
345
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
346
|
+
fs.createReadStream(index).pipe(res);
|
|
347
|
+
} else {
|
|
348
|
+
json(res, { error: 'Not found' }, 404);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} catch (err) {
|
|
352
|
+
console.error(err);
|
|
353
|
+
json(res, { error: err.message }, 500);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const HOST = process.env.AGENTACTA_HOST || '127.0.0.1';
|
|
358
|
+
server.listen(PORT, HOST, () => console.log(`AgentActa running on http://${HOST}:${PORT}`));
|
|
359
|
+
|
|
360
|
+
// Graceful shutdown
|
|
361
|
+
function shutdown(signal) {
|
|
362
|
+
console.log(`\n${signal} received, shutting down...`);
|
|
363
|
+
server.close(() => {
|
|
364
|
+
try { db.close(); } catch {}
|
|
365
|
+
console.log('AgentActa stopped.');
|
|
366
|
+
process.exit(0);
|
|
367
|
+
});
|
|
368
|
+
// Force exit after 5s if server doesn't close
|
|
369
|
+
setTimeout(() => {
|
|
370
|
+
try { db.close(); } catch {}
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}, 5000);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
376
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|