agentacta 2026.3.12 โ†’ 2026.3.27

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)
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ function extractCallBaseId(id) {
4
+ if (!id) return '';
5
+ return String(id).replace(/:(call|result)$/, '');
6
+ }
7
+
8
+ function loadDeltaAttributionContext(db, sessionId, rows) {
9
+ if (!db || !Array.isArray(rows) || !rows.length) return [];
10
+
11
+ const ordered = [...rows].sort((a, b) => {
12
+ const ta = Date.parse(a?.timestamp || 0) || 0;
13
+ const tb = Date.parse(b?.timestamp || 0) || 0;
14
+ if (ta !== tb) return ta - tb;
15
+ return String(a?.id || '').localeCompare(String(b?.id || ''));
16
+ });
17
+
18
+ const first = ordered[0];
19
+ const firstTs = first?.timestamp || '1970-01-01T00:00:00.000Z';
20
+ const firstId = first?.id || '';
21
+ const neighborhoodRows = db.prepare(
22
+ `SELECT * FROM events
23
+ WHERE session_id = ?
24
+ AND (timestamp < ? OR (timestamp = ? AND id < ?))
25
+ ORDER BY timestamp DESC, id DESC
26
+ LIMIT 12`
27
+ ).all(sessionId, firstTs, firstTs, firstId).reverse();
28
+
29
+ const callIds = [...new Set(
30
+ rows
31
+ .filter(row => row && row.type === 'tool_result')
32
+ .map(row => extractCallBaseId(row.id))
33
+ .filter(Boolean)
34
+ .map(base => `${base}:call`)
35
+ )];
36
+
37
+ if (!callIds.length) return neighborhoodRows;
38
+
39
+ const placeholders = callIds.map(() => '?').join(',');
40
+ const linkedCallRows = db.prepare(
41
+ `SELECT * FROM events
42
+ WHERE session_id = ?
43
+ AND type = 'tool_call'
44
+ AND id IN (${placeholders})`
45
+ ).all(sessionId, ...callIds);
46
+
47
+ const merged = [];
48
+ const seen = new Set();
49
+ for (const row of [...neighborhoodRows, ...linkedCallRows]) {
50
+ if (!row || !row.id || seen.has(row.id)) continue;
51
+ seen.add(row.id);
52
+ merged.push(row);
53
+ }
54
+ return merged;
55
+ }
56
+
57
+ module.exports = { loadDeltaAttributionContext };
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();