agentacta 1.2.2 → 1.3.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/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  const http = require('http');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { EventEmitter } = require('events');
5
6
 
6
7
  // --demo flag: use demo session data (must run before config load)
7
8
  if (process.argv.includes('--demo')) {
@@ -129,6 +130,10 @@ const db = open();
129
130
  // Live re-indexing setup
130
131
  const stmts = createStmts(db);
131
132
 
133
+ // SSE emitter: notifies connected clients when a session is re-indexed
134
+ const sseEmitter = new EventEmitter();
135
+ sseEmitter.setMaxListeners(100);
136
+
132
137
  const sessionDirs = discoverSessionDirs(config);
133
138
 
134
139
  // Initial indexing pass
@@ -163,7 +168,10 @@ for (const dir of sessionDirs) {
163
168
  _reindexTimers.delete(filePath);
164
169
  try {
165
170
  const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE);
166
- if (!result.skipped) console.log(`Live re-indexed: ${filename} (${dir.agent})`);
171
+ if (!result.skipped) {
172
+ console.log(`Live re-indexed: ${filename} (${dir.agent})`);
173
+ if (result.sessionId) sseEmitter.emit('session-update', result.sessionId);
174
+ }
167
175
  } catch (err) {
168
176
  console.error(`Error re-indexing ${filename}:`, err.message);
169
177
  }
@@ -252,6 +260,66 @@ const server = http.createServer((req, res) => {
252
260
  const total = agent ? db.prepare(countSql).get(agent).c : db.prepare(countSql).get().c;
253
261
  json(res, { sessions: rows, total, limit, offset });
254
262
  }
263
+
264
+ else if (pathname.match(/^\/api\/sessions\/[^/]+\/events$/)) {
265
+ const id = pathname.split('/')[3];
266
+ const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
267
+ if (!session) return json(res, { error: 'Not found' }, 404);
268
+
269
+ const after = query.after || '1970-01-01T00:00:00.000Z';
270
+ const afterId = query.afterId || '';
271
+ const limit = Math.min(parseInt(query.limit || '50', 10) || 50, 200);
272
+ const rows = db.prepare(
273
+ `SELECT * FROM events
274
+ WHERE session_id = ?
275
+ AND (timestamp > ? OR (timestamp = ? AND id > ?))
276
+ ORDER BY timestamp ASC, id ASC
277
+ LIMIT ?`
278
+ ).all(id, after, after, afterId, limit);
279
+ json(res, { events: rows, after, afterId, count: rows.length });
280
+ }
281
+
282
+ else if (pathname.match(/^\/api\/sessions\/[^/]+\/stream$/)) {
283
+ const id = pathname.split('/')[3];
284
+ const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
285
+ if (!session) return json(res, { error: 'Not found' }, 404);
286
+
287
+ res.writeHead(200, {
288
+ 'Content-Type': 'text/event-stream',
289
+ 'Cache-Control': 'no-cache',
290
+ 'Connection': 'keep-alive',
291
+ 'X-Accel-Buffering': 'no'
292
+ });
293
+ res.write(': connected\n\n');
294
+
295
+ let lastTs = req.headers['last-event-id'] || query.after || new Date().toISOString();
296
+
297
+ const onUpdate = (sessionId) => {
298
+ if (sessionId !== id) return;
299
+ try {
300
+ const rows = db.prepare(
301
+ 'SELECT * FROM events WHERE session_id = ? AND timestamp > ? ORDER BY timestamp ASC'
302
+ ).all(id, lastTs);
303
+ if (rows.length) {
304
+ lastTs = rows[rows.length - 1].timestamp;
305
+ res.write(`id: ${lastTs}\ndata: ${JSON.stringify(rows)}\n\n`);
306
+ }
307
+ } catch (err) {
308
+ console.error('SSE query error:', err.message);
309
+ }
310
+ };
311
+
312
+ sseEmitter.on('session-update', onUpdate);
313
+
314
+ const ping = setInterval(() => {
315
+ try { res.write(': ping\n\n'); } catch {}
316
+ }, 30000);
317
+
318
+ req.on('close', () => {
319
+ sseEmitter.off('session-update', onUpdate);
320
+ clearInterval(ping);
321
+ });
322
+ }
255
323
  else if (pathname.match(/^\/api\/sessions\/[^/]+$/) && !pathname.includes('export')) {
256
324
  const id = pathname.split('/')[3];
257
325
  const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentacta",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Audit trail and search engine for AI agent sessions",
5
5
  "main": "index.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -383,6 +383,7 @@ async function viewSessions() {
383
383
  }
384
384
 
385
385
  async function viewSession(id) {
386
+ if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
386
387
  window._currentSessionId = id;
387
388
  window.scrollTo(0, 0);
388
389
  const data = await api(`/sessions/${id}`);
@@ -469,6 +470,7 @@ async function viewSession(id) {
469
470
 
470
471
  $('#backBtn').addEventListener('click', () => {
471
472
  if (onScroll) { window.removeEventListener('scroll', onScroll); onScroll = null; }
473
+ if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
472
474
  if (window._lastView === 'timeline') viewTimeline();
473
475
  else if (window._lastView === 'files') viewFiles();
474
476
  else if (window._lastView === 'search') viewSearch(window._lastSearchQuery || '');
@@ -531,6 +533,81 @@ async function viewSession(id) {
531
533
  }
532
534
  });
533
535
  }
536
+
537
+ // --- Lightweight realtime updates (polling fallback first) ---
538
+ const knownIds = new Set(allEvents.map(e => e.id));
539
+ let pendingNewCount = 0;
540
+
541
+ const applyIncomingEvents = (incoming) => {
542
+ const container = document.getElementById('eventsContainer');
543
+ if (!container || !incoming?.length) return;
544
+
545
+ const fresh = incoming.filter(e => !knownIds.has(e.id));
546
+ if (!fresh.length) return;
547
+ fresh.forEach(e => knownIds.add(e.id));
548
+
549
+ const isAtTop = window.scrollY < 100;
550
+ for (const ev of fresh) {
551
+ const div = document.createElement('div');
552
+ div.innerHTML = renderEvent(ev);
553
+ const el = div.firstElementChild;
554
+ el.classList.add('event-highlight');
555
+ container.insertBefore(el, container.firstChild);
556
+ setTimeout(() => el.classList.remove('event-highlight'), 2000);
557
+ }
558
+
559
+ if (!isAtTop) {
560
+ pendingNewCount += fresh.length;
561
+ let indicator = document.getElementById('newEventsIndicator');
562
+ if (!indicator) {
563
+ indicator = document.createElement('div');
564
+ indicator.id = 'newEventsIndicator';
565
+ indicator.className = 'new-events-indicator';
566
+ document.body.appendChild(indicator);
567
+ indicator.addEventListener('click', () => {
568
+ window.scrollTo({ top: 0, behavior: 'smooth' });
569
+ indicator.remove();
570
+ pendingNewCount = 0;
571
+ });
572
+ }
573
+ indicator.textContent = `${pendingNewCount} new event${pendingNewCount !== 1 ? 's' : ''} ↑`;
574
+ }
575
+ };
576
+
577
+ // Poll every 3s for new events using delta endpoint
578
+ let lastSeenTs = allEvents.length ? allEvents[0].timestamp : new Date(0).toISOString();
579
+ let lastSeenId = allEvents.length ? allEvents[0].id : '';
580
+ const pollNewEvents = async () => {
581
+ try {
582
+ const latest = await api(`/sessions/${id}/events?after=${encodeURIComponent(lastSeenTs)}&afterId=${encodeURIComponent(lastSeenId)}&limit=50`);
583
+ const incoming = latest.events || [];
584
+ if (incoming.length) {
585
+ const tail = incoming[incoming.length - 1];
586
+ lastSeenTs = tail.timestamp || lastSeenTs;
587
+ lastSeenId = tail.id || lastSeenId;
588
+ applyIncomingEvents(incoming);
589
+ }
590
+ } catch (err) {
591
+ // silent; next tick will retry
592
+ }
593
+ };
594
+
595
+ const pollInterval = setInterval(pollNewEvents, 3000);
596
+
597
+ const sseScrollHandler = () => {
598
+ if (window.scrollY < 100) {
599
+ const ind = document.getElementById('newEventsIndicator');
600
+ if (ind) { ind.remove(); pendingNewCount = 0; }
601
+ }
602
+ };
603
+ window.addEventListener('scroll', sseScrollHandler, { passive: true });
604
+
605
+ window._sseCleanup = () => {
606
+ clearInterval(pollInterval);
607
+ window.removeEventListener('scroll', sseScrollHandler);
608
+ const ind = document.getElementById('newEventsIndicator');
609
+ if (ind) ind.remove();
610
+ };
534
611
  }
535
612
 
536
613
  async function viewTimeline(date) {
@@ -828,6 +905,7 @@ window._lastView = 'sessions';
828
905
 
829
906
  $$('.nav-item').forEach(item => {
830
907
  item.addEventListener('click', () => {
908
+ if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
831
909
  $$('.nav-item').forEach(i => i.classList.remove('active'));
832
910
  item.classList.add('active');
833
911
  const view = item.dataset.view;
package/public/style.css CHANGED
@@ -1077,6 +1077,34 @@ mark {
1077
1077
  animation: highlightFade 2s var(--ease-out);
1078
1078
  }
1079
1079
 
1080
+ /* ---- New Events SSE Indicator ---- */
1081
+ .new-events-indicator {
1082
+ position: fixed;
1083
+ top: 20px;
1084
+ left: 50%;
1085
+ transform: translateX(-50%);
1086
+ background: var(--accent);
1087
+ color: #fff;
1088
+ padding: 8px 20px;
1089
+ border-radius: 20px;
1090
+ font-size: 12px;
1091
+ font-weight: 600;
1092
+ cursor: pointer;
1093
+ z-index: 90;
1094
+ box-shadow: 0 4px 16px rgba(99, 144, 240, 0.4);
1095
+ animation: slideDown 0.3s var(--ease-out);
1096
+ }
1097
+
1098
+ .new-events-indicator:hover {
1099
+ background: #4a7ae8;
1100
+ transform: translateX(-50%) scale(1.05);
1101
+ }
1102
+
1103
+ @keyframes slideDown {
1104
+ from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
1105
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
1106
+ }
1107
+
1080
1108
  /* ---- Search Home Stats ---- */
1081
1109
  .search-stats {
1082
1110
  display: grid;