agentacta 2026.3.6 → 2026.3.12-r2

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,6 +32,8 @@ 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
@@ -80,7 +82,9 @@ Suggestions come from your own dataset: top tools, common topics, frequently tou
80
82
 
81
83
  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
84
 
83
- Session types get tagged so noisy categories are easier to spot (cron, sub-agent, heartbeat).
85
+ 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.
86
+
87
+ 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
88
 
85
89
  ### Timeline
86
90
 
@@ -118,18 +122,20 @@ On first run, AgentActa creates:
118
122
  - `~/.config/agentacta/config.json`
119
123
  - or `agentacta.config.json` in current directory (if present)
120
124
 
121
- Default config:
125
+ Default config (auto-generated on first run — session directories are detected automatically):
122
126
 
123
127
  ```json
124
128
  {
125
129
  "port": 4003,
126
130
  "storage": "reference",
127
- "sessionsPath": null,
131
+ "sessionsPath": ["~/.claude/projects", "~/.openclaw/sessions"],
128
132
  "dbPath": "./agentacta.db",
129
133
  "projectAliases": {}
130
134
  }
131
135
  ```
132
136
 
137
+ `sessionsPath` accepts a string, a colon-delimited string, or a JSON array.
138
+
133
139
  ### Storage modes
134
140
 
135
141
  - `reference` (default): index parsed events in SQLite, keep source JSONL on disk. Lightweight.
@@ -163,6 +169,76 @@ Default config:
163
169
  | `GET /api/health` | Server status, version, uptime, session count |
164
170
  | `GET /api/export/search?q=<query>&format=md` | Export search results |
165
171
 
172
+ ### Context API
173
+
174
+ The Context API gives agents historical context before they start working. Instead of exploring a codebase from scratch, an agent can query what's happened before.
175
+
176
+ | Endpoint | Description |
177
+ |---|---|
178
+ | `GET /api/context/file?path=<filepath>` | History for a specific file |
179
+ | `GET /api/context/repo?path=<repo-path>` | Aggregates for a repo/project |
180
+ | `GET /api/context/agent?name=<agent-name>` | Stats for a specific agent |
181
+
182
+ **File context** — how many sessions touched this file, when it was last modified, recent change summaries, operation breakdown (reads vs edits), related files, and recent errors:
183
+
184
+ ```bash
185
+ curl http://localhost:4003/api/context/file?path=/home/user/project/server.js
186
+ ```
187
+ ```json
188
+ {
189
+ "file": "/home/user/project/server.js",
190
+ "sessionCount": 34,
191
+ "lastModified": "3h ago",
192
+ "recentChanges": ["Added OAuth state validation", "Fixed password masking"],
193
+ "operations": { "edit": 105, "read": 56 },
194
+ "relatedFiles": [{ "path": "public/app.js", "count": 28 }],
195
+ "recentErrors": []
196
+ }
197
+ ```
198
+
199
+ **Agent context** — total sessions, cost, average duration, most-used tools, recent work:
200
+
201
+ ```bash
202
+ curl http://localhost:4003/api/context/agent?name=claude-code
203
+ ```
204
+ ```json
205
+ {
206
+ "agent": "claude-code",
207
+ "sessionCount": 60,
208
+ "totalCost": 18.83,
209
+ "avgDuration": 288,
210
+ "topTools": [{ "tool": "edit", "count": 190 }, { "tool": "exec", "count": 560 }],
211
+ "recentSessions": [{ "id": "...", "summary": "Added context API...", "timestamp": "..." }],
212
+ "successRate": 100
213
+ }
214
+ ```
215
+
216
+ **Repo context** — aggregate cost, tokens, distinct agents, most-touched files, common tools:
217
+
218
+ ```bash
219
+ curl http://localhost:4003/api/context/repo?path=agentacta
220
+ ```
221
+
222
+ #### Using the Context API with agents
223
+
224
+ Inject context into agent prompts so new sessions start informed:
225
+
226
+ ```bash
227
+ # Fetch context before starting Claude Code
228
+ CONTEXT=$(curl -s http://localhost:4003/api/context/file?path=$(pwd)/server.js)
229
+ claude --print "Context from previous sessions: $CONTEXT
230
+
231
+ Your task: refactor the auth module"
232
+ ```
233
+
234
+ Or add it to a CLAUDE.md / AGENTS.md:
235
+
236
+ ```markdown
237
+ ## Project Context API
238
+ Before modifying key files, query AgentActa for history:
239
+ curl http://localhost:4003/api/context/file?path={filepath}
240
+ ```
241
+
166
242
  Agent integration example:
167
243
 
