agentacta 1.0.0 → 1.1.0

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.0",
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
@@ -480,21 +480,28 @@ function renderFiles() {
480
480
  groups[dir].push(f);
481
481
  });
482
482
 
483
- // Sort groups by total touches
483
+ // Sort groups by active sort criteria
484
+ const groupMetric = (files) => {
485
+ if (sort === 'touches') return files.reduce((s, f) => s + f.touch_count, 0);
486
+ if (sort === 'sessions') return files.reduce((s, f) => s + f.session_count, 0);
487
+ if (sort === 'recent') return Math.max(...files.map(f => new Date(f.last_touched).getTime()));
488
+ return 0;
489
+ };
484
490
  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;
491
+ if (sort === 'name') return a[0].localeCompare(b[0]);
492
+ return groupMetric(b[1]) - groupMetric(a[1]);
488
493
  });
489
494
 
490
495
  listEl.innerHTML = sortedGroups.map(([dir, dirFiles]) => {
491
496
  const totalTouches = dirFiles.reduce((s, f) => s + f.touch_count, 0);
497
+ const totalSessions = dirFiles.reduce((s, f) => s + f.session_count, 0);
498
+ const groupStat = sort === 'sessions' ? `${totalSessions} sessions` : `${totalTouches} touches`;
492
499
  return `
493
500
  <div class="file-group">
494
501
  <div class="file-group-header" data-dir="${escHtml(dir)}">
495
502
  <span class="file-group-arrow">▶</span>
496
503
  <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>
504
+ <span style="color:var(--text2);font-size:12px;margin-left:auto">${dirFiles.length} files · ${groupStat}</span>
498
505
  </div>
499
506
  <div class="file-group-items" style="display:none">
500
507
  ${dirFiles.map(f => renderFileItem(f)).join('')}