agentacta 1.2.2 → 1.3.1

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.1",
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
@@ -49,6 +49,7 @@ function dlExport(url, filename) {
49
49
 
50
50
  function fmtTokens(n) {
51
51
  if (!n) return '0';
52
+ if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(4) + 'B';
52
53
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
53
54
  if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
54
55
  return n.toLocaleString();
@@ -94,6 +95,50 @@ function transitionView() {
94
95
  content.classList.add('view-enter');
95
96
  }
96
97
 
98
+ // --- Hash routing ---
99
+ window._navDepth = 0;
100
+
101
+ function setHash(hash, replace) {
102
+ const target = '#' + hash;
103
+ if (window.location.hash === target) return;
104
+ if (replace) {
105
+ history.replaceState(null, '', target);
106
+ } else {
107
+ history.pushState(null, '', target);
108
+ window._navDepth++;
109
+ }
110
+ }
111
+
112
+ function updateNavActive(view) {
113
+ $$('.nav-item').forEach(i => i.classList.remove('active'));
114
+ const navItem = $(`.nav-item[data-view="${view}"]`);
115
+ if (navItem) navItem.classList.add('active');
116
+ }
117
+
118
+ function handleRoute() {
119
+ const raw = (window.location.hash || '').slice(1) || 'search';
120
+ if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
121
+
122
+ if (raw.startsWith('session/')) {
123
+ const id = decodeURIComponent(raw.slice('session/'.length));
124
+ if (id) { viewSession(id); return; }
125
+ }
126
+
127
+ const view = raw === 'sessions' || raw === 'timeline' || raw === 'files' || raw === 'stats' ? raw : 'search';
128
+ window._lastView = view;
129
+ updateNavActive(view);
130
+ if (view === 'sessions') viewSessions();
131
+ else if (view === 'files') viewFiles();
132
+ else if (view === 'timeline') viewTimeline();
133
+ else if (view === 'stats') viewStats();
134
+ else viewSearch(window._lastSearchQuery || '');
135
+ }
136
+
137
+ window.addEventListener('popstate', () => {
138
+ if (window._navDepth > 0) window._navDepth--;
139
+ handleRoute();
140
+ });
141
+
97
142
  function renderEvent(ev) {
98
143
  const badge = `<span class="event-badge ${badgeClass(ev.type, ev.role)}">${ev.type === 'tool_call' ? 'tool' : ev.role || ev.type}</span>`;
99
144
  let body = '';
@@ -383,7 +428,9 @@ async function viewSessions() {
383
428
  }
384
429
 
385
430
  async function viewSession(id) {
431
+ if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
386
432
  window._currentSessionId = id;
433
+ setHash('session/' + encodeURIComponent(id));
387
434
  window.scrollTo(0, 0);
388
435
  const data = await api(`/sessions/${id}`);
389
436
 
@@ -468,11 +515,13 @@ async function viewSession(id) {
468
515
  }
469
516
 
470
517
  $('#backBtn').addEventListener('click', () => {
471
- if (onScroll) { window.removeEventListener('scroll', onScroll); onScroll = null; }
472
- if (window._lastView === 'timeline') viewTimeline();
473
- else if (window._lastView === 'files') viewFiles();
474
- else if (window._lastView === 'search') viewSearch(window._lastSearchQuery || '');
475
- else viewSessions();
518
+ if (window._navDepth > 0) {
519
+ history.back();
520
+ } else {
521
+ const view = window._lastView || 'sessions';
522
+ setHash(view, true);
523
+ handleRoute();
524
+ }
476
525
  });
477
526
 
478
527
  $('#copySessionId').addEventListener('click', async () => {
@@ -531,6 +580,82 @@ async function viewSession(id) {
531
580
  }
532
581
  });
533
582
  }
583
+
584
+ // --- Lightweight realtime updates (polling fallback first) ---
585
+ const knownIds = new Set(allEvents.map(e => e.id));
586
+ let pendingNewCount = 0;
587
+
588
+ const applyIncomingEvents = (incoming) => {
589
+ const container = document.getElementById('eventsContainer');
590
+ if (!container || !incoming?.length) return;
591
+
592
+ const fresh = incoming.filter(e => !knownIds.has(e.id));
593
+ if (!fresh.length) return;
594
+ fresh.forEach(e => knownIds.add(e.id));
595
+
596
+ const isAtTop = window.scrollY < 100;
597
+ for (const ev of fresh) {
598
+ const div = document.createElement('div');
599
+ div.innerHTML = renderEvent(ev);
600
+ const el = div.firstElementChild;
601
+ el.classList.add('event-highlight');
602
+ container.insertBefore(el, container.firstChild);
603
+ setTimeout(() => el.classList.remove('event-highlight'), 2000);
604
+ }
605
+
606
+ if (!isAtTop) {
607
+ pendingNewCount += fresh.length;
608
+ let indicator = document.getElementById('newEventsIndicator');
609
+ if (!indicator) {
610
+ indicator = document.createElement('div');
611
+ indicator.id = 'newEventsIndicator';
612
+ indicator.className = 'new-events-indicator';
613
+ document.body.appendChild(indicator);
614
+ indicator.addEventListener('click', () => {
615
+ window.scrollTo({ top: 0, behavior: 'smooth' });
616
+ indicator.remove();
617
+ pendingNewCount = 0;
618
+ });
619
+ }
620
+ indicator.textContent = `${pendingNewCount} new event${pendingNewCount !== 1 ? 's' : ''} ↑`;
621
+ }
622
+ };
623
+
624
+ // Poll every 3s for new events using delta endpoint
625
+ let lastSeenTs = allEvents.length ? allEvents[0].timestamp : new Date(0).toISOString();
626
+ let lastSeenId = allEvents.length ? allEvents[0].id : '';
627
+ const pollNewEvents = async () => {
628
+ try {
629
+ const latest = await api(`/sessions/${id}/events?after=${encodeURIComponent(lastSeenTs)}&afterId=${encodeURIComponent(lastSeenId)}&limit=50`);
630
+ const incoming = latest.events || [];
631
+ if (incoming.length) {
632
+ const tail = incoming[incoming.length - 1];
633
+ lastSeenTs = tail.timestamp || lastSeenTs;
634
+ lastSeenId = tail.id || lastSeenId;
635
+ applyIncomingEvents(incoming);
636
+ }
637
+ } catch (err) {
638
+ // silent; next tick will retry
639
+ }
640
+ };
641
+
642
+ const pollInterval = setInterval(pollNewEvents, 3000);
643
+
644
+ const sseScrollHandler = () => {
645
+ if (window.scrollY < 100) {
646
+ const ind = document.getElementById('newEventsIndicator');
647
+ if (ind) { ind.remove(); pendingNewCount = 0; }
648
+ }
649
+ };
650
+ window.addEventListener('scroll', sseScrollHandler, { passive: true });
651
+
652
+ window._sseCleanup = () => {
653
+ if (onScroll) { window.removeEventListener('scroll', onScroll); onScroll = null; }
654
+ clearInterval(pollInterval);
655
+ window.removeEventListener('scroll', sseScrollHandler);
656
+ const ind = document.getElementById('newEventsIndicator');
657
+ if (ind) ind.remove();
658
+ };
534
659
  }
535
660
 
536
661
  async function viewTimeline(date) {
@@ -828,10 +953,11 @@ window._lastView = 'sessions';
828
953
 
829
954
  $$('.nav-item').forEach(item => {
830
955
  item.addEventListener('click', () => {
831
- $$('.nav-item').forEach(i => i.classList.remove('active'));
832
- item.classList.add('active');
956
+ if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
833
957
  const view = item.dataset.view;
834
958
  window._lastView = view;
959
+ updateNavActive(view);
960
+ setHash(view);
835
961
  if (view === 'search') viewSearch();
836
962
  else if (view === 'sessions') viewSessions();
837
963
  else if (view === 'files') viewFiles();
@@ -840,7 +966,7 @@ $$('.nav-item').forEach(item => {
840
966
  });
841
967
  });
842
968
 
843
- viewSearch();
969
+ handleRoute();
844
970
 
845
971
  // Swipe right from left edge to go back
846
972
  (function initSwipeBack() {
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;