agentacta 2026.3.12 → 2026.3.26

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/README.md CHANGED
@@ -32,12 +32,15 @@ AgentActa gives you one place to inspect the full trail.
32
32
 
33
33
  - 🔍 Full-text search across messages, tool calls, and results
34
34
  - 📋 Session browser with summaries, token breakdowns, and model info
35
+ - 🧭 Project-scoped session filtering with per-event attribution
36
+ - 🤖 Clear Codex run visibility for direct and Symphony-origin sessions
35
37
  - 📅 Timeline view with live updates for today
36
38
  - 📁 File activity across all indexed sessions
37
39
  - 🌗 Light and dark themes
38
40
  - 📊 Stats for sessions, messages, tools, and tokens
39
41
  - ⚡ Live indexing via file watching
40
- - 📱 Mobile-friendly UI
42
+ - 📱 Mobile-optimized UI with floating navigation
43
+ - 🏥 Session health scoring — reliability scores, issue detection, and per-signal breakdowns
41
44
  - 💡 Search suggestions based on real data
42
45
  - ⌨️ Command palette (⌘K / Ctrl+K) for quick navigation
43
46
  - 🎨 Theme settings (system, light, dark, OLED)
@@ -80,12 +83,22 @@ Suggestions come from your own dataset: top tools, common topics, frequently tou
80
83
 
81
84
  Browse indexed sessions with auto-generated summaries, token splits (input/output), and model details. Click into any session to see the full event history.
82
85
 
83
- Session types get tagged so noisy categories are easier to spot (cron, sub-agent, heartbeat).
86
+ Session detail view supports project-scoped filtering, so mixed-project sessions can be narrowed down without losing the full underlying transcript. The Initial Prompt jump still resolves from full session context even when a project filter is active.
87
+
88
+ Session types get tagged so noisy categories are easier to spot (cron, sub-agent, heartbeat). Codex-backed work is also distinguished more clearly, including direct Codex runs and Symphony-origin Codex sessions.
84
89
 
85
90
  ### Timeline
86
91
 
87
92
  Pick a date, see everything that happened, newest first. Today's view updates live as new events come in.
88
93
 
94
+ ### Insights
95
+
96
+ The Insights tab surfaces session health across your entire history.
97
+
98
+ It tracks five issue signals — repeated tool loops, sessions that produced no output, high error rates, vague instructions, and incomplete sessions. Each signal is severity-scaled so scores reflect how bad the problem actually was, not just whether it occurred.
99
+
100
+ The reliability score (0–100) is the inverse of the confusion score: higher means the agent completed work cleanly. The issue rate shows what percentage of possible signal types were detected in a session.
101
+
89
102
  ### File Activity
90
103
 
91
104
  See what files were touched, how often, and by which sessions.
@@ -163,6 +176,7 @@ Default config (auto-generated on first run — session directories are detected
163
176
  | `GET /api/timeline/stream?after=<ts>` | SSE stream for live timeline updates |
164
177
  | `POST /api/maintenance` | VACUUM + WAL checkpoint (returns size before/after) |
165
178
  | `GET /api/health` | Server status, version, uptime, session count |
179
+ | `GET /api/insights` | Session health summary — reliability scores, issue counts, top flagged sessions |
166
180
  | `GET /api/export/search?q=<query>&format=md` | Export search results |
167
181
 
168
182
  ### Context API
package/config.js CHANGED
@@ -57,6 +57,8 @@ function loadConfig() {
57
57
  }
58
58
  }
59
59
 
60
+ // In demo mode, ignore file-based sessionsPath so live data doesn't bleed in
61
+ if (process.env.AGENTACTA_DEMO_MODE) delete fileConfig.sessionsPath;
60
62
  const config = { ...DEFAULTS, ...fileConfig };
61
63
 
62
64
  // Env var overrides (highest priority)
package/db.js CHANGED
@@ -103,6 +103,18 @@ function init(dbPath) {
103
103
  );
104
104
 
105
105
  CREATE INDEX IF NOT EXISTS idx_archive_session ON archive(session_id);
106
+
107
+ CREATE TABLE IF NOT EXISTS session_insights (
108
+ session_id TEXT PRIMARY KEY,
109
+ signals TEXT,
110
+ confusion_score INTEGER DEFAULT 0,
111
+ flagged INTEGER DEFAULT 0,
112
+ computed_at TEXT,
113
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
114
+ );
115
+
116
+ CREATE INDEX IF NOT EXISTS idx_insights_flagged ON session_insights(flagged);
117
+ CREATE INDEX IF NOT EXISTS idx_insights_score ON session_insights(confusion_score DESC);
106
118
  `);
107
119
 
108
120
  // Add columns if missing (migration)
package/index.js CHANGED
@@ -20,12 +20,16 @@ if (process.argv.includes('--demo')) {
20
20
  }
21
21
  process.env.AGENTACTA_SESSIONS_PATH = demoDir;
22
22
  process.env.AGENTACTA_DB_PATH = path.join(demoDir, 'demo.db');
23
+ process.env.AGENTACTA_DEMO_MODE = '1'; // signal to config.js to skip file-based sessionsPath
23
24
  console.log(`Demo mode: using sessions from ${demoDir}`);
24
25
  }
25
26
 
26
27
  const { loadConfig } = require('./config');
27
28
  const { open, init, createStmts } = require('./db');
28
- const { discoverSessionDirs, indexFile } = require('./indexer');
29
+ const { discoverSessionDirs, listJsonlFiles, indexFile } = require('./indexer');
30
+ const { attributeSessionEvents, attributeEventDelta } = require('./project-attribution');
31
+ const { loadDeltaAttributionContext } = require('./delta-attribution-context');
32
+ const { analyzeSession, analyzeAll, getInsightsSummary } = require('./insights');
29
33
 
30
34
  const config = loadConfig();
31
35
  const PORT = config.port;
@@ -154,26 +158,68 @@ const sessionDirs = discoverSessionDirs(config);
154
158
 
155
159
  // Initial indexing pass
156
160
  for (const dir of sessionDirs) {
157
- const files = fs.readdirSync(dir.path).filter(f => f.endsWith('.jsonl'));
158
- for (const file of files) {
161
+ const files = listJsonlFiles(dir.path, !!dir.recursive);
162
+ for (const filePath of files) {
159
163
  try {
160
- const result = indexFile(db, path.join(dir.path, file), dir.agent, stmts, ARCHIVE_MODE);
161
- if (!result.skipped) console.log(`Indexed: ${file} (${dir.agent})`);
164
+ const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
165
+ if (!result.skipped) console.log(`Indexed: ${path.basename(filePath)} (${dir.agent})`);
162
166
  } catch (err) {
163
- console.error(`Error indexing ${file}:`, err.message);
167
+ console.error(`Error indexing ${path.basename(filePath)}:`, err.message);
164
168
  }
165
169
  }
166
170
  }
167
171
 
172
+ // Compute insights for all indexed sessions
173
+ try {
174
+ analyzeAll(db);
175
+ console.log('Insights computed for all sessions');
176
+ } catch (err) {
177
+ console.error('Error computing insights:', err.message);
178
+ }
179
+
168
180
  console.log(`Watching ${sessionDirs.length} session directories`);
169
181
 
170
182
  // Debounce map: filePath -> timeout handle
171
183
  const _reindexTimers = new Map();
172
184
  const REINDEX_DEBOUNCE_MS = 2000;
185
+ const RECURSIVE_RESCAN_MS = 15000;
186
+
187
+ function reindexRecursiveDir(dir) {
188
+ try {
189
+ const files = listJsonlFiles(dir.path, true);
190
+ let changed = 0;
191
+ const upsert = db.prepare('INSERT OR REPLACE INTO session_insights (session_id, signals, confusion_score, flagged, computed_at) VALUES (?, ?, ?, ?, ?)');
192
+ for (const filePath of files) {
193
+ const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
194
+ if (!result.skipped) {
195
+ changed++;
196
+ if (result.sessionId) {
197
+ try {
198
+ const insight = analyzeSession(db, result.sessionId);
199
+ if (insight) upsert.run(insight.session_id, JSON.stringify(insight.signals), insight.confusion_score, insight.flagged ? 1 : 0, insight.computed_at);
200
+ } catch {}
201
+ sseEmitter.emit('session-update', result.sessionId);
202
+ }
203
+ }
204
+ }
205
+ if (changed > 0) console.log(`Live re-indexed ${changed} files (${dir.agent})`);
206
+ } catch (err) {
207
+ console.error(`Error rescanning ${dir.path}:`, err.message);
208
+ }
209
+ }
173
210
 
174
211
  for (const dir of sessionDirs) {
175
212
  try {
176
213
  fs.watch(dir.path, { persistent: false }, (eventType, filename) => {
214
+ if (dir.recursive) {
215
+ if (_reindexTimers.has(dir.path)) clearTimeout(_reindexTimers.get(dir.path));
216
+ _reindexTimers.set(dir.path, setTimeout(() => {
217
+ _reindexTimers.delete(dir.path);
218
+ reindexRecursiveDir(dir);
219
+ }, REINDEX_DEBOUNCE_MS));
220
+ return;
221
+ }
222
+
177
223
  if (!filename || !filename.endsWith('.jsonl')) return;
178
224
  const filePath = path.join(dir.path, filename);
179
225
  if (!fs.existsSync(filePath)) return;
@@ -183,10 +229,13 @@ for (const dir of sessionDirs) {
183
229
  _reindexTimers.set(filePath, setTimeout(() => {
184
230
  _reindexTimers.delete(filePath);
185
231
  try {
186
- const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE);
232
+ const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
187
233
  if (!result.skipped) {
188
234
  console.log(`Live re-indexed: ${filename} (${dir.agent})`);
189
- if (result.sessionId) sseEmitter.emit('session-update', result.sessionId);
235
+ if (result.sessionId) {
236
+ try { analyzeSession(db, result.sessionId); const upsert = db.prepare('INSERT OR REPLACE INTO session_insights (session_id, signals, confusion_score, flagged, computed_at) VALUES (?, ?, ?, ?, ?)'); const insight = analyzeSession(db, result.sessionId); if (insight) upsert.run(insight.session_id, JSON.stringify(insight.signals), insight.confusion_score, insight.flagged ? 1 : 0, insight.computed_at); } catch {}
237
+ sseEmitter.emit('session-update', result.sessionId);
238
+ }
190
239
  }
191
240
  } catch (err) {
192
241
  console.error(`Error re-indexing ${filename}:`, err.message);
@@ -194,6 +243,10 @@ for (const dir of sessionDirs) {
194
243
  }, REINDEX_DEBOUNCE_MS));
195
244
  });
196
245
  console.log(` Watching: ${dir.path}`);
246
+ if (dir.recursive) {
247
+ const timer = setInterval(() => reindexRecursiveDir(dir), RECURSIVE_RESCAN_MS);
248
+ timer.unref?.();
249
+ }
197
250
  } catch (err) {
198
251
  console.error(` Failed to watch ${dir.path}:`, err.message);
199
252
  }
@@ -206,6 +259,7 @@ const server = http.createServer((req, res) => {
206
259
  if (pathname === '/api/reindex') {
207
260
  const { indexAll } = require('./indexer');
208
261
  const result = indexAll(db, config);
262
+ try { analyzeAll(db); } catch (e) { console.error('Insights recompute error:', e.message); }
209
263
  return json(res, { ok: true, sessions: result.sessions, events: result.events });
210
264
  }
211
265
 
@@ -293,7 +347,7 @@ const server = http.createServer((req, res) => {
293
347
 
294
348
  else if (pathname.match(/^\/api\/sessions\/[^/]+\/events$/)) {
295
349
  const id = pathname.split('/')[3];
296
- const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
350
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
297
351
  if (!session) return json(res, { error: 'Not found' }, 404);
298
352
 
299
353
  const after = query.after || '1970-01-01T00:00:00.000Z';
@@ -306,12 +360,14 @@ const server = http.createServer((req, res) => {
306
360
  ORDER BY timestamp ASC, id ASC
307
361
  LIMIT ?`