168
244
  ```javascript
package/config.js CHANGED
@@ -14,6 +14,12 @@ function resolveConfigFile() {
14
14
 
15
15
  const CONFIG_FILE = resolveConfigFile();
16
16
 
17
+ const KNOWN_SESSION_DIRS = [
18
+ path.join(os.homedir(), '.claude', 'projects'), // Claude Code
19
+ path.join(os.homedir(), '.codex', 'sessions'), // Codex CLI
20
+ path.join(os.homedir(), '.openclaw', 'sessions'), // OpenClaw
21
+ ];
22
+
17
23
  const DEFAULTS = {
18
24
  port: 4003,
19
25
  storage: 'reference',
@@ -22,6 +28,11 @@ const DEFAULTS = {
22
28
  projectAliases: {}
23
29
  };
24
30
 
31
+ function detectSessionDirs() {
32
+ const found = KNOWN_SESSION_DIRS.filter(d => fs.existsSync(d));
33
+ return found.length > 0 ? found : null;
34
+ }
35
+
25
36
  function loadConfig() {
26
37
  let fileConfig = {};
27
38
 
@@ -32,11 +43,18 @@ function loadConfig() {
32
43
  console.error(`Warning: Could not parse ${CONFIG_FILE}:`, err.message);
33
44
  }
34
45
  } else {
35
- // First-run: create default config in XDG location
46
+ // First-run: create default config with auto-detected session dirs
47
+ const detected = detectSessionDirs();
48
+ const firstRunDefaults = { ...DEFAULTS, sessionsPath: detected };
36
49
  const dir = path.dirname(CONFIG_FILE);
37
50
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
38
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULTS, null, 2) + '\n');
51
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(firstRunDefaults, null, 2) + '\n');
52
+ // Apply to in-memory config so this run also benefits
53
+ fileConfig = firstRunDefaults;
39
54
  console.log(`Created default config: ${CONFIG_FILE}`);
55
+ if (detected) {
56
+ console.log(`Auto-detected session directories:\n${detected.map(d => ` - ${d}`).join('\n')}`);
57
+ }
40
58
  }
41
59
 
42
60
  const config = { ...DEFAULTS, ...fileConfig };
package/index.js CHANGED
@@ -25,7 +25,9 @@ if (process.argv.includes('--demo')) {
25
25
 
26
26
  const { loadConfig } = require('./config');
27
27
  const { open, init, createStmts } = require('./db');
28
- const { discoverSessionDirs, indexFile } = require('./indexer');
28
+ const { discoverSessionDirs, listJsonlFiles, indexFile } = require('./indexer');
29
+ const { attributeSessionEvents, attributeEventDelta } = require('./project-attribution');
30
+ const { loadDeltaAttributionContext } = require('./delta-attribution-context');
29
31
 
30
32
  const config = loadConfig();
31
33
  const PORT = config.port;
@@ -105,6 +107,15 @@ function getDbSize() {
105
107
  }
106
108
  }
107
109
 
110
+ function relativeTime(ts) {
111
+ if (!ts) return null;
112
+ const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
113
+ if (diff < 60) return 'just now';
114
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
115
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
116
+ return `${Math.floor(diff / 86400)}d ago`;
117
+ }
118
+
108
119
  function normalizeAgentLabel(agent) {
109
120
  if (!agent) return agent;
110
121
  if (agent === 'main') return 'openclaw-main';
@@ -145,13 +156,13 @@ const sessionDirs = discoverSessionDirs(config);
145
156
 
146
157
  // Initial indexing pass
147
158
  for (const dir of sessionDirs) {
148
- const files = fs.readdirSync(dir.path).filter(f => f.endsWith('.jsonl'));
149
- for (const file of files) {
159
+ const files = listJsonlFiles(dir.path, !!dir.recursive);
160
+ for (const filePath of files) {
150
161
  try {
151
- const result = indexFile(db, path.join(dir.path, file), dir.agent, stmts, ARCHIVE_MODE);
152
- if (!result.skipped) console.log(`Indexed: ${file} (${dir.agent})`);
162
+ const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
163
+ if (!result.skipped) console.log(`Indexed: ${path.basename(filePath)} (${dir.agent})`);
153
164
  } catch (err) {
154
- console.error(`Error indexing ${file}:`, err.message);
165
+ console.error(`Error indexing ${path.basename(filePath)}:`, err.message);
155
166
  }
156
167
  }
157
168
  }
@@ -161,10 +172,37 @@ console.log(`Watching ${sessionDirs.length} session directories`);
161
172
  // Debounce map: filePath -> timeout handle
162
173
  const _reindexTimers = new Map();
163
174
  const REINDEX_DEBOUNCE_MS = 2000;
175
+ const RECURSIVE_RESCAN_MS = 15000;
176
+
177
+ function reindexRecursiveDir(dir) {
178
+ try {
179
+ const files = listJsonlFiles(dir.path, true);
180
+ let changed = 0;
181
+ for (const filePath of files) {
182
+ const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
183
+ if (!result.skipped) {
184
+ changed++;
185
+ if (result.sessionId) sseEmitter.emit('session-update', result.sessionId);
186
+ }
187
+ }
188
+ if (changed > 0) console.log(`Live re-indexed ${changed} files (${dir.agent})`);
189
+ } catch (err) {
190
+ console.error(`Error rescanning ${dir.path}:`, err.message);
191
+ }
192
+ }
164
193
 
165
194
  for (const dir of sessionDirs) {
166
195
  try {
167
196
  fs.watch(dir.path, { persistent: false }, (eventType, filename) => {
197
+ if (dir.recursive) {
198
+ if (_reindexTimers.has(dir.path)) clearTimeout(_reindexTimers.get(dir.path));
199
+ _reindexTimers.set(dir.path, setTimeout(() => {
200
+ _reindexTimers.delete(dir.path);
201
+ reindexRecursiveDir(dir);
202
+ }, REINDEX_DEBOUNCE_MS));
203
+ return;
204
+ }
205
+
168
206
  if (!filename || !filename.endsWith('.jsonl')) return;
169
207
  const filePath = path.join(dir.path, filename);
170
208
  if (!fs.existsSync(filePath)) return;
@@ -174,7 +212,7 @@ for (const dir of sessionDirs) {
174
212
  _reindexTimers.set(filePath, setTimeout(() => {
175
213
  _reindexTimers.delete(filePath);
176
214
  try {
177
- const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE);
215
+ const result = indexFile(db, filePath, dir.agent, stmts, ARCHIVE_MODE, config);
178
216
  if (!result.skipped) {
179
217
  console.log(`Live re-indexed: ${filename} (${dir.agent})`);
180
218
  if (result.sessionId) sseEmitter.emit('session-update', result.sessionId);
@@ -185,6 +223,10 @@ for (const dir of sessionDirs) {
185
223
  }, REINDEX_DEBOUNCE_MS));
186
224
  });
187
225
  console.log(` Watching: ${dir.path}`);
226
+ if (dir.recursive) {
227
+ const timer = setInterval(() => reindexRecursiveDir(dir), RECURSIVE_RESCAN_MS);
228
+ timer.unref?.();
229
+ }
188
230
  } catch (err) {
189
231
  console.error(` Failed to watch ${dir.path}:`, err.message);
190
232
  }
@@ -284,7 +326,7 @@ const server = http.createServer((req, res) => {
284
326
 
285
327
  else if (pathname.match(/^\/api\/sessions\/[^/]+\/events$/)) {
286
328
  const id = pathname.split('/')[3];
287
- const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
329
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
288
330
  if (!session) return json(res, { error: 'Not found' }, 404);
289
331
 
290
332
  const after = query.after || '1970-01-01T00:00:00.000Z';
@@ -297,12 +339,14 @@ const server = http.createServer((req, res) => {
297
339
  ORDER BY timestamp ASC, id ASC
298
340
  LIMIT ?`
