agentacta 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,7 +62,7 @@ Open `http://localhost:4003` in your browser.
62
62
 
63
63
  AgentActa automatically finds your sessions in:
64
64
  - `~/.openclaw/agents/*/sessions/` (OpenClaw)
65
- - `~/.claude/projects/*/sessions/` (Claude Code)
65
+ - `~/.claude/projects/*/` (Claude Code)
66
66
 
67
67
  Or point it at a custom path:
68
68
 
@@ -76,7 +76,7 @@ AGENTACTA_SESSIONS_PATH=/path/to/sessions agentacta
76
76
  Full-text search powered by SQLite FTS5. Filter by message type (messages, tool calls, results) and role (user, assistant). Quick search suggestions are generated from your actual data — most-used tools, common topics, frequently touched files.
77
77
 
78
78
  ### Sessions
79
- Browse all indexed sessions with auto-generated summaries, token breakdowns (output vs input), and model info. Click into any session to see the full event history, most recent first.
79
+ Browse all indexed sessions with auto-generated summaries, token breakdowns (output vs input), and model info. Sessions are automatically tagged by type — cron jobs, sub-agent tasks, and heartbeat sessions get distinct badges. Click into any session to see the full event history, most recent first.
80
80
 
81
81
  ### Timeline
82
82
  Pick a date, see everything that happened. Messages, tool invocations, file changes — most recent first.
@@ -101,13 +101,14 @@ Data never leaves your machine.
101
101
 
102
102
  ## Configuration
103
103
 
104
- On first run, AgentActa creates `agentacta.config.json` with sensible defaults:
104
+ On first run, AgentActa creates a config file with sensible defaults at `~/.config/agentacta/config.json` (or `agentacta.config.json` in the current directory if it exists):
105
105
 
106
106
  ```json
107
107
  {
108
108
  "port": 4003,
109
109
  "storage": "reference",
110
- "sessionDirs": []
110
+ "sessionsPath": null,
111
+ "dbPath": "./agentacta.db"
111
112
  }
112
113
  ```
113
114
 
@@ -199,7 +200,7 @@ All data stays local. AgentActa runs entirely on your machine — no cloud servi
199
200
 
200
201
  ## Contributing
201
202
 
202
- PRs welcome. If you're adding support for a new agent format, add a parser in `indexer.js` and open a PR.
203
+ PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup and guidelines. If you're adding support for a new agent format, add a parser in `indexer.js` and open a PR.
203
204
 
204
205
  ## Etymology
205
206
 
package/indexer.js CHANGED
@@ -30,11 +30,18 @@ function discoverSessionDirs(config) {
30
30
  }
31
31
  }
32
32
 
33
- // Scan ~/.claude/projects/*/sessions/
33
+ // Scan ~/.claude/projects/*/ (Claude Code stores JSONL directly in project dirs)
34
34
  const claudeProjects = path.join(home, '.claude/projects');