308
362
  ).all(id, after, after, afterId, limit);
309
- json(res, { events: rows, after, afterId, count: rows.length });
363
+ const contextRows = loadDeltaAttributionContext(db, id, rows);
364
+ const events = attributeEventDelta(session, rows, contextRows);
365
+ json(res, { events, after, afterId, count: events.length });
310
366
  }
311
367
 
312
368
  else if (pathname.match(/^\/api\/sessions\/[^/]+\/stream$/)) {
313
369
  const id = pathname.split('/')[3];
314
- const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
370
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
315
371
  if (!session) return json(res, { error: 'Not found' }, 404);
316
372
 
317
373
  res.writeHead(200, {
@@ -331,8 +387,10 @@ const server = http.createServer((req, res) => {
331
387
  'SELECT * FROM events WHERE session_id = ? AND timestamp > ? ORDER BY timestamp ASC'
332
388
  ).all(id, lastTs);
333
389
  if (rows.length) {
390
+ const contextRows = loadDeltaAttributionContext(db, id, rows);
391
+ const attributedRows = attributeEventDelta(session, rows, contextRows);
334
392
  lastTs = rows[rows.length - 1].timestamp;
335
- res.write(`id: ${lastTs}\ndata: ${JSON.stringify(rows)}\n\n`);
393
+ res.write(`id: ${lastTs}\ndata: ${JSON.stringify(attributedRows)}\n\n`);
336
394
  }
337
395
  } catch (err) {
338
396
  console.error('SSE query error:', err.message);
@@ -356,8 +414,9 @@ const server = http.createServer((req, res) => {
356
414
  if (!session) { json(res, { error: 'Not found' }, 404); }
357
415
  else {
358
416
  const events = db.prepare('SELECT * FROM events WHERE session_id = ? ORDER BY timestamp DESC').all(id);
417
+ const attributed = attributeSessionEvents(session, events);
359
418
  const hasArchive = ARCHIVE_MODE && db.prepare('SELECT COUNT(*) as c FROM archive WHERE session_id = ?').get(id).c > 0;
360
- json(res, { session, events, hasArchive });
419
+ json(res, { session, events: attributed.events, projectFilters: attributed.projectFilters, hasArchive });
361
420
  }
362
421
  }
363
422
  else if (pathname.match(/^\/api\/archive\/session\/[^/]+$/)) {
@@ -726,6 +785,27 @@ const server = http.createServer((req, res) => {
726
785
  `).all(fp);
727
786
  json(res, { file: fp, sessions: rows });
728
787
  }
788
+ else if (pathname === '/api/insights') {
789
+ const summary = getInsightsSummary(db);
790
+ return json(res, summary);
791
+ }
792
+ else if (pathname.match(/^\/api\/insights\/session\/[^/]+$/)) {
793
+ const id = pathname.split('/')[4];
794
+ const row = db.prepare('SELECT * FROM session_insights WHERE session_id = ?').get(id);
795
+ if (!row) {
796
+ // Compute on-the-fly if not yet analyzed
797
+ const result = analyzeSession(db, id);
798
+ if (!result) return json(res, { error: 'Session not found' }, 404);
799
+ return json(res, result);
800
+ }
801
+ return json(res, {
802
+ session_id: row.session_id,
803
+ signals: JSON.parse(row.signals || '[]'),
804
+ confusion_score: row.confusion_score,
805
+ flagged: !!row.flagged,
806
+ computed_at: row.computed_at
807
+ });
808
+ }
729
809
  else if (!serveStatic(req, res)) {
730
810
  const index = path.join(PUBLIC, 'index.html');
731
811
  if (fs.existsSync(index)) {
package/indexer.js CHANGED
@@ -28,14 +28,26 @@ function listJsonlFiles(baseDir, recursive = false) {
28
28
  function discoverSessionDirs(config) {
29
29
  const dirs = [];
30
30
  const home = process.env.HOME;
31
+ const codexSessionsPath = path.join(home, '.codex/sessions');
32
+
33
+ function normalizedPath(p) {
34
+ return path.resolve(p).replace(/[\\\/]+$/, '');
35
+ }
36
+
37
+ function hasDir(targetPath) {
38
+ const wanted = normalizedPath(targetPath);
39
+ return dirs.some(d => normalizedPath(d.path) === wanted);
40
+ }
31
41
 
32
42
  // Expand a single path into session dirs, handling Claude Code's per-project structure
33
43
  function expandPath(p) {
34
44
  if (!fs.existsSync(p)) return;
35
45
  const stat = fs.statSync(p);
36
46
  if (!stat.isDirectory()) return;
47
+ const normalized = normalizedPath(p);
48
+ const normalizedCodex = normalizedPath(codexSessionsPath);
37
49
  // Claude Code: ~/.claude/projects contains per-project subdirs with JSONL files
38
- if (p.replace(/\/$/, '').endsWith('/.claude/projects')) {
50
+ if (normalized.endsWith('/.claude/projects')) {
39
51
  for (const proj of fs.readdirSync(p)) {
40
52
  const projDir = path.join(p, proj);
41
53
  if (fs.statSync(projDir).isDirectory()) {
@@ -43,6 +55,9 @@ function discoverSessionDirs(config) {
43
55
  if (hasJsonl) dirs.push({ path: projDir, agent: 'claude-code' });
44
56
  }
45
57
  }
58
+ } else if (normalized === normalizedCodex) {
59
+ // Codex CLI stores nested YYYY/MM/DD directories and must be recursive.
60
+ dirs.push({ path: p, agent: 'codex-cli', recursive: true });
46
61
  } else {
47
62
  dirs.push({ path: p, agent: path.basename(path.dirname(p)) });
48
63
  }
@@ -55,6 +70,10 @@ function discoverSessionDirs(config) {
55
70
  ? sessionsOverride
56
71
  : sessionsOverride.split(':');
57
72
  overridePaths.forEach(expandPath);
73
+ // Keep direct Codex visibility even when custom overrides omit it.
74
+ if (fs.existsSync(codexSessionsPath) && fs.statSync(codexSessionsPath).isDirectory() && !hasDir(codexSessionsPath)) {
75
+ dirs.push({ path: codexSessionsPath, agent: 'codex-cli', recursive: true });
76
+ }
58
77
  if (dirs.length) return dirs;
59
78
  }
60
79
 
@@ -73,7 +92,7 @@ function discoverSessionDirs(config) {
73
92
  expandPath(path.join(home, '.claude/projects'));
74
93
 
75
94
  // Scan ~/.codex/sessions recursively (Codex CLI stores nested YYYY/MM/DD/*.jsonl)
76
- const codexSessions = path.join(home, '.codex/sessions');
95
+ const codexSessions = codexSessionsPath;
77
96
  if (fs.existsSync(codexSessions) && fs.statSync(codexSessions).isDirectory()) {
78
97
  dirs.push({ path: codexSessions, agent: 'codex-cli', recursive: true });
79
98
  }
@@ -226,6 +245,8 @@ function extractProjectFromPath(filePath, config) {
226
245
 
227
246
  // Common repo location: ~/Developer/<repo>/...
228
247
  if (parts[0] === 'Developer' && parts[1]) return aliasProject(parts[1], config);
248
+ // Symphony worktrees: ~/symphony-workspaces/<issue>/...
249
+ if (parts[0] === 'symphony-workspaces' && parts[1]) return aliasProject(parts[1], config);
229
250
 
230
251
  // OpenClaw workspace and agent stores
231
252
  if (parts[0] === '.openclaw' && parts[1] === 'workspace') return aliasProject('workspace', config);
@@ -274,6 +295,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
274
295
  let firstMessageTimestamp = null;
275
296
  let codexProvider = null;
276
297
  let codexSource = null;
298
+ let codexOriginator = null;
277
299
  let sawSnapshotRecord = false;
278
300
  let sawNonSnapshotRecord = false;
279
301
 
@@ -321,7 +343,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
321
343
  const meta = firstLine.payload || {};
322
344
  sessionId = meta.id || path.basename(filePath, '.jsonl');
323
345
  sessionStart = meta.timestamp || firstLine.timestamp || new Date().toISOString();
324
- sessionType = 'codex-cli';
346
+ sessionType = 'codex-direct';
325
347
  agent = 'codex-cli';
326
348
  if (meta.model) {
327
349
  model = meta.model;
@@ -329,6 +351,8 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
329
351
  }
330
352
  codexProvider = meta.model_provider || null;
331
353
  codexSource = meta.source || null;
354
+ codexOriginator = meta.originator || null;
355
+ if (codexOriginator && codexOriginator.includes('symphony')) sessionType = 'codex-symphony';
332
356
  } else {
333
357
  return { skipped: true };
334
358
  }
@@ -363,6 +387,8 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
363
387
  }
364
388
  if (meta.model_provider) codexProvider = meta.model_provider;
365
389
  if (meta.source) codexSource = meta.source;
390
+ if (meta.originator) codexOriginator = meta.originator;
391
+ if (codexOriginator && codexOriginator.includes('symphony')) sessionType = 'codex-symphony';
366
392
  if (meta.model_provider && !model) model = meta.model_provider;
367
393
  continue;
368
394
  }
@@ -567,6 +593,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
567
593
  const parts = ['Codex CLI session'];
568
594
  if (codexProvider) parts.push(`provider=${codexProvider}`);
569
595
  if (codexSource) parts.push(`source=${codexSource}`);
596
+ if (codexOriginator) parts.push(`originator=${codexOriginator}`);
570
597
  summary = parts.join(' · ');
571
598
  } else {
572
599
  summary = 'Heartbeat session';
@@ -730,6 +757,6 @@ function indexAll(db, config) {
730
757
  return { sessions: stats.sessions, events: evStats.events, newSessions: totalSessions };
731
758
  }
732
759
 
733
- module.exports = { discoverSessionDirs, indexFile, indexAll };
760
+ module.exports = { discoverSessionDirs, listJsonlFiles, indexFile, indexAll };
734
761
 
735
762
  if (require.main === module) run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentacta",
3
- "version": "2026.3.12",
3
+ "version": "2026.3.26",
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
@@ -239,6 +239,16 @@ function updateNavActive(view) {
239
239
  $$('.nav-item').forEach(i => i.classList.remove('active'));
240
240
  const navItem = $(`.nav-item[data-view="${view}"]`);
241
241
  if (navItem) navItem.classList.add('active');
242
+ // Settings gear buttons
243
+ const isSettings = view === 'stats';
244
+ document.getElementById('settings-btn')?.classList.toggle('active', isSettings);
245
+ document.getElementById('settings-btn-mobile')?.classList.toggle('active', isSettings);
246
+ }
247
+
248
+ function updateMobileNavActive(view) {
249
+ $$('.mobile-nav-btn').forEach(b => b.classList.remove('active'));
250
+ const btn = $(`.mobile-nav-btn[data-view="${view}"]`);
251
+ if (btn) btn.classList.add('active');
242
252
  }
243
253
 
244
254
  function handleRoute() {
@@ -253,13 +263,15 @@ function handleRoute() {
253
263
  }
254
264
 
255
265
  const normalized = raw === 'search' ? 'overview' : raw;
256
- const view = normalized === 'overview' || normalized === 'sessions' || normalized === 'timeline' || normalized === 'files' || normalized === 'stats' ? normalized : 'overview';
266
+ const view = normalized === 'overview' || normalized === 'sessions' || normalized === 'timeline' || normalized === 'files' || normalized === 'stats' || normalized === 'insights' ? normalized : 'overview';
257
267
  window._lastView = view;
258
268
  updateNavActive(view);
269
+ updateMobileNavActive(view);
259
270
  if (view === 'overview') viewOverview();
260
271
  else if (view === 'sessions') viewSessions();
261
272
  else if (view === 'files') viewFiles();
262
273
  else if (view === 'timeline') viewTimeline();
274
+ else if (view === 'insights') viewInsights();
263
275
  else viewStats();
264
276
  }
265
277
 
@@ -647,7 +659,9 @@ async function viewSession(id) {
647
659
  if (data._error || data.error) { content.innerHTML = `<div class="empty"><h2>${escHtml(data.error || 'Unable to load')}</h2></div>`; return; }
648
660
 
649
661
  const s = data.session;
650
- const cost = fmtCost(s.total_cost);
662
+ const projectFilters = Array.isArray(data.projectFilters)
663
+ ? data.projectFilters.filter(p => p && p.project && Number.isFinite(Number(p.eventCount)))
664
+ : [];
651
665
  let html = `
652
666
  <div class="back-btn" id="backBtn">\u2190 Back</div>
653
667
  <div class="page-title">Session</div>
@@ -667,7 +681,6 @@ async function viewSession(id) {
667
681
  <div class="session-header" style="margin-bottom:12px">
668
682
  <span class="session-time">${fmtDate(s.start_time)} \u00b7 ${fmtTimeShort(s.start_time)} \u2013 ${fmtTimeShort(s.end_time)}</span>
669
683
  <span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
670
- ${renderProjectTags(s)}
671
684
  ${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(normalizeAgentLabel(s.agent))}</span>` : ''}
672
685
  ${s.session_type && s.session_type !== normalizeAgentLabel(s.agent || '') ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
673
686
  ${renderModelTags(s)}
@@ -678,16 +691,63 @@ async function viewSession(id) {
678
691
  <span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span> ${s.tool_count} tools</span>
679
692
  ${s.output_tokens ? `<span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/></svg></span> ${fmtTokens(s.output_tokens)} output</span><span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></span> ${fmtTokens(s.input_tokens + s.cache_read_tokens)} input</span>` : s.total_tokens ? `<span><span class="detail-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg></span> ${fmtTokens(s.total_tokens)} tokens</span><span></span>` : '<span></span><span></span>'}
680
693
  </div>
694
+ ${projectFilters.length ? `
695
+ <div class="section-label" style="margin-top:14px">Project Filter</div>
696
+ <div class="filters" id="sessionProjectFilters" style="margin-bottom:4px">
697
+ <span class="filter-chip active" data-project-filter="all">All</span>
698
+ ${projectFilters.map(p => `<span class="filter-chip" data-project-filter="${escHtml(p.project)}">${escHtml(p.project)} · ${p.eventCount}</span>`).join('')}
699
+ </div>
700
+ ` : ''}
681
701
  </div>
682
- <div class="section-label">Events</div>
702
+ <div class="section-label" id="sessionEventsLabel">Events</div>
703
+ <div id="eventsContainer"></div>
704
+ <div class="empty" id="sessionEventsEmpty" style="display:none"><h2>No events</h2><p>This session has no events to display.</p></div>
705
+ <div id="sessionInsightsPanel" class="loading" style="margin-top:var(--space-xl)">Loading insights...</div>
683
706
  `;
684
707
 
685
708
  const PAGE_SIZE = 50;
686
- const allEvents = data.events;
709
+ const allEvents = Array.isArray(data.events) ? [...data.events] : [];
710
+ let activeProjectFilter = 'all';
687
711
  let rendered = 0;
712
+ let onScroll = null;
713
+ let pendingNewCount = 0;
714
+
715
+ const getFilteredEvents = () => {
716
+ if (activeProjectFilter === 'all') return allEvents;
717
+ return allEvents.filter(ev => ev.project === activeProjectFilter);
718
+ };
719
+
720
+ const updateEventsLabel = () => {
721
+ const el = $('#sessionEventsLabel');
722
+ if (!el) return;
723
+ const count = getFilteredEvents().length;
724
+ if (activeProjectFilter === 'all') {
725
+ el.textContent = `Events (${count})`;
726
+ return;
727
+ }
728
+ el.textContent = `Events (${count}) · ${activeProjectFilter}`;
729
+ };
730
+
731
+ const updateEventsEmptyState = () => {
732
+ const empty = $('#sessionEventsEmpty');
733
+ if (!empty) return;
734
+ empty.style.display = getFilteredEvents().length ? 'none' : 'block';
735
+ };
736
+
737
+ const setProjectFilter = (nextFilter) => {
738
+ if (nextFilter === activeProjectFilter) return;
739
+ activeProjectFilter = nextFilter;
740
+ const chips = $$('#sessionProjectFilters .filter-chip');
741
+ chips.forEach(node => node.classList.toggle('active', node.dataset.projectFilter === activeProjectFilter));
742
+ pendingNewCount = 0;
743
+ const indicator = document.getElementById('newEventsIndicator');
744
+ if (indicator) indicator.remove();
745
+ resetRenderedEvents();
746
+ };
688
747
 
689
748
  function renderBatch() {
690
- const batch = allEvents.slice(rendered, rendered + PAGE_SIZE);
749
+ const filtered = getFilteredEvents();
750
+ const batch = filtered.slice(rendered, rendered + PAGE_SIZE);
691
751
  if (!batch.length) return;
692
752
  const frag = document.createElement('div');
693
753
  frag.innerHTML = batch.map(renderEvent).join('');
@@ -696,33 +756,61 @@ async function viewSession(id) {
696
756
  while (frag.firstChild) container.appendChild(frag.firstChild);
697
757
  }
698
758
  rendered += batch.length;
699
-
700
759
  }
701
760
 
702
- html += '<div id="eventsContainer">' + allEvents.slice(0, PAGE_SIZE).map(renderEvent).join('') + '</div>';
703
- rendered = Math.min(PAGE_SIZE, allEvents.length);
704
761
  content.innerHTML = html;
705
762
  transitionView();
706
763
 
707
- let onScroll = null;
708
- if (allEvents.length > PAGE_SIZE) {
764
+ const syncScrollHandler = () => {
765
+ const total = getFilteredEvents().length;
766
+ if (total <= rendered) {
767
+ if (onScroll) {
768
+ window.removeEventListener('scroll', onScroll);
769
+ onScroll = null;
770
+ }
771
+ return;
772
+ }
773
+
774
+ if (onScroll) return;
775
+
709
776
  let loading = false;
710
777
  onScroll = () => {
711
- if (loading || rendered >= allEvents.length) return;
778
+ if (loading || rendered >= getFilteredEvents().length) return;
712
779
  const scrollBottom = window.innerHeight + window.scrollY;
713
780
  const threshold = document.body.offsetHeight - 300;
714
781
  if (scrollBottom >= threshold) {
715
782
  loading = true;
716
783
  renderBatch();
717
784
  loading = false;
718
- if (rendered >= allEvents.length) {
785
+ if (rendered >= getFilteredEvents().length) {
719
786
  window.removeEventListener('scroll', onScroll);
720
787
  onScroll = null;
721
788
  }
722
789
  }
723
790
  };
724
791
  window.addEventListener('scroll', onScroll, { passive: true });
725
- }
792
+ };
793
+
794
+ const resetRenderedEvents = () => {
795
+ const container = document.getElementById('eventsContainer');
796
+ if (!container) return;
797
+ container.innerHTML = '';
798
+ rendered = 0;
799
+ renderBatch();
800
+ syncScrollHandler();
801
+ updateEventsLabel();
802
+ updateEventsEmptyState();
803
+ };
804
+
805
+ resetRenderedEvents();
806
+
807
+ const filterChips = $$('#sessionProjectFilters .filter-chip');
808
+ filterChips.forEach(chip => {
809
+ chip.addEventListener('click', () => {
810
+ const nextFilter = chip.dataset.projectFilter || 'all';
811
+ setProjectFilter(nextFilter);
812
+ });
813
+ });
726
814
 
727
815
  $('#backBtn').addEventListener('click', () => {
728
816
  clearJumpUi();
@@ -778,22 +866,35 @@ async function viewSession(id) {
778
866
  if (jumpBtn) {
779
867
  jumpBtn.addEventListener('click', async () => {
780
868
  const fromY = window.scrollY || window.pageYOffset || 0;
869
+ const firstMessageId = s.first_message_id;
781
870
 
782
871
  jumpBtn.classList.add('jumping');
783
872
  jumpBtn.disabled = true;
784
873
 
874
+ if (!firstMessageId) {
875
+ jumpBtn.classList.remove('jumping');
876
+ jumpBtn.disabled = false;
877
+ return;
878
+ }
879
+
880
+ // Resolve from full session context, not just project-filtered events.
881
+ const inCurrentFilter = getFilteredEvents().some(ev => ev.id === firstMessageId);
882
+ if (!inCurrentFilter && activeProjectFilter !== 'all') {
883
+ setProjectFilter('all');
884
+ }
885
+
785
886
  // Let button state paint before heavy DOM work.
786
887
  await new Promise(requestAnimationFrame);
787
888
 
788
889
  // Load remaining events in chunks so UI stays responsive.
789
890
  let loops = 0;
790
- while (rendered < allEvents.length) {
891
+ while (rendered < getFilteredEvents().length) {
791
892
  renderBatch();
792
893
  loops += 1;
793
894
  if (loops % 2 === 0) await new Promise(requestAnimationFrame);
794
895
  }
795
896
 
796
- const firstMessage = document.querySelector(`[data-event-id="${s.first_message_id}"]`);
897
+ const firstMessage = document.querySelector(`[data-event-id="${firstMessageId}"]`);
797
898
  if (!firstMessage) {
798
899
  jumpBtn.classList.remove('jumping');
799
900
  jumpBtn.disabled = false;
@@ -838,9 +939,8 @@ async function viewSession(id) {
838
939
  });
839
940
  }
840
941
 
841
- // --- Lightweight realtime updates (polling fallback first) ---
942
+ // --- Lightweight realtime updates (polling fallback first) ---
842
943
  const knownIds = new Set(allEvents.map(e => e.id));
843
- let pendingNewCount = 0;
844
944
 
845
945
  const applyIncomingEvents = (incoming) => {
846
946
  const container = document.getElementById('eventsContainer');
@@ -850,8 +950,22 @@ async function viewSession(id) {
850
950
  if (!fresh.length) return;
851
951
  fresh.forEach(e => knownIds.add(e.id));
852
952
 
953
+ // Delta endpoint returns oldest -> newest, and view is newest-first.
954
+ for (const ev of fresh) allEvents.unshift(ev);
955
+
956
+ const visibleFresh = activeProjectFilter === 'all'
957
+ ? fresh
958
+ : fresh.filter(ev => ev.project === activeProjectFilter);
959
+
960
+ if (!visibleFresh.length) {
961
+ updateEventsLabel();
962
+ updateEventsEmptyState();
963
+ syncScrollHandler();
964
+ return;
965
+ }
966
+
853
967
  const isAtTop = window.scrollY < 100;
854
- for (const ev of fresh) {
968
+ for (const ev of visibleFresh) {
855
969
  const div = document.createElement('div');
856
970
  div.innerHTML = renderEvent(ev);
857
971
  const el = div.firstElementChild;
@@ -859,9 +973,13 @@ async function viewSession(id) {
859
973
  container.insertBefore(el, container.firstChild);
860
974
  setTimeout(() => el.classList.remove('event-highlight'), 2000);
861
975
  }
976
+ rendered += visibleFresh.length;
977
+ updateEventsLabel();
978
+ updateEventsEmptyState();
979
+ syncScrollHandler();
862
980
 
863
981
  if (!isAtTop) {
864
- pendingNewCount += fresh.length;
982
+ pendingNewCount += visibleFresh.length;
865
983
  let indicator = document.getElementById('newEventsIndicator');
866
984
  if (!indicator) {
867
985
  indicator = document.createElement('div');
@@ -913,6 +1031,12 @@ async function viewSession(id) {
913
1031
  const ind = document.getElementById('newEventsIndicator');
914
1032
  if (ind) ind.remove();
915
1033
  };
1034
+
1035
+ // Load insights panel
1036
+ api(`/insights/session/${id}`).then(insights => {
1037
+ const panel = document.getElementById('sessionInsightsPanel');
1038
+ if (panel) panel.outerHTML = renderInsightsPanel(insights._error ? null : insights);
1039
+ });
916
1040
  }
917
1041
 
918
1042
  async function viewTimeline(date) {
@@ -1091,7 +1215,7 @@ async function viewStats() {
1091
1215
  <div class="config-card"><div class="config-label">Storage Mode</div><div class="config-value">${escHtml(data.storageMode || 'reference')}</div></div>
1092
1216
  <div class="config-card"><div class="config-label">DB Size</div><div class="config-value" id="dbSizeValue">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
1093
1217
  </div>
1094
- <p class="settings-help" style="margin-bottom:var(--space-sm)">Date range: ${fmtDate(data.dateRange?.earliest)} ${fmtDate(data.dateRange?.latest)}</p>
1218
+ <p class="settings-help" style="margin-bottom:var(--space-sm)">Date range: ${fmtDate(data.dateRange?.earliest)} to ${fmtDate(data.dateRange?.latest)}</p>
1095
1219
  <div class="settings-maintenance">
1096
1220
  <button class="export-btn" id="optimizeDbBtn">Optimize Database</button>
1097
1221
  <span id="optimizeDbStatus" class="settings-maintenance-status"></span>
@@ -1419,6 +1543,166 @@ async function viewFileDetail(filePath) {
1419
1543
  });
1420
1544
  }
1421
1545
 
1546
+ // --- Insights helpers ---
1547
+ const SIGNAL_LABELS = {
1548
+ tool_retry_loop: 'Repeated Actions',
1549
+ session_bail: 'No Output Produced',
1550
+ high_error_rate: 'Frequent Errors',
1551
+ long_prompt_short_session: 'Vague Instructions',
1552
+ no_completion: 'Incomplete Session'
1553
+ };
1554
+
1555
+ const SIGNAL_DESCRIPTIONS = {
1556
+ tool_retry_loop: 'The agent called the same tool many times in a row, suggesting it was stuck in a retry loop',
1557
+ session_bail: 'The agent ran many actions but never wrote or edited any files',
1558
+ high_error_rate: 'More than 30% of tool calls returned errors',
1559
+ long_prompt_short_session: 'A very short prompt led to a long session, suggesting the agent lacked sufficient context',
1560
+ no_completion: 'The session ended mid-action instead of finishing with a response'
1561
+ };
1562
+
1563
+ const SIGNAL_COLORS = {
1564
+ tool_retry_loop: 'amber',
1565
+ high_error_rate: 'red',
1566
+ no_completion: 'purple',
1567
+ session_bail: 'teal',
1568
+ long_prompt_short_session: 'accent'
1569
+ };
1570
+
1571
+ function renderSignalTag(sig) {
1572
+ const label = SIGNAL_LABELS[sig.type] || sig.type;
1573
+ const color = SIGNAL_COLORS[sig.type] || 'muted';
1574
+ const desc = SIGNAL_DESCRIPTIONS[sig.type] || '';
1575
+ return `<span class="signal-tag signal-${color}"${desc ? ` title="${escHtml(desc)}"` : ''}><span class="signal-dot"></span>${escHtml(label)}</span>`;
1576
+ }
1577
+
1578
+ function renderReliabilityBadge(score) {
1579
+ const reliability = 100 - score;
1580
+ return `<span class="insight-score-value" title="Reliability score: higher means fewer errors">${reliability}</span>`;
1581
+ }
1582
+
1583
+ function renderIssueRateBadge(signals) {
1584
+ const TOTAL_SIGNAL_TYPES = 5;
1585
+ const uniqueTypes = new Set((signals || []).map(s => s.type)).size;
1586
+ const rate = Math.round((uniqueTypes / TOTAL_SIGNAL_TYPES) * 100);
1587
+ return `<span class="insight-score-value" title="${uniqueTypes} of ${TOTAL_SIGNAL_TYPES} issue types detected">${rate}%</span>`;
1588
+ }
1589
+
1590
+ function renderInsightsPanel(insights) {
1591
+ if (!insights || !insights.signals || !insights.signals.length) {
1592
+ return `<div class="insights-panel insights-clean"><span style="color:var(--text-tertiary);font-size:13px">No issues detected</span></div>`;
1593
+ }
1594
+ return `<div class="insights-panel">
1595
+ <div class="section-label" style="margin-top:0">Session Health ${renderReliabilityBadge(insights.confusion_score)}</div>
1596
+ <div class="insights-signals">
1597
+ ${insights.signals.map(sig => {
1598
+ let detail = '';
1599
+ if (sig.type === 'tool_retry_loop') detail = `${escHtml(sig.tool)} called ${sig.count}x consecutively`;
1600
+ else if (sig.type === 'session_bail') detail = `${sig.tool_calls} tool calls with no file writes`;
1601
+ else if (sig.type === 'high_error_rate') detail = `${sig.rate}% error rate (${sig.error_count}/${sig.total})`;
1602
+ else if (sig.type === 'long_prompt_short_session') detail = `${sig.prompt_words} word prompt, ${sig.tool_calls} tool calls`;
1603
+ else if (sig.type === 'no_completion') detail = `Ended on ${escHtml(sig.last_event_type)}${sig.last_tool ? ': ' + escHtml(sig.last_tool) : ''}`;
1604
+ return `<div class="insight-callout">
1605
+ ${renderSignalTag(sig)}
1606
+ <span class="insight-detail">${detail}</span>
1607
+ </div>`;
1608
+ }).join('')}
1609
+ </div>
1610
+ </div>`;
1611
+ }
1612
+
1613
+ async function viewInsights() {
1614
+ clearJumpUi();
1615
+ window._lastView = 'insights';
1616
+ content.innerHTML = `<div class="page-title">Insights</div><div class="stat-grid">${skeletonRows(3, 'stats')}</div>`;
1617
+ transitionView();
1618
+
1619
+ const data = await api('/insights');
1620
+ if (data._error) {
1621
+ content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
1622
+ return;
1623
+ }
1624
+
1625
+ // Signal breakdown data — sorted by count descending
1626
+ const signalTypes = Object.keys(SIGNAL_LABELS).sort((a, b) => (data.signal_counts[b] || 0) - (data.signal_counts[a] || 0));
1627
+ const maxSignalCount = Math.max(1, ...signalTypes.map(t => data.signal_counts[t] || 0));
1628
+
1629
+ // Issue rate
1630
+ const issueRate = data.total_sessions > 0 ? Math.round((data.flagged_count / data.total_sessions) * 100) : 0;
1631
+
1632
+ // Reliability score (inverted struggle score)
1633
+ const reliabilityScore = 100 - (data.avg_confusion_score || 0);
1634
+
1635
+ let html = `<div class="page-title">Insights</div>
1636
+
1637
+ <div class="stat-grid">
1638
+ <div class="stat-card accent-amber"><div class="label">Issue Rate</div><div class="value">${issueRate}%</div><div class="stat-desc">${data.flagged_count} of ${data.total_sessions} sessions had at least one detected issue</div></div>
1639
+ <div class="stat-card accent-purple"><div class="label">Reliability Score</div><div class="value">${reliabilityScore}</div><div class="stat-desc">How often the agent completed tasks cleanly, out of 100</div></div>
1640
+ </div>
1641
+
1642
+ <div class="section-label">Issue Types</div>
1643
+ <div class="signal-chart">
1644
+ ${signalTypes.map(type => {
1645
+ const count = data.signal_counts[type] || 0;
1646
+ const pct = Math.round((count / maxSignalCount) * 100);
1647
+ const color = SIGNAL_COLORS[type] || 'muted';
1648
+ const desc = SIGNAL_DESCRIPTIONS[type] || '';
1649
+ const barColor = color === 'muted' ? 'var(--text-tertiary)' : `var(--${color})`;
1650
+ return `<div class="signal-lollipop-row${desc ? ' signal-lollipop-expandable' : ''}" data-desc="${escHtml(desc)}">
1651
+ <div class="signal-lollipop-main">
1652
+ <span class="signal-bar-label">${SIGNAL_LABELS[type]}</span>
1653
+ <div class="signal-lollipop-track">
1654
+ <svg width="100%" height="20" class="signal-lollipop-svg">
1655
+ <line x1="0" y1="10" x2="${pct}%" y2="10" stroke="${barColor}" stroke-width="2"/>
1656
+ <circle cx="${pct}%" cy="10" r="5" fill="${barColor}"/>
1657
+ </svg>
1658
+ </div>
1659
+ <span class="signal-bar-count">${count}</span>
1660
+ </div>
1661
+ ${desc ? `<div class="signal-lollipop-desc">${escHtml(desc)}</div>` : ''}
1662
+ </div>`;
1663
+ }).join('')}
1664
+ </div>
1665
+
1666
+ <div class="section-label">Sessions with Issues (${data.flagged_count})</div>
1667
+ <div id="insightsList">
1668
+ ${data.top_flagged.length ? [...data.top_flagged].sort((a, b) => {
1669
+ const aRate = new Set((a.signals||[]).map(s=>s.type)).size;
1670
+ const bRate = new Set((b.signals||[]).map(s=>s.type)).size;
1671
+ return bRate - aRate || b.confusion_score - a.confusion_score;
1672
+ }).map(s => {
1673
+ const summary = cleanSessionSummary(s.summary, '');
1674
+ return `<div class="session-item insight-row" data-id="${escHtml(s.session_id)}">
1675
+ <div class="session-header">
1676
+ <span class="session-time">${fmtTime(s.start_time)}</span>
1677
+ <span class="insight-scores">issue rate ${renderIssueRateBadge(s.signals)} · reliability ${renderReliabilityBadge(s.confusion_score)}</span>
1678
+ </div>
1679
+ <div class="session-summary">${escHtml(truncate(summary, 120))}</div>
1680
+ <div class="session-meta">
1681
+ <span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
1682
+ ${s.agent ? `<span class="session-agent">${escHtml(normalizeAgentLabel(s.agent))}</span>` : ''}
1683
+ ${s.model ? `<span class="session-model">${escHtml(s.model)}</span>` : ''}
1684
+ </span>
1685
+ <span class="insight-signal-tags">${s.signals.map(sig => renderSignalTag(sig)).join('')}</span>
1686
+ </div>
1687
+ </div>`;
1688
+ }).join('') : '<div class="empty"><p>No flagged sessions found</p></div>'}
1689
+ </div>
1690
+ `;
1691
+
1692
+ content.innerHTML = html;
1693
+ transitionView();
1694
+
1695
+ $$('.session-item', content).forEach(item => {
1696
+ item.addEventListener('click', () => viewSession(item.dataset.id));
1697
+ });
1698
+
1699
+ $$('.signal-lollipop-expandable', content).forEach(row => {
1700
+ row.addEventListener('click', () => {
1701
+ row.classList.toggle('signal-lollipop-open');
1702
+ });
1703
+ });
1704
+ }
1705
+
1422
1706
  // --- Navigation ---
1423
1707
  window._searchType = '';
1424
1708
  window._searchRole = '';
@@ -1437,6 +1721,7 @@ $$('.nav-item').forEach(item => {
1437
1721
  else if (view === 'sessions') viewSessions();
1438
1722
  else if (view === 'files') viewFiles();
1439
1723
  else if (view === 'timeline') viewTimeline();
1724
+ else if (view === 'insights') viewInsights();
1440
1725
  else if (view === 'stats') viewStats();
1441
1726
  });
1442
1727
  });
@@ -1683,8 +1968,14 @@ function openCmdk() {
1683
1968
  initTheme();
1684
1969
  document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
1685
1970
  document.getElementById('theme-toggle-mobile')?.addEventListener('click', toggleTheme);
1686
- document.getElementById('cmdkBtn')?.addEventListener('click', () => openCmdk());
1687
1971
  document.getElementById('mobile-search-btn')?.addEventListener('click', () => openCmdk());
1972
+ document.getElementById('settings-btn')?.addEventListener('click', () => { window._lastView = 'stats'; updateNavActive('stats'); setHash('stats'); viewStats(); });
1973
+ document.getElementById('settings-btn-mobile')?.addEventListener('click', () => { window._lastView = 'stats'; updateNavActive('stats'); setHash('stats'); viewStats(); });
1974
+ document.getElementById('cmdkBtn')?.addEventListener('click', () => openCmdk());
1975
+
1976
+
1977
+
1978
+
1688
1979
  document.addEventListener('keydown', e => {
1689
1980
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
1690
1981
  e.preventDefault();
package/public/index.html CHANGED
@@ -21,7 +21,10 @@
21
21
  <nav class="sidebar">
22
22
  <div class="sidebar-header">
23
23
  <h1>Agent<span>Acta</span></h1>
24
- <button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme"><svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg><svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
24
+ <div class="sidebar-header-actions">
25
+ <button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme"><svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg><svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
26
+ <button class="theme-toggle" id="settings-btn" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.325 4.317a1.724 1.724 0 0 1 3.35 0 1.724 1.724 0 0 0 2.573 1.066 1.724 1.724 0 0 1 2.28 2.28 1.724 1.724 0 0 0 1.065 2.573 1.724 1.724 0 0 1 0 3.35 1.724 1.724 0 0 0-1.066 2.573 1.724 1.724 0 0 1-2.28 2.28 1.724 1.724 0 0 0-2.573 1.065 1.724 1.724 0 0 1-3.35 0 1.724 1.724 0 0 0-2.573-1.066 1.724 1.724 0 0 1-2.28-2.28 1.724 1.724 0 0 0-1.065-2.573 1.724 1.724 0 0 1 0-3.35 1.724 1.724 0 0 0 1.066-2.573 1.724 1.724 0 0 1 2.28-2.28 1.724 1.724 0 0 0 2.572-1.065z"/><circle cx="12" cy="12" r="3"/></svg></button>
27
+ </div>
25
28
  </div>
26
29
  <button class="cmdk-trigger" id="cmdkBtn" aria-label="Search" title="Search (⌘K)">
27
30
  <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
@@ -29,26 +32,26 @@
29
32
  <kbd>⌘K</kbd>
30
33
  </button>
31
34
  <div class="nav-section">
35
+ <div class="nav-item active" data-view="overview">
36
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h7V3H3z"/><path d="M14 21h7v-7h-7z"/><path d="M14 10h7V3h-7z"/><path d="M3 21h7v-5H3z"/></svg>
37
+ <span>Overview</span>
38
+ </div>
32
39
  <div class="nav-item" data-view="sessions">
33
40
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
34
41
  <span>Sessions</span>
35
42
  </div>
43
+ <div class="nav-item" data-view="insights">
44
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
45
+ <span>Insights</span>
46
+ </div>
36
47
  <div class="nav-item" data-view="timeline">
37
48
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
38
49
  <span>Timeline</span>
39
50
  </div>
40
- <div class="nav-item active" data-view="overview">
41
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h7V3H3z"/><path d="M14 21h7v-7h-7z"/><path d="M14 10h7V3h-7z"/><path d="M3 21h7v-5H3z"/></svg>
42
- <span>Overview</span>
43
- </div>
44
51
  <div class="nav-item" data-view="files">
45
52
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
46
53
  <span>Files</span>
47
54
  </div>
48
- <div class="nav-item" data-view="stats">
49
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.325 4.317a1.724 1.724 0 0 1 3.35 0 1.724 1.724 0 0 0 2.573 1.066 1.724 1.724 0 0 1 2.28 2.28 1.724 1.724 0 0 0 1.065 2.573 1.724 1.724 0 0 1 0 3.35 1.724 1.724 0 0 0-1.066 2.573 1.724 1.724 0 0 1-2.28 2.28 1.724 1.724 0 0 0-2.573 1.065 1.724 1.724 0 0 1-3.35 0 1.724 1.724 0 0 0-2.573-1.066 1.724 1.724 0 0 1-2.28-2.28 1.724 1.724 0 0 0-1.065-2.573 1.724 1.724 0 0 1 0-3.35 1.724 1.724 0 0 0 1.066-2.573 1.724 1.724 0 0 1 2.28-2.28 1.724 1.724 0 0 0 2.572-1.065z"/><circle cx="12" cy="12" r="3"/></svg>
50
- <span>Settings</span>
51
- </div>
52
55
 
53
56
  </div>
54
57
  </nav>
@@ -56,8 +59,8 @@
56
59
  <div class="mobile-toolbar">
57
60
  <button class="mobile-search-btn" id="mobile-search-btn" title="Search" aria-label="Search"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></button>
58
61
  <button class="theme-toggle-mobile" id="theme-toggle-mobile" title="Toggle theme" aria-label="Toggle theme"><svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg><svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
62
+ <button class="theme-toggle-mobile" id="settings-btn-mobile" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.325 4.317a1.724 1.724 0 0 1 3.35 0 1.724 1.724 0 0 0 2.573 1.066 1.724 1.724 0 0 1 2.28 2.28 1.724 1.724 0 0 0 1.065 2.573 1.724 1.724 0 0 1 0 3.35 1.724 1.724 0 0 0-1.066 2.573 1.724 1.724 0 0 1-2.28 2.28 1.724 1.724 0 0 0-2.573 1.065 1.724 1.724 0 0 1-3.35 0 1.724 1.724 0 0 0-2.573-1.066 1.724 1.724 0 0 1-2.28-2.28 1.724 1.724 0 0 0-1.065-2.573 1.724 1.724 0 0 1 0-3.35 1.724 1.724 0 0 0 1.066-2.573 1.724 1.724 0 0 1 2.28-2.28 1.724 1.724 0 0 0 2.572-1.065z"/><circle cx="12" cy="12" r="3"/></svg></button>
59
63
  </div>
60
-
61
64
  </div>
62
65
  <script src="/app.js"></script>
63
66
  <script>
package/public/style.css CHANGED
@@ -197,7 +197,12 @@ body.cmdk-open {
197
197
  display: flex;
198
198
  align-items: center;
199
199
  justify-content: space-between;
200
- gap: var(--space-sm);
200
+ }
201
+
202
+ .sidebar-header-actions {
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 6px;
201
206
  }
202
207
 
203
208
  .sidebar h1 {
@@ -233,6 +238,13 @@ body.cmdk-open {
233
238
  color: var(--text-primary);
234
239
  }
235
240
 
241
+ .theme-toggle.active,
242
+ .theme-toggle-mobile.active {
243
+ color: var(--accent);
244
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
245
+ background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated));
246
+ }
247
+
236
248
  .theme-toggle svg,
237
249
  .theme-toggle-mobile svg {
238
250
  width: 16px;
@@ -605,6 +617,28 @@ body.cmdk-open {
605
617
  border-radius: 10px;
606
618
  }
607
619
 
620
+ .signal-tag {
621
+ display: inline-flex;
622
+ align-items: center;
623
+ gap: 6px;
624
+ font-size: 11px;
625
+ font-weight: 500;
626
+ color: var(--text-secondary);
627
+ white-space: nowrap;
628
+ }
629
+ .signal-dot {
630
+ width: 7px;
631
+ height: 7px;
632
+ border-radius: 50%;
633
+ flex-shrink: 0;
634
+ }
635
+ .signal-amber .signal-dot { background: var(--amber); }
636
+ .signal-red .signal-dot { background: var(--red); }
637
+ .signal-purple .signal-dot { background: var(--purple); }
638
+ .signal-teal .signal-dot { background: var(--teal); }
639
+ .signal-accent .signal-dot { background: var(--accent); }
640
+ .signal-muted .signal-dot { background: var(--text-tertiary); }
641
+
608
642
  .session-type {
609
643
  font-size: 11px;
610
644
  font-weight: 500;
@@ -623,6 +657,8 @@ body.cmdk-open {
623
657
 
624
658
  .session-meta {
625
659
  display: flex;
660
+ align-items: center;
661
+ justify-content: space-between;
626
662
  gap: var(--space-lg);
627
663
  margin-top: var(--space-sm);
628
664
  font-size: 12px;
@@ -1511,22 +1547,25 @@ mark {
1511
1547
  .sidebar {
1512
1548
  position: fixed;
1513
1549
  top: auto;
1514
- bottom: 0;
1515
- left: 0;
1516
- right: 0;
1517
- width: 100%;
1550
+ bottom: calc(24px + env(safe-area-inset-bottom, 0px));
1551
+ left: 50%;
1552
+ right: auto;
1553
+ transform: translateX(-50%);
1554
+ width: auto;
1518
1555
  height: auto;
1519
1556
  border-right: none;
1520
- border-top: 1px solid var(--border-subtle);
1521
- padding: 0;
1522
- padding-bottom: max(env(safe-area-inset-bottom, 0px), 16px);
1557
+ border: 1px solid var(--border-default);
1558
+ border-radius: 999px;
1559
+ padding: 8px 12px;
1523
1560
  display: flex;
1524
1561
  flex-direction: row;
1525
- overflow: hidden;
1562
+ align-items: center;
1563
+ overflow: visible;
1526
1564
  z-index: 100;
1527
1565
  background: var(--bg-surface);
1528
1566
  backdrop-filter: blur(20px);
1529
1567
  -webkit-backdrop-filter: blur(20px);
1568
+ box-shadow: 0 8px 32px rgba(0,0,0,0.28);
1530
1569
  }
1531
1570
 
1532
1571
  .sidebar-header { display: none; }
@@ -1535,29 +1574,30 @@ mark {
1535
1574
  display: flex;
1536
1575
  }
1537
1576
 
1577
+ .sidebar-header, .cmdk-trigger { display: none; }
1578
+
1538
1579
  .nav-section {
1539
1580
  display: flex;
1540
1581
  flex-direction: row;
1541
- width: 100%;
1542
1582
  padding: 0;
1543
- gap: 0;
1583
+ gap: 2px;
1544
1584
  }
1545
1585
 
1546
1586
  .nav-item {
1547
- flex: 1;
1587
+ flex: none;
1548
1588
  flex-direction: column;
1549
1589
  align-items: center;
1550
1590
  justify-content: center;
1551
- padding: 10px 0 4px;
1591
+ padding: 7px 14px 5px;
1552
1592
  font-size: 10px;
1553
1593
  gap: 3px;
1554
- border-radius: 0;
1594
+ border-radius: 999px;
1555
1595
  letter-spacing: 0.02em;
1556
1596
  }
1557
1597
 
1558
1598
  .nav-item::before { display: none; }
1559
1599
 
1560
- .nav-item svg { width: 22px; height: 22px; }
1600
+ .nav-item svg { width: 20px; height: 20px; }
1561
1601
 
1562
1602
  .nav-item.active {
1563
1603
  color: var(--accent);
@@ -1568,7 +1608,7 @@ mark {
1568
1608
  margin-left: 0;
1569
1609
  padding: var(--space-xl) var(--space-lg);
1570
1610
  padding-top: calc(var(--space-xl) + env(safe-area-inset-top, 0px));
1571
- padding-bottom: calc(80px + max(env(safe-area-inset-bottom, 0px), 20px));
1611
+ padding-bottom: calc(100px + max(env(safe-area-inset-bottom, 0px), 20px));
1572
1612
  max-width: 100%;
1573
1613
  }
1574
1614
 
@@ -1911,3 +1951,144 @@ mark {
1911
1951
  .cmdk-item-sub { display: none; }
1912
1952
  .cmdk-item-meta { font-size: 10px; }
1913
1953
  }
1954
+
1955
+ /* ── Insights ── */
1956
+ .settings-gear {
1957
+ background: none;
1958
+ border: none;
1959
+ cursor: pointer;
1960
+ padding: 4px;
1961
+ border-radius: var(--radius-sm);
1962
+ color: var(--text-muted);
1963
+ display: flex;
1964
+ align-items: center;
1965
+ justify-content: center;
1966
+ transition: color 0.15s, background 0.15s;
1967
+ }
1968
+ .settings-gear svg { width: 16px; height: 16px; }
1969
+ .settings-gear:hover { color: var(--text-primary); background: var(--hover-bg); }
1970
+
1971
+ .signal-lollipop-row {
1972
+ display: flex;
1973
+ flex-direction: column;
1974
+ margin-bottom: 8px;
1975
+ }
1976
+ .signal-lollipop-expandable {
1977
+ cursor: pointer;
1978
+ }
1979
+ .signal-lollipop-main {
1980
+ display: flex;
1981
+ align-items: center;
1982
+ gap: 10px;
1983
+ }
1984
+ .signal-bar-label {
1985
+ font-size: 13px;
1986
+ color: var(--text-secondary);
1987
+ width: 130px;
1988
+ flex-shrink: 0;
1989
+ }
1990
+ .signal-expand-hint {
1991
+ font-size: 11px;
1992
+ color: var(--text-tertiary);
1993
+ flex-shrink: 0;
1994
+ }
1995
+ .signal-lollipop-desc {
1996
+ display: none;
1997
+ font-size: 12px;
1998
+ color: var(--text-tertiary);
1999
+ padding: 5px 0 3px 0;
2000
+ line-height: 1.4;
2001
+ }
2002
+ .signal-lollipop-open .signal-lollipop-desc {
2003
+ display: block;
2004
+ }
2005
+ .signal-lollipop-open .signal-expand-hint {
2006
+ color: var(--text-secondary);
2007
+ }
2008
+ .signal-lollipop-track {
2009
+ flex: 1;
2010
+ height: 20px;
2011
+ }
2012
+ .signal-lollipop-svg {
2013
+ display: block;
2014
+ overflow: visible;
2015
+ }
2016
+ .signal-bar-count {
2017
+ font-size: 12px;
2018
+ font-variant-numeric: tabular-nums;
2019
+ color: var(--text-muted);
2020
+ width: 36px;
2021
+ text-align: right;
2022
+ flex-shrink: 0;
2023
+ }
2024
+ .stat-desc {
2025
+ font-size: 11px;
2026
+ color: var(--text-tertiary);
2027
+ margin-top: 4px;
2028
+ line-height: 1.3;
2029
+ }
2030
+
2031
+ .insight-row { cursor: pointer; }
2032
+ .insight-row:hover { background: var(--hover-bg); }
2033
+ .insight-callout {
2034
+ display: flex;
2035
+ align-items: flex-start;
2036
+ gap: 8px;
2037
+ padding: 8px 0;
2038
+ border-bottom: 1px solid var(--border-color);
2039
+ font-size: 13px;
2040
+ }
2041
+ .insight-callout:last-child { border-bottom: none; }
2042
+ .insight-detail { color: var(--text-secondary); }
2043
+ .insights-signals { margin-top: 8px; }
2044
+
2045
+ .confusion-badge {
2046
+ display: inline-flex;
2047
+ align-items: center;
2048
+ gap: 4px;
2049
+ padding: 1px 7px;
2050
+ border-radius: 999px;
2051
+ font-size: 11px;
2052
+ font-weight: 600;
2053
+ letter-spacing: 0.02em;
2054
+ }
2055
+
2056
+ .confusion-label {
2057
+ font-weight: 400;
2058
+ font-size: 10px;
2059
+ opacity: 0.7;
2060
+ text-transform: uppercase;
2061
+ letter-spacing: 0.04em;
2062
+ }
2063
+
2064
+ .confusion-issue-count {
2065
+ background: color-mix(in srgb, var(--text-tertiary) 15%, transparent);
2066
+ color: var(--text-secondary);
2067
+ }
2068
+
2069
+ .insight-scores {
2070
+ font-size: 11px;
2071
+ color: var(--text-tertiary);
2072
+ white-space: nowrap;
2073
+ text-transform: uppercase;
2074
+ letter-spacing: 0.05em;
2075
+ }
2076
+
2077
+ .insight-score-value {
2078
+ font-weight: 600;
2079
+ color: var(--text-secondary);
2080
+ }
2081
+
2082
+ .insight-signal-tags {
2083
+ display: flex;
2084
+ flex-wrap: wrap;
2085
+ gap: 5px;
2086
+ align-items: center;
2087
+ }
2088
+ .confusion-green { background: #dcfce7; color: #166534; }
2089
+ .confusion-yellow { background: #fef9c3; color: #854d0e; }
2090
+ .confusion-red { background: #fee2e2; color: #991b1b; }
2091
+ [data-theme="dark"] .confusion-green { background: #14532d; color: #86efac; }
2092
+ [data-theme="dark"] .confusion-yellow { background: #713f12; color: #fde68a; }
2093
+ [data-theme="dark"] .confusion-red { background: #7f1d1d; color: #fca5a5; }
2094
+