299
341
  ).all(id, after, after, afterId, limit);
300
- json(res, { events: rows, after, afterId, count: rows.length });
342
+ const contextRows = loadDeltaAttributionContext(db, id, rows);
343
+ const events = attributeEventDelta(session, rows, contextRows);
344
+ json(res, { events, after, afterId, count: events.length });
301
345
  }
302
346
 
303
347
  else if (pathname.match(/^\/api\/sessions\/[^/]+\/stream$/)) {
304
348
  const id = pathname.split('/')[3];
305
- const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
349
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
306
350
  if (!session) return json(res, { error: 'Not found' }, 404);
307
351
 
308
352
  res.writeHead(200, {
@@ -322,8 +366,10 @@ const server = http.createServer((req, res) => {
322
366
  'SELECT * FROM events WHERE session_id = ? AND timestamp > ? ORDER BY timestamp ASC'
323
367
  ).all(id, lastTs);
324
368
  if (rows.length) {
369
+ const contextRows = loadDeltaAttributionContext(db, id, rows);
370
+ const attributedRows = attributeEventDelta(session, rows, contextRows);
325
371
  lastTs = rows[rows.length - 1].timestamp;
326
- res.write(`id: ${lastTs}\ndata: ${JSON.stringify(rows)}\n\n`);
372
+ res.write(`id: ${lastTs}\ndata: ${JSON.stringify(attributedRows)}\n\n`);
327
373
  }
328
374
  } catch (err) {
329
375
  console.error('SSE query error:', err.message);
@@ -347,8 +393,9 @@ const server = http.createServer((req, res) => {
347
393
  if (!session) { json(res, { error: 'Not found' }, 404); }
348
394
  else {
349
395
  const events = db.prepare('SELECT * FROM events WHERE session_id = ? ORDER BY timestamp DESC').all(id);
396
+ const attributed = attributeSessionEvents(session, events);
350
397
  const hasArchive = ARCHIVE_MODE && db.prepare('SELECT COUNT(*) as c FROM archive WHERE session_id = ?').get(id).c > 0;
351
- json(res, { session, events, hasArchive });
398
+ json(res, { session, events: attributed.events, projectFilters: attributed.projectFilters, hasArchive });
352
399
  }
353
400
  }
354
401
  else if (pathname.match(/^\/api\/archive\/session\/[^/]+$/)) {
@@ -514,6 +561,182 @@ const server = http.createServer((req, res) => {
514
561
  const sizeAfter = getDbSize();
515
562
  json(res, { ok: true, sizeBefore, sizeAfter });
516
563
  }
564
+ // --- Context API ---
565
+ else if (pathname === '/api/context/file') {
566
+ const fp = query.path || '';
567
+ if (!fp) return json(res, { error: 'path parameter is required' }, 400);
568
+
569
+ const sessionCount = db.prepare(
570
+ 'SELECT COUNT(DISTINCT session_id) as c FROM file_activity WHERE file_path = ?'
571
+ ).get(fp).c;
572
+
573
+ if (sessionCount === 0) {
574
+ return json(res, { file: fp, sessionCount: 0, lastModified: null, recentChanges: [], operations: {}, relatedFiles: [], recentErrors: [] });
575
+ }
576
+
577
+ const lastTouched = db.prepare(
578
+ 'SELECT MAX(timestamp) as t FROM file_activity WHERE file_path = ?'
579
+ ).get(fp).t;
580
+
581
+ const recentChanges = db.prepare(
582
+ `SELECT DISTINCT s.summary FROM file_activity fa
583
+ JOIN sessions s ON s.id = fa.session_id
584
+ WHERE fa.file_path = ? AND s.summary IS NOT NULL
585
+ ORDER BY s.start_time DESC LIMIT 5`
586
+ ).all(fp).map(r => r.summary);
587
+
588
+ const opsRows = db.prepare(
589
+ 'SELECT operation, COUNT(*) as c FROM file_activity WHERE file_path = ? GROUP BY operation'
590
+ ).all(fp);
591
+ const operations = {};
592
+ for (const r of opsRows) operations[r.operation] = r.c;
593
+
594
+ const relatedFiles = db.prepare(
595
+ `SELECT fa2.file_path, COUNT(DISTINCT fa1.session_id) as c
596
+ FROM file_activity fa1
597
+ JOIN file_activity fa2 ON fa1.session_id = fa2.session_id
598
+ WHERE fa1.file_path = ? AND fa2.file_path != ?
599
+ GROUP BY fa2.file_path
600
+ ORDER BY c DESC LIMIT 5`
601
+ ).all(fp, fp).map(r => ({ path: r.file_path, count: r.c }));
602
+
603
+ const sessionIds = db.prepare(
604
+ 'SELECT DISTINCT session_id FROM file_activity WHERE file_path = ?'
605
+ ).all(fp).map(r => r.session_id);
606
+
607
+ let recentErrors = [];
608
+ if (sessionIds.length) {
609
+ const placeholders = sessionIds.map(() => '?').join(',');
610
+ recentErrors = db.prepare(
611
+ `SELECT tool_result FROM events
612
+ WHERE session_id IN (${placeholders})
613
+ AND tool_result IS NOT NULL
614
+ AND (tool_result LIKE '%error%' OR tool_result LIKE '%Error%' OR tool_result LIKE '%ERROR%')
615
+ ORDER BY timestamp DESC LIMIT 3`
616
+ ).all(...sessionIds).map(r => r.tool_result.slice(0, 200));
617
+ }
618
+
619
+ return json(res, {
620
+ file: fp, sessionCount,
621
+ lastModified: relativeTime(lastTouched),
622
+ recentChanges, operations, relatedFiles, recentErrors
623
+ });
624
+ }
625
+ else if (pathname === '/api/context/repo') {
626
+ const repoPath = query.path || '';
627
+ if (!repoPath) return json(res, { error: 'path parameter is required' }, 400);
628
+
629
+ // Find sessions matching the repo path via file_activity or initial_prompt
630
+ const sessionIds = db.prepare(
631
+ `SELECT DISTINCT session_id FROM file_activity WHERE file_path = ? OR file_path LIKE ?`
632
+ ).all(repoPath, repoPath + '/%').map(r => r.session_id);
633
+
634
+ const promptSessions = db.prepare(
635
+ `SELECT id FROM sessions WHERE initial_prompt LIKE ?`
636
+ ).all('%' + repoPath + '%').map(r => r.id);
637
+
638
+ const allIds = [...new Set([...sessionIds, ...promptSessions])];
639
+
640
+ if (allIds.length === 0) {
641
+ return json(res, { repo: repoPath, sessionCount: 0, totalCost: 0, totalTokens: 0, agents: [], topFiles: [], recentSessions: [], commonTools: [], commonErrors: [] });
642
+ }
643
+
644
+ const ph = allIds.map(() => '?').join(',');
645
+
646
+ const agg = db.prepare(
647
+ `SELECT COUNT(*) as c, SUM(total_cost) as cost, SUM(total_tokens) as tokens
648
+ FROM sessions WHERE id IN (${ph})`
649
+ ).get(...allIds);
650
+
651
+ const agents = [...new Set(
652
+ db.prepare(`SELECT DISTINCT agent FROM sessions WHERE id IN (${ph}) AND agent IS NOT NULL`).all(...allIds)
653
+ .map(r => normalizeAgentLabel(r.agent)).filter(Boolean)
654
+ )];
655
+
656
+ const topFiles = db.prepare(
657
+ `SELECT file_path, COUNT(*) as c FROM file_activity
658
+ WHERE session_id IN (${ph})
659
+ GROUP BY file_path ORDER BY c DESC LIMIT 10`
660
+ ).all(...allIds).map(r => ({ path: r.file_path, count: r.c }));
661
+
662
+ const recentSessions = db.prepare(
663
+ `SELECT id, summary, agent, start_time, end_time FROM sessions
664
+ WHERE id IN (${ph})
665
+ ORDER BY start_time DESC LIMIT 5`
666
+ ).all(...allIds).map(r => ({
667
+ id: r.id, summary: r.summary, agent: normalizeAgentLabel(r.agent),
668
+ timestamp: r.start_time, status: r.end_time ? 'completed' : 'in-progress'
669
+ }));
670
+
671
+ const commonTools = db.prepare(
672
+ `SELECT tool_name, COUNT(*) as c FROM events
673
+ WHERE session_id IN (${ph}) AND tool_name IS NOT NULL
674
+ GROUP BY tool_name ORDER BY c DESC LIMIT 10`
675
+ ).all(...allIds).map(r => ({ tool: r.tool_name, count: r.c }));
676
+
677
+ const commonErrors = db.prepare(
678
+ `SELECT DISTINCT SUBSTR(tool_result, 1, 200) as err FROM events
679
+ WHERE session_id IN (${ph})
680
+ AND tool_result IS NOT NULL
681
+ AND (tool_result LIKE '%error%' OR tool_result LIKE '%Error%' OR tool_result LIKE '%ERROR%')
682
+ ORDER BY timestamp DESC LIMIT 5`
683
+ ).all(...allIds).map(r => r.err);
684
+
685
+ return json(res, {
686
+ repo: repoPath, sessionCount: allIds.length,
687
+ totalCost: agg.cost || 0, totalTokens: agg.tokens || 0,
688
+ agents, topFiles, recentSessions, commonTools, commonErrors
689
+ });
690
+ }
691
+ else if (pathname === '/api/context/agent') {
692
+ const name = query.name || '';
693
+ if (!name) return json(res, { error: 'name parameter is required' }, 400);
694
+
695
+ // Try exact match first, then check all sessions with normalized label match
696
+ let sessions = db.prepare(
697
+ 'SELECT * FROM sessions WHERE agent = ?'
698
+ ).all(name);
699
+ if (sessions.length === 0) {
700
+ sessions = db.prepare('SELECT * FROM sessions WHERE agent IS NOT NULL').all()
701
+ .filter(s => normalizeAgentLabel(s.agent) === name);
702
+ }
703
+
704
+ if (sessions.length === 0) {
705
+ return json(res, { agent: name, sessionCount: 0, totalCost: 0, avgDuration: 0, topTools: [], recentSessions: [], successRate: 0 });
706
+ }
707
+
708
+ const totalCost = sessions.reduce((s, r) => s + (r.total_cost || 0), 0);
709
+ let totalDuration = 0;
710
+ let durationCount = 0;
711
+ for (const s of sessions) {
712
+ if (s.start_time && s.end_time) {
713
+ totalDuration += (new Date(s.end_time) - new Date(s.start_time)) / 1000;
714
+ durationCount++;
715
+ }
716
+ }
717
+ const avgDuration = durationCount > 0 ? Math.round(totalDuration / durationCount) : 0;
718
+
719
+ const withSummary = sessions.filter(s => s.summary).length;
720
+ const successRate = Math.round((withSummary / sessions.length) * 100);
721
+
722
+ const ids = sessions.map(s => s.id);
723
+ const ph = ids.map(() => '?').join(',');
724
+ const topTools = db.prepare(
725
+ `SELECT tool_name, COUNT(*) as c FROM events
726
+ WHERE session_id IN (${ph}) AND tool_name IS NOT NULL
727
+ GROUP BY tool_name ORDER BY c DESC LIMIT 10`
728
+ ).all(...ids).map(r => ({ tool: r.tool_name, count: r.c }));
729
+
730
+ const recentSessions = sessions
731
+ .sort((a, b) => (b.start_time || '').localeCompare(a.start_time || ''))
732
+ .slice(0, 5)
733
+ .map(s => ({ id: s.id, summary: s.summary, timestamp: s.start_time }));
734
+
735
+ return json(res, {
736
+ agent: name, sessionCount: sessions.length,
737
+ totalCost, avgDuration, topTools, recentSessions, successRate
738
+ });
739
+ }
517
740
  else if (pathname === '/api/files') {
518
741
  const limit = parseInt(query.limit) || 100;
519
742
  const offset = parseInt(query.offset) || 0;
package/indexer.js CHANGED
@@ -28,17 +28,56 @@ 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
+ }
41
+
42
+ // Expand a single path into session dirs, handling Claude Code's per-project structure
43
+ function expandPath(p) {
44
+ if (!fs.existsSync(p)) return;
45
+ const stat = fs.statSync(p);
46
+ if (!stat.isDirectory()) return;
47
+ const normalized = normalizedPath(p);
48
+ const normalizedCodex = normalizedPath(codexSessionsPath);
49
+ // Claude Code: ~/.claude/projects contains per-project subdirs with JSONL files
50
+ if (normalized.endsWith('/.claude/projects')) {
51
+ for (const proj of fs.readdirSync(p)) {
52
+ const projDir = path.join(p, proj);
53
+ if (fs.statSync(projDir).isDirectory()) {
54
+ const hasJsonl = fs.readdirSync(projDir).some(f => f.endsWith('.jsonl'));
55
+ if (hasJsonl) dirs.push({ path: projDir, agent: 'claude-code' });
56
+ }
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 });
61
+ } else {
62
+ dirs.push({ path: p, agent: path.basename(path.dirname(p)) });
63
+ }
64
+ }
31
65
 
32
66
  // Config sessionsPath or env var override
33
67
  const sessionsOverride = process.env.AGENTACTA_SESSIONS_PATH || (config && config.sessionsPath);
34
68
  if (sessionsOverride) {
35
- for (const p of sessionsOverride.split(':')) {
36
- if (fs.existsSync(p)) dirs.push({ path: p, agent: path.basename(path.dirname(p)) });
69
+ const overridePaths = Array.isArray(sessionsOverride)
70
+ ? sessionsOverride
71
+ : sessionsOverride.split(':');
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 });
37
76
  }
38
77
  if (dirs.length) return dirs;
39
78
  }
40
79
 
41
- // Scan ~/.openclaw/agents/*/sessions/
80
+ // Auto-discover: ~/.openclaw/agents/*/sessions/
42
81
  const oclawAgents = path.join(home, '.openclaw/agents');
43
82
  if (fs.existsSync(oclawAgents)) {
44
83
  for (const agent of fs.readdirSync(oclawAgents)) {
@@ -49,26 +88,11 @@ function discoverSessionDirs(config) {
49
88
  }
50
89
  }
51
90
 
52
- // Scan ~/.claude/projects/*/ (Claude Code stores JSONL directly in project dirs)
53
- const claudeProjects = path.join(home, '.claude/projects');
54
- if (fs.existsSync(claudeProjects)) {
55
- for (const proj of fs.readdirSync(claudeProjects)) {
56
- const projDir = path.join(claudeProjects, proj);
57
- // Claude Code: JSONL files directly in project dir
58
- if (fs.existsSync(projDir) && fs.statSync(projDir).isDirectory()) {
59
- const hasJsonl = fs.readdirSync(projDir).some(f => f.endsWith('.jsonl'));
60
- if (hasJsonl) dirs.push({ path: projDir, agent: 'claude-code' });
61
- }
62
- // Also check sessions/ subdirectory (future-proofing)
63
- const sp = path.join(projDir, 'sessions');
64
- if (fs.existsSync(sp) && fs.statSync(sp).isDirectory()) {
65
- dirs.push({ path: sp, agent: 'claude-code' });
66
- }
67
- }
68
- }
91
+ // Auto-discover: ~/.claude/projects/
92
+ expandPath(path.join(home, '.claude/projects'));
69
93
 
70
94
  // Scan ~/.codex/sessions recursively (Codex CLI stores nested YYYY/MM/DD/*.jsonl)
71
- const codexSessions = path.join(home, '.codex/sessions');
95
+ const codexSessions = codexSessionsPath;
72
96
  if (fs.existsSync(codexSessions) && fs.statSync(codexSessions).isDirectory()) {
73
97
  dirs.push({ path: codexSessions, agent: 'codex-cli', recursive: true });
74
98
  }
@@ -221,6 +245,8 @@ function extractProjectFromPath(filePath, config) {
221
245
 
222
246
  // Common repo location: ~/Developer/<repo>/...
223
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);
224
250
 
225
251
  // OpenClaw workspace and agent stores
226
252
  if (parts[0] === '.openclaw' && parts[1] === 'workspace') return aliasProject('workspace', config);
@@ -269,6 +295,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
269
295
  let firstMessageTimestamp = null;
270
296
  let codexProvider = null;
271
297
  let codexSource = null;
298
+ let codexOriginator = null;
272
299
  let sawSnapshotRecord = false;
273
300
  let sawNonSnapshotRecord = false;
274
301
 
@@ -289,11 +316,16 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
289
316
  if (firstLine.agent) agent = firstLine.agent;
290
317
  if (firstLine.sessionType) sessionType = firstLine.sessionType;
291
318
  if (sessionId.includes('subagent')) sessionType = 'subagent';
292
- } else if (firstLine.type === 'user' || firstLine.type === 'assistant' || firstLine.type === 'file-history-snapshot') {
293
- // Claude Code format — no session header, extract from first message line
319
+ } else if (firstLine.type === 'user' || firstLine.type === 'assistant' || firstLine.type === 'file-history-snapshot' || firstLine.type === 'queue-operation') {
320
+ // Claude Code format — no session header, extract from first message or queue-operation line
294
321
  isClaudeCode = true;
295
322
  for (const line of lines) {
296
323
  let obj; try { obj = JSON.parse(line); } catch { continue; }
324
+ if (obj.sessionId && obj.timestamp) {
325
+ sessionId = obj.sessionId;
326
+ sessionStart = obj.timestamp;
327
+ break;
328
+ }
297
329
  if ((obj.type === 'user' || obj.type === 'assistant') && obj.sessionId) {
298
330
  sessionId = obj.sessionId;
299
331
  sessionStart = obj.timestamp;
@@ -311,7 +343,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
311
343
  const meta = firstLine.payload || {};
312
344
  sessionId = meta.id || path.basename(filePath, '.jsonl');
313
345
  sessionStart = meta.timestamp || firstLine.timestamp || new Date().toISOString();
314
- sessionType = 'codex-cli';
346
+ sessionType = 'codex-direct';
315
347
  agent = 'codex-cli';
316
348
  if (meta.model) {
317
349
  model = meta.model;
@@ -319,6 +351,8 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
319
351
  }
320
352
  codexProvider = meta.model_provider || null;
321
353
  codexSource = meta.source || null;
354
+ codexOriginator = meta.originator || null;
355
+ if (codexOriginator && codexOriginator.includes('symphony')) sessionType = 'codex-symphony';
322
356
  } else {
323
357
  return { skipped: true };
324
358
  }
@@ -353,6 +387,8 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
353
387
  }
354
388
  if (meta.model_provider) codexProvider = meta.model_provider;
355
389
  if (meta.source) codexSource = meta.source;
390
+ if (meta.originator) codexOriginator = meta.originator;
391
+ if (codexOriginator && codexOriginator.includes('symphony')) sessionType = 'codex-symphony';
356
392
  if (meta.model_provider && !model) model = meta.model_provider;
357
393
  continue;
358
394
  }
@@ -557,6 +593,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
557
593
  const parts = ['Codex CLI session'];
558
594
  if (codexProvider) parts.push(`provider=${codexProvider}`);
559
595
  if (codexSource) parts.push(`source=${codexSource}`);
596
+ if (codexOriginator) parts.push(`originator=${codexOriginator}`);
560
597
  summary = parts.join(' · ');
561
598
  } else {
562
599
  summary = 'Heartbeat session';
@@ -720,6 +757,6 @@ function indexAll(db, config) {
720
757
  return { sessions: stats.sessions, events: evStats.events, newSessions: totalSessions };
721
758
  }
722
759
 
723
- module.exports = { discoverSessionDirs, indexFile, indexAll };
760
+ module.exports = { discoverSessionDirs, listJsonlFiles, indexFile, indexAll };
724
761
 
725
762
  if (require.main === module) run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentacta",
3
- "version": "2026.3.6",
3
+ "version": "2026.3.12-r2",
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
@@ -647,7 +647,9 @@ async function viewSession(id) {
647
647
  if (data._error || data.error) { content.innerHTML = `<div class="empty"><h2>${escHtml(data.error || 'Unable to load')}</h2></div>`; return; }
648
648
 
649
649
  const s = data.session;
650
- const cost = fmtCost(s.total_cost);
650
+ const projectFilters = Array.isArray(data.projectFilters)
651
+ ? data.projectFilters.filter(p => p && p.project && Number.isFinite(Number(p.eventCount)))
652
+ : [];
651
653
  let html = `
652
654
  <div class="back-btn" id="backBtn">\u2190 Back</div>
653
655
  <div class="page-title">Session</div>
@@ -667,7 +669,6 @@ async function viewSession(id) {
667
669
  <div class="session-header" style="margin-bottom:12px">
668
670
  <span class="session-time">${fmtDate(s.start_time)} \u00b7 ${fmtTimeShort(s.start_time)} \u2013 ${fmtTimeShort(s.end_time)}</span>
669
671
  <span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
670
- ${renderProjectTags(s)}
671
672
  ${s.agent && s.agent !== 'main' ? `<span class="session-agent">${escHtml(normalizeAgentLabel(s.agent))}</span>` : ''}
672
673
  ${s.session_type && s.session_type !== normalizeAgentLabel(s.agent || '') ? `<span class="session-type">${escHtml(s.session_type)}</span>` : ''}
673
674
  ${renderModelTags(s)}
@@ -678,16 +679,62 @@ async function viewSession(id) {
678
679
  <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
680
  ${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
681
  </div>
682
+ ${projectFilters.length ? `
683
+ <div class="section-label" style="margin-top:14px">Project Filter</div>
684
+ <div class="filters" id="sessionProjectFilters" style="margin-bottom:4px">
685
+ <span class="filter-chip active" data-project-filter="all">All</span>
686
+ ${projectFilters.map(p => `<span class="filter-chip" data-project-filter="${escHtml(p.project)}">${escHtml(p.project)} · ${p.eventCount}</span>`).join('')}
687
+ </div>
688
+ ` : ''}
681
689
  </div>
682
- <div class="section-label">Events</div>
690
+ <div class="section-label" id="sessionEventsLabel">Events</div>
691
+ <div id="eventsContainer"></div>
692
+ <div class="empty" id="sessionEventsEmpty" style="display:none"><h2>No events</h2><p>This session has no events to display.</p></div>
683
693
  `;
684
694
 
685
695
  const PAGE_SIZE = 50;
686
- const allEvents = data.events;
696
+ const allEvents = Array.isArray(data.events) ? [...data.events] : [];
697
+ let activeProjectFilter = 'all';
687
698
  let rendered = 0;
699
+ let onScroll = null;
700
+ let pendingNewCount = 0;
701
+
702
+ const getFilteredEvents = () => {
703
+ if (activeProjectFilter === 'all') return allEvents;
704
+ return allEvents.filter(ev => ev.project === activeProjectFilter);
705
+ };
706
+
707
+ const updateEventsLabel = () => {
708
+ const el = $('#sessionEventsLabel');
709
+ if (!el) return;
710
+ const count = getFilteredEvents().length;
711
+ if (activeProjectFilter === 'all') {
712
+ el.textContent = `Events (${count})`;
713
+ return;
714
+ }
715
+ el.textContent = `Events (${count}) · ${activeProjectFilter}`;
716
+ };
717
+
718
+ const updateEventsEmptyState = () => {
719
+ const empty = $('#sessionEventsEmpty');
720
+ if (!empty) return;
721
+ empty.style.display = getFilteredEvents().length ? 'none' : 'block';
722
+ };
723
+
724
+ const setProjectFilter = (nextFilter) => {
725
+ if (nextFilter === activeProjectFilter) return;
726
+ activeProjectFilter = nextFilter;
727
+ const chips = $$('#sessionProjectFilters .filter-chip');
728
+ chips.forEach(node => node.classList.toggle('active', node.dataset.projectFilter === activeProjectFilter));
729
+ pendingNewCount = 0;
730
+ const indicator = document.getElementById('newEventsIndicator');
731
+ if (indicator) indicator.remove();
732
+ resetRenderedEvents();
733
+ };
688
734
 
689
735
  function renderBatch() {
690
- const batch = allEvents.slice(rendered, rendered + PAGE_SIZE);
736
+ const filtered = getFilteredEvents();
737
+ const batch = filtered.slice(rendered, rendered + PAGE_SIZE);
691
738
  if (!batch.length) return;
692
739
  const frag = document.createElement('div');
693
740
  frag.innerHTML = batch.map(renderEvent).join('');
@@ -696,33 +743,61 @@ async function viewSession(id) {
696
743
  while (frag.firstChild) container.appendChild(frag.firstChild);
697
744
  }
698
745
  rendered += batch.length;
699
-
700
746
  }
701
747
 
702
- html += '<div id="eventsContainer">' + allEvents.slice(0, PAGE_SIZE).map(renderEvent).join('') + '</div>';
703
- rendered = Math.min(PAGE_SIZE, allEvents.length);
704
748
  content.innerHTML = html;
705
749
  transitionView();
706
750
 
707
- let onScroll = null;
708
- if (allEvents.length > PAGE_SIZE) {
751
+ const syncScrollHandler = () => {
752
+ const total = getFilteredEvents().length;
753
+ if (total <= rendered) {
754
+ if (onScroll) {
755
+ window.removeEventListener('scroll', onScroll);
756
+ onScroll = null;
757
+ }
758
+ return;
759
+ }
760
+
761
+ if (onScroll) return;
762
+
709
763
  let loading = false;
710
764
  onScroll = () => {
711
- if (loading || rendered >= allEvents.length) return;
765
+ if (loading || rendered >= getFilteredEvents().length) return;
712
766
  const scrollBottom = window.innerHeight + window.scrollY;
713
767
  const threshold = document.body.offsetHeight - 300;
714
768
  if (scrollBottom >= threshold) {
715
769
  loading = true;
716
770
  renderBatch();
717
771
  loading = false;
718
- if (rendered >= allEvents.length) {
772
+ if (rendered >= getFilteredEvents().length) {
719
773
  window.removeEventListener('scroll', onScroll);
720
774
  onScroll = null;
721
775
  }
722
776
  }
723
777
  };
724
778
  window.addEventListener('scroll', onScroll, { passive: true });
725
- }
779
+ };
780
+
781
+ const resetRenderedEvents = () => {
782
+ const container = document.getElementById('eventsContainer');
783
+ if (!container) return;
784
+ container.innerHTML = '';
785
+ rendered = 0;
786
+ renderBatch();
787
+ syncScrollHandler();
788
+ updateEventsLabel();
789
+ updateEventsEmptyState();
790
+ };
791
+
792
+ resetRenderedEvents();
793
+
794
+ const filterChips = $$('#sessionProjectFilters .filter-chip');
795
+ filterChips.forEach(chip => {
796
+ chip.addEventListener('click', () => {
797
+ const nextFilter = chip.dataset.projectFilter || 'all';
798
+ setProjectFilter(nextFilter);
799
+ });
800
+ });
726
801
 
727
802
  $('#backBtn').addEventListener('click', () => {
728
803
  clearJumpUi();
@@ -778,22 +853,35 @@ async function viewSession(id) {
778
853
  if (jumpBtn) {
779
854
  jumpBtn.addEventListener('click', async () => {
780
855
  const fromY = window.scrollY || window.pageYOffset || 0;
856
+ const firstMessageId = s.first_message_id;
781
857
 
782
858
  jumpBtn.classList.add('jumping');
783
859
  jumpBtn.disabled = true;
784
860
 
861
+ if (!firstMessageId) {
862
+ jumpBtn.classList.remove('jumping');
863
+ jumpBtn.disabled = false;
864
+ return;
865
+ }
866
+
867
+ // Resolve from full session context, not just project-filtered events.
868
+ const inCurrentFilter = getFilteredEvents().some(ev => ev.id === firstMessageId);
869
+ if (!inCurrentFilter && activeProjectFilter !== 'all') {
870
+ setProjectFilter('all');
871
+ }
872
+
785
873
  // Let button state paint before heavy DOM work.
786
874
  await new Promise(requestAnimationFrame);
787
875
 
788
876
  // Load remaining events in chunks so UI stays responsive.
789
877
  let loops = 0;
790
- while (rendered < allEvents.length) {
878
+ while (rendered < getFilteredEvents().length) {
791
879
  renderBatch();
792
880
  loops += 1;
793
881
  if (loops % 2 === 0) await new Promise(requestAnimationFrame);
794
882
  }
795
883
 
796
- const firstMessage = document.querySelector(`[data-event-id="${s.first_message_id}"]`);
884
+ const firstMessage = document.querySelector(`[data-event-id="${firstMessageId}"]`);
797
885
  if (!firstMessage) {
798
886
  jumpBtn.classList.remove('jumping');
799
887
  jumpBtn.disabled = false;
@@ -838,9 +926,8 @@ async function viewSession(id) {
838
926
  });
839
927
  }
840
928
 
841
- // --- Lightweight realtime updates (polling fallback first) ---
929
+ // --- Lightweight realtime updates (polling fallback first) ---
842
930
  const knownIds = new Set(allEvents.map(e => e.id));
843
- let pendingNewCount = 0;
844
931
 
845
932
  const applyIncomingEvents = (incoming) => {
846
933
  const container = document.getElementById('eventsContainer');
@@ -850,8 +937,22 @@ async function viewSession(id) {
850
937
  if (!fresh.length) return;
851
938
  fresh.forEach(e => knownIds.add(e.id));
852
939
 
940
+ // Delta endpoint returns oldest -> newest, and view is newest-first.
941
+ for (const ev of fresh) allEvents.unshift(ev);
942
+
943
+ const visibleFresh = activeProjectFilter === 'all'
944
+ ? fresh
945
+ : fresh.filter(ev => ev.project === activeProjectFilter);
946
+
947
+ if (!visibleFresh.length) {
948
+ updateEventsLabel();
949
+ updateEventsEmptyState();
950
+ syncScrollHandler();
951
+ return;
952
+ }
953
+
853
954
  const isAtTop = window.scrollY < 100;
854
- for (const ev of fresh) {
955
+ for (const ev of visibleFresh) {
855
956
  const div = document.createElement('div');
856
957
  div.innerHTML = renderEvent(ev);
857
958
  const el = div.firstElementChild;
@@ -859,9 +960,13 @@ async function viewSession(id) {
859
960
  container.insertBefore(el, container.firstChild);
860
961
  setTimeout(() => el.classList.remove('event-highlight'), 2000);
861
962
  }
963
+ rendered += visibleFresh.length;
964
+ updateEventsLabel();
965
+ updateEventsEmptyState();
966
+ syncScrollHandler();
862
967
 
863
968
  if (!isAtTop) {
864
- pendingNewCount += fresh.length;
969
+ pendingNewCount += visibleFresh.length;
865
970
  let indicator = document.getElementById('newEventsIndicator');
866
971
  if (!indicator) {
867
972
  indicator = document.createElement('div');