35
35
  if (fs.existsSync(claudeProjects)) {
36
36
  for (const proj of fs.readdirSync(claudeProjects)) {
37
- const sp = path.join(claudeProjects, proj, 'sessions');
37
+ const projDir = path.join(claudeProjects, proj);
38
+ // Claude Code: JSONL files directly in project dir
39
+ if (fs.existsSync(projDir) && fs.statSync(projDir).isDirectory()) {
40
+ const hasJsonl = fs.readdirSync(projDir).some(f => f.endsWith('.jsonl'));
41
+ if (hasJsonl) dirs.push({ path: projDir, agent: `claude-${proj}` });
42
+ }
43
+ // Also check sessions/ subdirectory (future-proofing)
44
+ const sp = path.join(projDir, 'sessions');
38
45
  if (fs.existsSync(sp) && fs.statSync(sp).isDirectory()) {
39
46
  dirs.push({ path: sp, agent: `claude-${proj}` });
40
47
  }
@@ -133,23 +140,40 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
133
140
  let firstMessageTimestamp = null;
134
141
 
135
142
  const firstLine = JSON.parse(lines[0]);
143
+ let isClaudeCode = false;
144
+
136
145
  if (firstLine.type === 'session') {
146
+ // OpenClaw format
137
147
  sessionId = firstLine.id;
138
148
  sessionStart = firstLine.timestamp;
139
- // Parse agent info from session metadata
140
149
  if (firstLine.agent) agent = firstLine.agent;
141
150
  if (firstLine.sessionType) sessionType = firstLine.sessionType;
142
- // Detect sub-agent from ID patterns
143
151
  if (sessionId.includes('subagent')) sessionType = 'subagent';
144
-
145
- stmts.deleteEvents.run(sessionId);
146
- stmts.deleteSession.run(sessionId);
147
- stmts.deleteFileActivity.run(sessionId);
148
- if (stmts.deleteArchive) stmts.deleteArchive.run(sessionId);
152
+ } else if (firstLine.type === 'user' || firstLine.type === 'assistant' || firstLine.type === 'file-history-snapshot') {
153
+ // Claude Code format — no session header, extract from first message line
154
+ isClaudeCode = true;
155
+ for (const line of lines) {
156
+ let obj; try { obj = JSON.parse(line); } catch { continue; }
157
+ if ((obj.type === 'user' || obj.type === 'assistant') && obj.sessionId) {
158
+ sessionId = obj.sessionId;
159
+ sessionStart = obj.timestamp;
160
+ break;
161
+ }
162
+ }
163
+ if (!sessionId) {
164
+ // Fallback: use filename as session ID
165
+ sessionId = path.basename(filePath, '.jsonl');
166
+ sessionStart = new Date(firstLine.timestamp || Date.now()).toISOString();
167
+ }
149
168
  } else {
150
169
  return { skipped: true };
151
170
  }
152
171
 
172
+ stmts.deleteEvents.run(sessionId);
173
+ stmts.deleteSession.run(sessionId);
174
+ stmts.deleteFileActivity.run(sessionId);
175
+ if (stmts.deleteArchive) stmts.deleteArchive.run(sessionId);
176
+
153
177
  const pendingEvents = [];
154
178
  const fileActivities = [];
155
179
 
@@ -157,16 +181,34 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
157
181
  let obj;
158
182
  try { obj = JSON.parse(line); } catch { continue; }
159
183
 
160
- if (obj.type === 'session' || obj.type === 'model_change' || obj.type === 'thinking_level_change' || obj.type === 'custom') {
184
+ if (obj.type === 'session' || obj.type === 'model_change' || obj.type === 'thinking_level_change' || obj.type === 'custom' || obj.type === 'file-history-snapshot') {
161
185
  if (obj.type === 'model_change') model = obj.modelId || model;
162
186
  continue;
163
187
  }
164
188
 
189
+ // Normalize: Claude Code uses top-level type "user"/"assistant" with message object
190
+ // OpenClaw uses type "message" with message.role
191
+ let msg, ts;
165
192
  if (obj.type === 'message' && obj.message) {
166
- const msg = obj.message;
167
- const ts = obj.timestamp;
193
+ msg = obj.message;
194
+ ts = obj.timestamp;
195
+ } else if ((obj.type === 'user' || obj.type === 'assistant') && obj.message) {
196
+ // Claude Code format: wrap into consistent shape
197
+ msg = obj.message;
198
+ if (!msg.role) msg.role = obj.type === 'user' ? 'user' : 'assistant';
199
+ ts = obj.timestamp;
200
+ } else {
201
+ continue;
202
+ }
203
+
204
+ if (msg) {
168
205
  sessionEnd = ts;
169
206
 
207
+ // Extract model from assistant messages as fallback
208
+ if (!model && msg.role === 'assistant' && msg.model && msg.model !== 'delivery-mirror' && !msg.model.startsWith('<')) {
209
+ model = msg.model;
210
+ }
211
+
170
212
  // Cost tracking
171
213
  if (msg.usage && msg.usage.cost && typeof msg.usage.cost.total === 'number') {
172
214
  totalCost += msg.usage.cost.total;
@@ -175,15 +217,23 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
175
217
  totalTokens += msg.usage.totalTokens;
176
218
  }
177
219
  if (msg.usage) {
220
+ // OpenClaw format
178
221
  if (typeof msg.usage.input === 'number') totalInputTokens += msg.usage.input;
179
222
  if (typeof msg.usage.output === 'number') totalOutputTokens += msg.usage.output;
180
223
  if (typeof msg.usage.cacheRead === 'number') totalCacheReadTokens += msg.usage.cacheRead;
181
224
  if (typeof msg.usage.cacheWrite === 'number') totalCacheWriteTokens += msg.usage.cacheWrite;
225
+ // Claude Code format
226
+ if (typeof msg.usage.input_tokens === 'number') totalInputTokens += msg.usage.input_tokens;
227
+ if (typeof msg.usage.output_tokens === 'number') totalOutputTokens += msg.usage.output_tokens;
228
+ if (typeof msg.usage.cache_read_input_tokens === 'number') totalCacheReadTokens += msg.usage.cache_read_input_tokens;
229
+ if (typeof msg.usage.cache_creation_input_tokens === 'number') totalCacheWriteTokens += msg.usage.cache_creation_input_tokens;
182
230
  }
183
231
 
232
+ const eventId = obj.id || obj.uuid || `evt-${Date.parse(ts) || Math.random()}`;
233
+
184
234
  const tr = extractToolResult(msg);
185
235
  if (tr) {
186
- pendingEvents.push([obj.id, sessionId, ts, 'tool_result', 'tool', tr.content, tr.toolName, null, tr.content]);
236
+ pendingEvents.push([eventId, sessionId, ts, 'tool_result', 'tool', tr.content, tr.toolName, null, tr.content]);
187
237
  continue;
188
238
  }
189
239
 
@@ -191,7 +241,7 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
191
241
  const role = msg.role || 'unknown';
192
242
 
193
243
  if (content) {
194
- pendingEvents.push([obj.id, sessionId, ts, 'message', role, content, null, null, null]);
244
+ pendingEvents.push([eventId, sessionId, ts, 'message', role, content, null, null, null]);
195
245
  msgCount++;
196
246
  // Better summary: skip heartbeat messages
197
247
  if (!summary && role === 'user' && !isHeartbeat(content)) {
@@ -200,14 +250,14 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
200
250
  // Capture initial prompt from first substantial user message
201
251
  if (!initialPrompt && role === 'user' && content.trim().length > 10 && !isHeartbeat(content)) {
202
252
  initialPrompt = content.slice(0, 500); // Limit to 500 chars
203
- firstMessageId = obj.id;
253
+ firstMessageId = eventId;
204
254
  firstMessageTimestamp = ts;
205
255
  }
206
256
  }
207
257
 
208
258
  const tools = extractToolCalls(msg);
209
259
  for (const tool of tools) {
210
- pendingEvents.push([tool.id || `${obj.id}-${tool.name}`, sessionId, ts, 'tool_call', role, null, tool.name, tool.args, null]);
260
+ pendingEvents.push([tool.id || `${eventId}-${tool.name}`, sessionId, ts, 'tool_call', role, null, tool.name, tool.args, null]);
211
261
  toolCount++;
212
262
 
213
263
  // File activity tracking
@@ -227,6 +277,21 @@ function indexFile(db, filePath, agentName, stmts, archiveMode) {
227
277
  summary = 'Heartbeat session';
228
278
  }
229
279
 
280
+ // Infer session type from first user message content
281
+ if (!sessionType && initialPrompt) {
282
+ const p = initialPrompt.toLowerCase();
283
+ if (p.includes('[cron:')) sessionType = 'cron';
284
+ else if (p.includes('heartbeat') && p.includes('heartbeat_ok')) sessionType = 'heartbeat';
285
+ }
286
+ if (!sessionType && !initialPrompt) sessionType = 'heartbeat';
287
+ // Detect subagent: task-style prompts injected by sessions_spawn
288
+ // These typically start with a date/time stamp and contain a detailed task
289
+ if (!sessionType && initialPrompt) {
290
+ const p = initialPrompt.trim();
291
+ // Sub-agent prompts start with "[Wed 2026-..." or "You are working on..."
292
+ if (/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-/.test(p)) sessionType = 'subagent';
293
+ }
294
+
230
295
  stmts.upsertSession.run(sessionId, sessionStart, sessionEnd, msgCount, toolCount, model, summary, agent, sessionType, totalCost, totalTokens, totalInputTokens, totalOutputTokens, totalCacheReadTokens, totalCacheWriteTokens, initialPrompt, firstMessageId, firstMessageTimestamp);
231
296
  for (const ev of pendingEvents) stmts.insertEvent.run(...ev);
232
297
  for (const fa of fileActivities) stmts.insertFileActivity.run(...fa);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentacta",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Audit trail and search engine for AI agent sessions",
5
5
  "main": "index.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -218,7 +218,11 @@ async function showSearchHome() {
218
218
  });
219
219
 
220
220
  $$('.session-item', el).forEach(item => {
221
- item.addEventListener('click', () => viewSession(item.dataset.id));
221
+ item.addEventListener('click', () => {
222
+ window._lastView = 'search';
223
+ window._lastSearchQuery = $('#searchInput')?.value || '';
224
+ viewSession(item.dataset.id);
225
+ });
222
226
  });
223
227
  }
224
228
 
@@ -260,11 +264,16 @@ async function doSearch(q) {
260
264
  `).join('');
261
265
 
262
266
  $$('.session-link', el).forEach(link => {
263
- link.addEventListener('click', () => viewSession(link.dataset.session));
267
+ link.addEventListener('click', () => {
268
+ window._lastView = 'search';
269
+ window._lastSearchQuery = q;
270
+ viewSession(link.dataset.session);
271
+ });
264
272
  });
265
273
  }
266
274
 
267
275
  async function viewSessions() {
276
+ window._currentSessionId = null;
268
277
  content.innerHTML = '<div class="loading">Loading…</div>';
269
278
  const data = await api('/sessions?limit=200');
270
279
 
@@ -278,6 +287,7 @@ async function viewSessions() {
278
287
  }
279
288
 
280
289
  async function viewSession(id) {
290
+ window._currentSessionId = id;
281
291
  content.innerHTML = '<div class="loading">Loading…</div>';
282
292
  const data = await api(`/sessions/${id}`);
283
293
 
@@ -287,14 +297,12 @@ async function viewSession(id) {
287
297
  const cost = fmtCost(s.total_cost);
288
298
  let html = `
289
299
  <div class="back-btn" id="backBtn">← Back</div>
290
- <div style="display:flex;justify-content:space-between;align-items:center">
291
- <div class="page-title">Session</div>
292
- <div style="display:flex;gap:8px;align-items:center">
293
- ${s.first_message_id ? `<button class="jump-to-start-btn" id="jumpToStartBtn" title="Jump to initial prompt">↗️ Initial Prompt</button>` : ''}
294
- ${data.hasArchive ? `<a class="export-btn" href="#" onclick="dlExport('/api/archive/export/${id}','session.jsonl');return false">📦 JSONL</a>` : ''}
295
- <a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=md','session.md');return false">📄 MD</a>
296
- <a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=json','session.json');return false">📋 JSON</a>
297
- </div>
300
+ <div class="page-title">Session</div>
301
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
302
+ ${s.first_message_id ? `<button class="jump-to-start-btn" id="jumpToStartBtn" title="Jump to initial prompt">↗️ Initial Prompt</button>` : ''}
303
+ ${data.hasArchive ? `<a class="export-btn" href="#" onclick="dlExport('/api/archive/export/${id}','session.jsonl');return false">📦 JSONL</a>` : ''}
304
+ <a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=md','session.md');return false">📄 MD</a>
305
+ <a class="export-btn" href="#" onclick="dlExport('/api/export/session/${id}?format=json','session.json');return false">📋 JSON</a>
298
306
  </div>
299
307
  <div class="session-item" style="cursor:default">
300
308
  <div class="session-header">
@@ -320,6 +328,7 @@ async function viewSession(id) {
320
328
  $('#backBtn').addEventListener('click', () => {
321
329
  if (window._lastView === 'timeline') viewTimeline();
322
330
  else if (window._lastView === 'files') viewFiles();
331
+ else if (window._lastView === 'search') viewSearch(window._lastSearchQuery || '');
323
332
  else viewSessions();
324
333
  });
325
334
 
@@ -480,21 +489,28 @@ function renderFiles() {
480
489
  groups[dir].push(f);
481
490
  });
482
491
 
483
- // Sort groups by total touches
492
+ // Sort groups by active sort criteria
493
+ const groupMetric = (files) => {
494
+ if (sort === 'touches') return files.reduce((s, f) => s + f.touch_count, 0);
495
+ if (sort === 'sessions') return files.reduce((s, f) => s + f.session_count, 0);
496
+ if (sort === 'recent') return Math.max(...files.map(f => new Date(f.last_touched).getTime()));
497
+ return 0;
498
+ };
484
499
  const sortedGroups = Object.entries(groups).sort((a, b) => {
485
- const aTotal = a[1].reduce((s, f) => s + f.touch_count, 0);
486
- const bTotal = b[1].reduce((s, f) => s + f.touch_count, 0);
487
- return bTotal - aTotal;
500
+ if (sort === 'name') return a[0].localeCompare(b[0]);
501
+ return groupMetric(b[1]) - groupMetric(a[1]);
488
502
  });
489
503
 
490
504
  listEl.innerHTML = sortedGroups.map(([dir, dirFiles]) => {
491
505
  const totalTouches = dirFiles.reduce((s, f) => s + f.touch_count, 0);
506
+ const totalSessions = dirFiles.reduce((s, f) => s + f.session_count, 0);
507
+ const groupStat = sort === 'sessions' ? `${totalSessions} sessions` : `${totalTouches} touches`;
492
508
  return `
493
509
  <div class="file-group">
494
510
  <div class="file-group-header" data-dir="${escHtml(dir)}">
495
511
  <span class="file-group-arrow">▶</span>
496
512
  <span class="file-group-name">~/${escHtml(dir)}</span>
497
- <span style="color:var(--text2);font-size:12px;margin-left:auto">${dirFiles.length} files · ${totalTouches} touches</span>
513
+ <span style="color:var(--text2);font-size:12px;margin-left:auto">${dirFiles.length} files · ${groupStat}</span>
498
514
  </div>
499
515
  <div class="file-group-items" style="display:none">
500
516
  ${dirFiles.map(f => renderFileItem(f)).join('')}
@@ -606,6 +622,40 @@ $$('.nav-item').forEach(item => {
606
622
 
607
623
  viewSearch();
608
624
 
625
+ // Swipe right from left edge to go back
626
+ (function initSwipeBack() {
627
+ let startX = 0, startY = 0, swiping = false;
628
+ const edgeWidth = 30; // px from left edge
629
+ const threshold = 80;
630
+
631
+ document.addEventListener('touchstart', e => {
632
+ const x = e.touches[0].clientX;
633
+ if (x <= edgeWidth) {
634
+ startX = x;
635
+ startY = e.touches[0].clientY;
636
+ swiping = true;
637
+ }
638
+ }, { passive: true });
639
+
640
+ document.addEventListener('touchmove', e => {
641
+ if (!swiping) return;
642
+ const dx = e.touches[0].clientX - startX;
643
+ const dy = Math.abs(e.touches[0].clientY - startY);
644
+ // Cancel if vertical movement exceeds horizontal (it's a scroll)
645
+ if (dy > dx) { swiping = false; }
646
+ }, { passive: true });
647
+
648
+ document.addEventListener('touchend', e => {
649
+ if (!swiping) return;
650
+ swiping = false;
651
+ const dx = e.changedTouches[0].clientX - startX;
652
+ if (dx > threshold) {
653
+ const backBtn = $('#backBtn');
654
+ if (backBtn) backBtn.click();
655
+ }
656
+ });
657
+ })();
658
+
609
659
  // Pull to refresh
610
660
  (function initPTR() {
611
661
  let startY = 0;
@@ -645,8 +695,14 @@ viewSearch();
645
695
  indicator.classList.add('refreshing');
646
696
  try {
647
697
  await api('/reindex');
648
- const active = $('.nav-item.active');
649
- if (active) active.click();
698
+ // If viewing a session detail, refresh it in place
699
+ const backBtn = $('#backBtn');
700
+ if (backBtn && window._currentSessionId) {
701
+ await viewSession(window._currentSessionId);
702
+ } else {
703
+ const active = $('.nav-item.active');
704
+ if (active) active.click();
705
+ }
650
706
  } catch(err) {}
651
707
  setTimeout(() => {
652
708
  indicator.classList.remove('visible', 'refreshing');