agentacta 2026.3.26 → 2026.4.8

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.
@@ -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/indexer.js CHANGED
@@ -29,14 +29,21 @@ function discoverSessionDirs(config) {
29
29
  const dirs = [];
30
30
  const home = process.env.HOME;
31
31
  const codexSessionsPath = path.join(home, '.codex/sessions');
32
+ const cronRunsPath = path.join(home, '.openclaw/cron/runs');
32
33
 
33
34
  function normalizedPath(p) {
34
35
  return path.resolve(p).replace(/[\\\/]+$/, '');
35
36
  }
36
37
 
37
- function hasDir(targetPath) {
38
+ function hasDir(targetPath, sourceType = 'transcript') {
38
39
  const wanted = normalizedPath(targetPath);
39
- return dirs.some(d => normalizedPath(d.path) === wanted);
40
+ return dirs.some(d => normalizedPath(d.path) === wanted && (d.sourceType || 'transcript') === sourceType);
41
+ }
42
+
43
+ function addDir(dir) {
44
+ if (!dir || !dir.path) return;
45
+ if (hasDir(dir.path, dir.sourceType || 'transcript')) return;
46
+ dirs.push(dir);
40
47
  }
41
48
 
42
49
  // Expand a single path into session dirs, handling Claude Code's per-project structure
@@ -52,14 +59,14 @@ function discoverSessionDirs(config) {
52
59
  const projDir = path.join(p, proj);
53
60
  if (fs.statSync(projDir).isDirectory()) {
54
61
  const hasJsonl = fs.readdirSync(projDir).some(f => f.endsWith('.jsonl'));
55
- if (hasJsonl) dirs.push({ path: projDir, agent: 'claude-code' });
62
+ if (hasJsonl) addDir({ path: projDir, agent: 'claude-code' });
56
63
  }
57
64
  }
58
65
  } else if (normalized === normalizedCodex) {
59
66
  // Codex CLI stores nested YYYY/MM/DD directories and must be recursive.
60
- dirs.push({ path: p, agent: 'codex-cli', recursive: true });
67
+ addDir({ path: p, agent: 'codex-cli', recursive: true });
61
68
  } else {
62
- dirs.push({ path: p, agent: path.basename(path.dirname(p)) });
69
+ addDir({ path: p, agent: path.basename(path.dirname(p)) });
63
70
  }
64
71
  }
65
72
 
@@ -70,11 +77,6 @@ function discoverSessionDirs(config) {
70
77
  ? sessionsOverride
71
78
  : sessionsOverride.split(':');
72
79
  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
- }
77
- if (dirs.length) return dirs;
78
80
  }
79
81
 
80
82
  // Auto-discover: ~/.openclaw/agents/*/sessions/
@@ -83,7 +85,7 @@ function discoverSessionDirs(config) {
83
85
  for (const agent of fs.readdirSync(oclawAgents)) {
84
86
  const sp = path.join(oclawAgents, agent, 'sessions');
85
87
  if (fs.existsSync(sp) && fs.statSync(sp).isDirectory()) {
86
- dirs.push({ path: sp, agent });
88
+ addDir({ path: sp, agent });
87
89
  }
88
90
  }
89
91
  }
@@ -94,13 +96,18 @@ function discoverSessionDirs(config) {
94
96
  // Scan ~/.codex/sessions recursively (Codex CLI stores nested YYYY/MM/DD/*.jsonl)
95
97
  const codexSessions = codexSessionsPath;
96
98
  if (fs.existsSync(codexSessions) && fs.statSync(codexSessions).isDirectory()) {
97
- dirs.push({ path: codexSessions, agent: 'codex-cli', recursive: true });
99
+ addDir({ path: codexSessions, agent: 'codex-cli', recursive: true });
100
+ }
101
+
102
+ // Fallback synthetic source for cron-backed runs that have metadata but no transcript JSONL.
103
+ if (fs.existsSync(cronRunsPath) && fs.statSync(cronRunsPath).isDirectory()) {
104
+ addDir({ path: cronRunsPath, agent: 'cron', sourceType: 'cron-run' });
98
105
  }
99
106
 
100
107
  if (!dirs.length) {
101
108
  // Fallback to hardcoded
102
109
  const fallback = path.join(home, '.openclaw/agents/main/sessions');
103
- if (fs.existsSync(fallback)) dirs.push({ path: fallback, agent: 'main' });
110
+ if (fs.existsSync(fallback)) addDir({ path: fallback, agent: 'main' });
104
111
  }
105
112
 
106
113
  return dirs;
@@ -261,6 +268,94 @@ function extractProjectFromPath(filePath, config) {
261
268
  return null;
262
269
  }
263
270
 
271
+ function indexCronRunFile(db, filePath, agentName, stmts) {
272
+ const stat = fs.statSync(filePath);
273
+ const mtime = stat.mtime.toISOString();
274
+
275
+ if (!REINDEX) {
276
+ const state = stmts.getState.get(filePath);
277
+ if (state && state.last_modified === mtime) return { skipped: true };
278
+ }
279
+
280
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
281
+ if (!raw) return { skipped: true };
282
+
283
+ let meta;
284
+ try {
285
+ meta = JSON.parse(raw.split('\n').find(Boolean));
286
+ } catch {
287
+ return { skipped: true };
288
+ }
289
+
290
+ const sessionId = meta.sessionId;
291
+ if (!sessionId) return { skipped: true };
292
+
293
+ // Guard: don't overwrite a session that was already indexed from a real transcript.
294
+ // Check both event presence AND session_type — a transcript session with zero events
295
+ // (e.g. header-only file) should still win over synthetic cron metadata.
296
+ const existingSession = db.prepare('SELECT session_type FROM sessions WHERE id = ?').get(sessionId);
297
+ if (existingSession && existingSession.session_type !== 'cron') {
298
+ stmts.upsertState.run(filePath, 1, mtime);
299
+ return { skipped: true, preferredTranscript: true, sessionId };
300
+ }
301
+ const existingRealSession = db.prepare('SELECT EXISTS(SELECT 1 FROM events WHERE session_id = ?) AS has_events').get(sessionId);
302
+ if (existingRealSession && existingRealSession.has_events) {
303
+ stmts.upsertState.run(filePath, 1, mtime);
304
+ return { skipped: true, preferredTranscript: true, sessionId };
305
+ }
306
+
307
+ const ts = typeof meta.ts === 'number' ? new Date(meta.ts).toISOString() : new Date().toISOString();
308
+ const runAt = typeof meta.runAtMs === 'number' ? new Date(meta.runAtMs).toISOString() : ts;
309
+ const durationMs = typeof meta.durationMs === 'number' ? meta.durationMs : null;
310
+ const endTime = ts;
311
+ const startTime = durationMs ? new Date(new Date(endTime).getTime() - durationMs).toISOString() : runAt;
312
+ const summary = stripLeadingDatetimePrefix(meta.summary || 'Cron run');
313
+ const sessionKey = typeof meta.sessionKey === 'string' ? meta.sessionKey : '';
314
+ const sessionKeyParts = sessionKey.split(':');
315
+ const inferredAgent = sessionKeyParts[0] === 'agent' && sessionKeyParts[1] ? sessionKeyParts[1] : agentName;
316
+ const model = meta.model || meta.provider || null;
317
+ const totalInputTokens = meta.usage && typeof meta.usage.input_tokens === 'number' ? meta.usage.input_tokens : 0;
318
+ const totalOutputTokens = meta.usage && typeof meta.usage.output_tokens === 'number' ? meta.usage.output_tokens : 0;
319
+ const totalTokens = meta.usage && typeof meta.usage.total_tokens === 'number'
320
+ ? meta.usage.total_tokens
321
+ : totalInputTokens + totalOutputTokens;
322
+
323
+ const commitIndex = db.transaction(() => {
324
+ if (stmts.deleteArchive) stmts.deleteArchive.run(sessionId);
325
+ stmts.deleteFileActivity.run(sessionId);
326
+ stmts.deleteEvents.run(sessionId);
327
+ stmts.deleteSession.run(sessionId);
328
+
329
+ stmts.upsertSession.run(
330
+ sessionId,
331
+ startTime,
332
+ endTime,
333
+ 0,
334
+ 0,
335
+ model,
336
+ summary,
337
+ inferredAgent,
338
+ 'cron',
339
+ 0,
340
+ totalTokens,
341
+ totalInputTokens,
342
+ totalOutputTokens,
343
+ 0,
344
+ 0,
345
+ null,
346
+ null,
347
+ null,
348
+ model ? JSON.stringify([model]) : null,
349
+ null
350
+ );
351
+
352
+ stmts.upsertState.run(filePath, 1, mtime);
353
+ });
354
+
355
+ commitIndex();
356
+ return { sessionId, synthetic: true };
357
+ }
358
+
264
359
  function indexFile(db, filePath, agentName, stmts, archiveMode, config) {
265
360
  const stat = fs.statSync(filePath);
266
361
  const mtime = stat.mtime.toISOString();
@@ -664,7 +759,7 @@ function run() {
664
759
  let allFiles = [];
665
760
  for (const dir of sessionDirs) {
666
761
  const files = listJsonlFiles(dir.path, !!dir.recursive)
667
- .map(filePath => ({ path: filePath, agent: dir.agent }));
762
+ .map(filePath => ({ path: filePath, agent: dir.agent, sourceType: dir.sourceType || 'transcript' }));
668
763
  allFiles.push(...files);
669
764
  }
670
765
 
@@ -673,7 +768,9 @@ function run() {
673
768
  const indexMany = db.transaction(() => {
674
769
  let indexed = 0;
675
770
  for (const f of allFiles) {
676
- const result = indexFile(db, f.path, f.agent, stmts, archiveMode, config);
771
+ const result = f.sourceType === 'cron-run'
772
+ ? indexCronRunFile(db, f.path, f.agent, stmts)
773
+ : indexFile(db, f.path, f.agent, stmts, archiveMode, config);
677
774
  if (!result.skipped) {
678
775
  indexed++;
679
776
  if (indexed % 10 === 0) process.stdout.write('.');
@@ -706,7 +803,9 @@ function run() {
706
803
  const files = listJsonlFiles(dir.path, true);
707
804
  let changed = 0;
708
805
  for (const filePath of files) {
709
- const result = indexFile(db, filePath, dir.agent, stmts, archiveMode, config);
806
+ const result = dir.sourceType === 'cron-run'
807
+ ? indexCronRunFile(db, filePath, dir.agent, stmts)
808
+ : indexFile(db, filePath, dir.agent, stmts, archiveMode, config);
710
809
  if (!result.skipped) changed++;
711
810
  }
712
811
  if (changed > 0) console.log(`Re-indexed ${changed} files (${dir.agent})`);
@@ -723,7 +822,9 @@ function run() {
723
822
  if (!fs.existsSync(filePath)) return;
724
823
  setTimeout(() => {
725
824
  try {
726
- const result = indexFile(db, filePath, dir.agent, stmts, archiveMode, config);
825
+ const result = dir.sourceType === 'cron-run'
826
+ ? indexCronRunFile(db, filePath, dir.agent, stmts)
827
+ : indexFile(db, filePath, dir.agent, stmts, archiveMode, config);
727
828
  if (!result.skipped) console.log(`Re-indexed: ${filename} (${dir.agent})`);
728
829
  } catch (err) {
729
830
  console.error(`Error re-indexing ${filename}:`, err.message);
@@ -745,7 +846,9 @@ function indexAll(db, config) {
745
846
  const files = listJsonlFiles(dir.path, !!dir.recursive);
746
847
  for (const filePath of files) {
747
848
  try {
748
- const result = indexFile(db, filePath, dir.agent, stmts, archiveMode, config);
849
+ const result = dir.sourceType === 'cron-run'
850
+ ? indexCronRunFile(db, filePath, dir.agent, stmts)
851
+ : indexFile(db, filePath, dir.agent, stmts, archiveMode, config);
749
852
  if (!result.skipped) totalSessions++;
750
853
  } catch (err) {
751
854
  console.error(`Error indexing ${path.basename(filePath)}:`, err.message);
@@ -757,6 +860,6 @@ function indexAll(db, config) {
757
860
  return { sessions: stats.sessions, events: evStats.events, newSessions: totalSessions };
758
861
  }
759
862
 
760
- module.exports = { discoverSessionDirs, listJsonlFiles, indexFile, indexAll };
863
+ module.exports = { discoverSessionDirs, listJsonlFiles, indexFile, indexCronRunFile, indexAll };
761
864
 
762
865
  if (require.main === module) run();
package/insights.js ADDED
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+
3
+ // SIGNAL_WEIGHTS kept for reference — no longer used directly in scoring.
4
+ // Scoring is now severity-scaled per signal (see analyzeSession).
5
+ const SIGNAL_WEIGHTS = {
6
+ tool_retry_loop: 30,
7
+ session_bail: 25,
8
+ high_error_rate: 20,
9
+ long_prompt_short_session: 15,
10
+ no_completion: 10
11
+ };
12
+
13
+ function analyzeSession(db, sessionId) {
14
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
15
+ if (!session) return null;
16
+
17
+ const events = db.prepare(
18
+ 'SELECT * FROM events WHERE session_id = ? ORDER BY timestamp ASC'
19
+ ).all(sessionId);
20
+
21
+ const signals = [];
22
+
23
+ // 1. tool_retry_loop: Same tool called 3+ times consecutively
24
+ // Group by tool, keep highest streak count per tool
25
+ const toolCalls = events.filter(e => e.type === 'tool_call');
26
+ if (toolCalls.length >= 3) {
27
+ const worstStreakByTool = {};
28
+ let consecutive = 1;
29
+ for (let i = 1; i < toolCalls.length; i++) {
30
+ if (toolCalls[i].tool_name === toolCalls[i - 1].tool_name) {
31
+ consecutive++;
32
+ } else {
33
+ if (consecutive >= 3) {
34
+ const tool = toolCalls[i - 1].tool_name;
35
+ if (!worstStreakByTool[tool] || consecutive > worstStreakByTool[tool]) {
36
+ worstStreakByTool[tool] = consecutive;
37
+ }
38
+ }
39
+ consecutive = 1;
40
+ }
41
+ }
42
+ // Check final streak
43
+ if (consecutive >= 3) {
44
+ const tool = toolCalls[toolCalls.length - 1].tool_name;
45
+ if (!worstStreakByTool[tool] || consecutive > worstStreakByTool[tool]) {
46
+ worstStreakByTool[tool] = consecutive;
47
+ }
48
+ }
49
+ for (const [tool, count] of Object.entries(worstStreakByTool)) {
50
+ signals.push({ type: 'tool_retry_loop', tool, count });
51
+ }
52
+ }
53
+
54
+ // 2. session_bail: >20 tool calls but no file write events
55
+ if (toolCalls.length > 20) {
56
+ const hasWrite = events.some(e =>
57
+ e.type === 'tool_call' && e.tool_name &&
58
+ (e.tool_name === 'Write' || e.tool_name === 'Edit' ||
59
+ e.tool_name.toLowerCase().includes('write') ||
60
+ e.tool_name.toLowerCase().includes('edit'))
61
+ );
62
+ if (!hasWrite) {
63
+ signals.push({
64
+ type: 'session_bail',
65
+ tool_calls: toolCalls.length
66
+ });
67
+ }
68
+ }
69
+
70
+ // 3. high_error_rate: >30% of tool calls returned errors
71
+ const toolResults = events.filter(e => e.type === 'tool_result');
72
+ if (toolResults.length > 0) {
73
+ const errorResults = toolResults.filter(e => {
74
+ const c = (e.content || e.tool_result || '').toLowerCase();
75
+ return c.includes('error') || c.includes('Error') || c.includes('ERROR') ||
76
+ c.includes('failed') || c.includes('exception');
77
+ });
78
+ const errorRate = errorResults.length / toolResults.length;
79
+ if (errorRate > 0.3) {
80
+ signals.push({
81
+ type: 'high_error_rate',
82
+ error_count: errorResults.length,
83
+ total: toolResults.length,
84
+ rate: Math.round(errorRate * 100)
85
+ });
86
+ }
87
+ }
88
+
89
+ // 4. long_prompt_short_session: Initial prompt <15 words but >30 tool calls
90
+ if (session.initial_prompt && toolCalls.length > 30) {
91
+ const wordCount = session.initial_prompt.trim().split(/\s+/).length;
92
+ if (wordCount < 15) {
93
+ signals.push({
94
+ type: 'long_prompt_short_session',
95
+ prompt_words: wordCount,
96
+ tool_calls: toolCalls.length
97
+ });
98
+ }
99
+ }
100
+
101
+ // 5. no_completion: Last event is a tool call, not an assistant message
102
+ if (events.length > 0) {
103
+ const lastEvent = events[events.length - 1];
104
+ if (lastEvent.type === 'tool_call' || lastEvent.type === 'tool_result') {
105
+ signals.push({
106
+ type: 'no_completion',
107
+ last_event_type: lastEvent.type,
108
+ last_tool: lastEvent.tool_name || null
109
+ });
110
+ }
111
+ }
112
+
113
+ // Compute confusion_score — severity-scaled per signal
114
+ function clamp(val, min, max) { return Math.max(min, Math.min(max, val)); }
115
+ function lerp(t, min, max) { return min + clamp(t, 0, 1) * (max - min); }
116
+
117
+ const seenTypes = new Set();
118
+ let confusionScore = 0;
119
+ for (const sig of signals) {
120
+ if (seenTypes.has(sig.type)) continue;
121
+ seenTypes.add(sig.type);
122
+
123
+ if (sig.type === 'tool_retry_loop') {
124
+ // streak 3 = base 20, streak 10+ = 40
125
+ const t = clamp((sig.count - 3) / 7, 0, 1);
126
+ confusionScore += Math.round(lerp(t, 20, 40));
127
+ } else if (sig.type === 'session_bail') {
128
+ // 20 tool calls = base 15, 60+ = 30
129
+ const t = clamp((sig.tool_calls - 20) / 40, 0, 1);
130
+ confusionScore += Math.round(lerp(t, 15, 30));
131
+ } else if (sig.type === 'high_error_rate') {
132
+ // 31% error rate = base 10, 100% = 35
133
+ const t = clamp((sig.rate - 30) / 70, 0, 1);
134
+ confusionScore += Math.round(lerp(t, 10, 35));
135
+ } else if (sig.type === 'long_prompt_short_session') {
136
+ // 30 tool calls = base 10, 80+ = 20
137
+ const t = clamp((sig.tool_calls - 30) / 50, 0, 1);
138
+ confusionScore += Math.round(lerp(t, 10, 20));
139
+ } else if (sig.type === 'no_completion') {
140
+ confusionScore += 10;
141
+ } else {
142
+ confusionScore += SIGNAL_WEIGHTS[sig.type] || 0;
143
+ }
144
+ }
145
+ confusionScore = Math.min(confusionScore, 100);
146
+
147
+ const flagged = confusionScore >= 30;
148
+
149
+ return {
150
+ session_id: sessionId,
151
+ signals,
152
+ confusion_score: confusionScore,
153
+ flagged,
154
+ computed_at: new Date().toISOString()
155
+ };
156
+ }
157
+
158
+ function analyzeAll(db) {
159
+ const sessions = db.prepare('SELECT id FROM sessions').all();
160
+ const results = [];
161
+
162
+ const upsert = db.prepare(`
163
+ INSERT OR REPLACE INTO session_insights
164
+ (session_id, signals, confusion_score, flagged, computed_at)
165
+ VALUES (?, ?, ?, ?, ?)
166
+ `);
167
+
168
+ const runAll = db.transaction(() => {
169
+ for (const s of sessions) {
170
+ const result = analyzeSession(db, s.id);
171
+ if (!result) continue;
172
+ upsert.run(
173
+ result.session_id,
174
+ JSON.stringify(result.signals),
175
+ result.confusion_score,
176
+ result.flagged ? 1 : 0,
177
+ result.computed_at
178
+ );
179
+ results.push(result);
180
+ }
181
+ });
182
+
183
+ runAll();
184
+ return results;
185
+ }
186
+
187
+ function getInsightsSummary(db) {
188
+ const rows = db.prepare(
189
+ 'SELECT si.*, s.summary, s.model, s.agent, s.start_time, s.tool_count, s.message_count FROM session_insights si JOIN sessions s ON s.id = si.session_id'
190
+ ).all();
191
+
192
+ if (!rows.length) {
193
+ return {
194
+ total_sessions: 0,
195
+ flagged_count: 0,
196
+ flagged_percentage: 0,
197
+ avg_confusion_score: 0,
198
+ signal_counts: {},
199
+ by_agent: {},
200
+ top_flagged: []
201
+ };
202
+ }
203
+
204
+ let totalScore = 0;
205
+ let flaggedCount = 0;
206
+ const signalCounts = {};
207
+ const byAgent = {};
208
+
209
+ for (const row of rows) {
210
+ totalScore += row.confusion_score;
211
+ if (row.flagged) flaggedCount++;
212
+
213
+ const signals = JSON.parse(row.signals || '[]');
214
+ const seenTypes = new Set();
215
+ for (const sig of signals) {
216
+ if (!seenTypes.has(sig.type)) {
217
+ signalCounts[sig.type] = (signalCounts[sig.type] || 0) + 1;
218
+ seenTypes.add(sig.type);
219
+ }
220
+ }
221
+
222
+ const agent = row.agent || 'unknown';
223
+ if (!byAgent[agent]) byAgent[agent] = { count: 0, flagged: 0, total_score: 0 };
224
+ byAgent[agent].count++;
225
+ if (row.flagged) byAgent[agent].flagged++;
226
+ byAgent[agent].total_score += row.confusion_score;
227
+ }
228
+
229
+ for (const agent of Object.keys(byAgent)) {
230
+ byAgent[agent].avg_score = Math.round(byAgent[agent].total_score / byAgent[agent].count);
231
+ }
232
+
233
+ const topFlagged = rows
234
+ .filter(r => r.flagged)
235
+ .sort((a, b) => b.confusion_score - a.confusion_score)
236
+ .slice(0, 20)
237
+ .map(r => ({
238
+ session_id: r.session_id,
239
+ summary: r.summary,
240
+ model: r.model,
241
+ agent: r.agent,
242
+ start_time: r.start_time,
243
+ tool_count: r.tool_count,
244
+ message_count: r.message_count,
245
+ confusion_score: r.confusion_score,
246
+ signals: JSON.parse(r.signals || '[]')
247
+ }));
248
+
249
+ return {
250
+ total_sessions: rows.length,
251
+ flagged_count: flaggedCount,
252
+ flagged_percentage: rows.length ? Math.round((flaggedCount / rows.length) * 100) : 0,
253
+ avg_confusion_score: Math.round(totalScore / rows.length),
254
+ signal_counts: signalCounts,
255
+ by_agent: byAgent,
256
+ top_flagged: topFlagged
257
+ };
258
+ }
259
+
260
+ module.exports = { analyzeSession, analyzeAll, getInsightsSummary };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentacta",
3
- "version": "2026.3.26",
3
+ "version": "2026.4.8",
4
4
  "description": "Audit trail and search engine for AI agent sessions",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -11,6 +11,9 @@
11
11
  "indexer.js",
12
12
  "db.js",
13
13
  "config.js",
14
+ "project-attribution.js",
15
+ "delta-attribution-context.js",
16
+ "insights.js",
14
17
  "public/",
15
18
  "LICENSE",
16
19
  "README.md"
@@ -51,4 +54,4 @@
51
54
  "tar-fs": "^3.0.6",
52
55
  "npmlog": "^7.0.1"
53
56
  }
54
- }
57
+ }
@@ -0,0 +1,443 @@
1
+ 'use strict';
2
+
3
+ const PATH_KEYS = new Set([
4
+ 'path', 'file', 'filename', 'file_path', 'filepath',
5
+ 'cwd', 'workdir', 'directory', 'dir', 'root',
6
+ 'repository_path', 'repositorypath', 'repo_path', 'repopath'
7
+ ]);
8
+
9
+ const PROJECT_KEYS = new Set([
10
+ 'project', 'project_name', 'projectname',
11
+ 'repo', 'repository', 'repo_name', 'reponame', 'repository_name', 'repositoryname',
12
+ 'workspace'
13
+ ]);
14
+
15
+ const BRANCH_KEYS = new Set([
16
+ 'branch', 'branch_name', 'branchname', 'ref', 'git_ref', 'gitref'
17
+ ]);
18
+
19
+ const LOOKAROUND_WINDOW = 6;
20
+ const MIN_CONFIDENCE = 2;
21
+
22
+ function safeParseJson(value) {
23
+ if (typeof value !== 'string') return null;
24
+ try {
25
+ return JSON.parse(value);
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function normalizeKey(value) {
32
+ return String(value || '').trim().toLowerCase();
33
+ }
34
+
35
+ function normalizeProjectKey(value) {
36
+ return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]/g, '');
37
+ }
38
+
39
+ function looksLikeFilesystemPath(value, options = {}) {
40
+ const { allowRelative = false } = options;
41
+ if (typeof value !== 'string') return false;
42
+
43
+ const raw = value.trim();
44
+ if (!raw) return false;
45
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
46
+ if (/^[\w.-]+@[\w.-]+:.+/.test(raw)) return false;
47
+ if (/^refs\/(heads|tags|remotes)\//i.test(raw)) return false;
48
+
49
+ const normalized = raw.replace(/\\/g, '/');
50
+ const isWindowsDriveAbs = /^[a-zA-Z]:\//.test(normalized);
51
+ const isUncAbs = normalized.startsWith('//');
52
+ if (
53
+ normalized.startsWith('/')
54
+ || normalized.startsWith('~/')
55
+ || normalized.startsWith('./')
56
+ || normalized.startsWith('../')
57
+ || isWindowsDriveAbs
58
+ || isUncAbs
59
+ ) {
60
+ return true;
61
+ }
62
+
63
+ if (!allowRelative || !normalized.includes('/')) return false;
64
+ if (/^(origin|remotes)\//i.test(normalized)) return false;
65
+
66
+ const parts = normalized.split('/').filter(Boolean);
67
+ if (!parts.length) return false;
68
+ if (parts.length === 2 && !parts[1].includes('.')) return false;
69
+
70
+ return parts.length >= 2;
71
+ }
72
+
73
+ function isInternalProjectTag(tag) {
74
+ if (!tag) return true;
75
+ return tag.startsWith('agent:') || tag.startsWith('claude:');
76
+ }
77
+
78
+ function toDisplayProject(tag) {
79
+ if (!tag || typeof tag !== 'string') return null;
80
+ const value = tag.trim();
81
+ if (!value || isInternalProjectTag(value)) return null;
82
+ return value;
83
+ }
84
+
85
+ function extractProjectFromPath(filePath) {
86
+ if (!filePath || typeof filePath !== 'string') return null;
87
+ const normalized = filePath.trim().replace(/\\/g, '/');
88
+ if (!looksLikeFilesystemPath(normalized, { allowRelative: true })) return null;
89
+ const isWindowsDriveAbs = /^[a-zA-Z]:\//.test(normalized);
90
+ const isUncAbs = normalized.startsWith('//');
91
+ if (!normalized.startsWith('/') && !normalized.startsWith('~') && !isWindowsDriveAbs && !isUncAbs) return null;
92
+
93
+ const rel = normalized
94
+ .replace(/^[a-zA-Z]:\//, '')
95
+ .replace(/^\/\/[^/]+\/[^/]+\//, '')
96
+ .replace(/^\/home\/[^/]+\//, '')
97
+ .replace(/^\/Users\/[^/]+\//, '')
98
+ .replace(/^Users\/[^/]+\//, '')
99
+ .replace(/^~\//, '');
100
+
101
+ const parts = rel.split('/').filter(Boolean);
102
+ if (!parts.length) return null;
103
+
104
+ if (parts[0] === 'Developer' && parts[1]) return parts[1];
105
+ if (parts[0] === 'dev' && parts[1]) return parts[1];
106
+ if (parts[0] === 'code' && parts[1]) return parts[1];
107
+ if (parts[0] === '.openclaw' && parts[1] === 'workspace') return 'workspace';
108
+ if (parts[0] === '.openclaw' && parts[1] === 'agents' && parts[2]) return `agent:${parts[2]}`;
109
+ if (parts[0] === '.claude' && parts[1] === 'projects' && parts[2]) return `claude:${parts[2]}`;
110
+ if (parts[0] === 'Shared') return 'shared';
111
+ return null;
112
+ }
113
+
114
+ function extractSessionProjects(session) {
115
+ const raw = session && session.projects;
116
+ if (!raw) return [];
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(raw);
120
+ } catch {
121
+ return [];
122
+ }
123
+ if (!Array.isArray(parsed)) return [];
124
+ return [...new Set(parsed.map(toDisplayProject).filter(Boolean))];
125
+ }
126
+
127
+ function addCandidate(candidateSet, value) {
128
+ const p = toDisplayProject(value);
129
+ if (p) candidateSet.add(p);
130
+ }
131
+
132
+ function visitObject(value, visitor, key = '') {
133
+ if (!value || typeof value !== 'object') return;
134
+ if (Array.isArray(value)) {
135
+ for (const item of value) visitObject(item, visitor, key);
136
+ return;
137
+ }
138
+ for (const [k, v] of Object.entries(value)) {
139
+ visitor(k, v);
140
+ if (v && typeof v === 'object') visitObject(v, visitor, k);
141
+ }
142
+ }
143
+
144
+ function buildCandidateProjects(session, events) {
145
+ const candidateSet = new Set(extractSessionProjects(session));
146
+
147
+ for (const event of events || []) {
148
+ const args = safeParseJson(event.tool_args);
149
+ if (!args) continue;
150
+
151
+ visitObject(args, (key, value) => {
152
+ if (typeof value !== 'string') return;
153
+
154
+ const keyNorm = normalizeKey(key);
155
+ if (PATH_KEYS.has(keyNorm)) {
156
+ if (!looksLikeFilesystemPath(value, { allowRelative: true })) return;
157
+ addCandidate(candidateSet, extractProjectFromPath(value));
158
+ return;
159
+ }
160
+
161
+ if (looksLikeFilesystemPath(value)) {
162
+ addCandidate(candidateSet, extractProjectFromPath(value));
163
+ }
164
+
165
+ if (PROJECT_KEYS.has(keyNorm)) {
166
+ addCandidate(candidateSet, value);
167
+ }
168
+ });
169
+ }
170
+
171
+ return [...candidateSet];
172
+ }
173
+
174
+ function escapeRegExp(value) {
175
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
176
+ }
177
+
178
+ function countCandidateMentions(text, candidate) {
179
+ if (!text || !candidate) return 0;
180
+ const rx = new RegExp(`(^|[^a-z0-9])${escapeRegExp(candidate.toLowerCase())}([^a-z0-9]|$)`, 'gi');
181
+ let matches = 0;
182
+ let m;
183
+ const haystack = text.toLowerCase();
184
+ while ((m = rx.exec(haystack)) !== null) {
185
+ matches += 1;
186
+ }
187
+ return matches;
188
+ }
189
+
190
+ function buildCandidateLookup(candidates) {
191
+ const byNorm = new Map();
192
+ for (const candidate of candidates) {
193
+ byNorm.set(normalizeProjectKey(candidate), candidate);
194
+ }
195
+ return byNorm;
196
+ }
197
+
198
+ function resolveCandidate(value, candidates, byNorm, options = {}) {
199
+ const { allowPath = true } = options;
200
+ if (!value || typeof value !== 'string') return null;
201
+ const raw = value.trim();
202
+ if (!raw) return null;
203
+
204
+ if (allowPath) {
205
+ const fromPath = toDisplayProject(extractProjectFromPath(raw));
206
+ if (fromPath) {
207
+ const resolved = byNorm.get(normalizeProjectKey(fromPath));
208
+ return resolved || fromPath;
209
+ }
210
+ }
211
+
212
+ const direct = byNorm.get(normalizeProjectKey(raw));
213
+ if (direct) return direct;
214
+
215
+ const lower = raw.toLowerCase();
216
+ for (const candidate of candidates) {
217
+ const rx = new RegExp(`(^|[^a-z0-9])${escapeRegExp(candidate.toLowerCase())}([^a-z0-9]|$)`, 'i');
218
+ if (rx.test(lower)) return candidate;
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ function chooseBestProject(scores) {
225
+ let bestProject = null;
226
+ let bestScore = 0;
227
+ let secondBest = 0;
228
+
229
+ for (const [project, score] of scores.entries()) {
230
+ if (score > bestScore) {
231
+ secondBest = bestScore;
232
+ bestScore = score;
233
+ bestProject = project;
234
+ continue;
235
+ }
236
+ if (score > secondBest) secondBest = score;
237
+ }
238
+
239
+ if (!bestProject || bestScore < MIN_CONFIDENCE) {
240
+ return { project: null, score: 0 };
241
+ }
242
+ if (bestScore === secondBest) {
243
+ return { project: null, score: 0 };
244
+ }
245
+ return { project: bestProject, score: bestScore };
246
+ }
247
+
248
+ function addScore(scores, project, value) {
249
+ if (!project || value <= 0) return;
250
+ scores.set(project, (scores.get(project) || 0) + value);
251
+ }
252
+
253
+ function extractCallBaseId(id) {
254
+ if (!id) return '';
255
+ return String(id).replace(/:(call|result)$/, '');
256
+ }
257
+
258
+ function scoreEvent(event, candidates, byNorm) {
259
+ const scores = new Map();
260
+ const args = safeParseJson(event.tool_args);
261
+
262
+ if (args) {
263
+ visitObject(args, (key, value) => {
264
+ if (typeof value !== 'string') return;
265
+ const keyNorm = normalizeKey(key);
266
+ if (PATH_KEYS.has(keyNorm)) {
267
+ const candidate = resolveCandidate(value, candidates, byNorm, { allowPath: true });
268
+ if (!candidate) return;
269
+ addScore(scores, candidate, 4);
270
+ return;
271
+ }
272
+
273
+ if (PROJECT_KEYS.has(keyNorm)) {
274
+ const candidate = resolveCandidate(value, candidates, byNorm, { allowPath: true });
275
+ if (!candidate) return;
276
+ addScore(scores, candidate, 3);
277
+ return;
278
+ }
279
+
280
+ if (BRANCH_KEYS.has(keyNorm)) {
281
+ const candidate = resolveCandidate(value, candidates, byNorm, { allowPath: false });
282
+ if (!candidate) return;
283
+ addScore(scores, candidate, 2);
284
+ return;
285
+ }
286
+
287
+ if (looksLikeFilesystemPath(value)) {
288
+ const candidate = resolveCandidate(value, candidates, byNorm, { allowPath: true });
289
+ if (!candidate) return;
290
+ addScore(scores, candidate, 3);
291
+ return;
292
+ }
293
+
294
+ const candidate = resolveCandidate(value, candidates, byNorm, { allowPath: false });
295
+ if (!candidate) return;
296
+ addScore(scores, candidate, 1);
297
+ });
298
+ }
299
+
300
+ if (typeof event.content === 'string' && event.content) {
301
+ for (const candidate of candidates) {
302
+ const count = countCandidateMentions(event.content, candidate);
303
+ if (count > 0) addScore(scores, candidate, Math.min(count, 2));
304
+ }
305
+ }
306
+
307
+ if (typeof event.tool_name === 'string' && event.tool_name) {
308
+ const candidate = resolveCandidate(event.tool_name, candidates, byNorm);
309
+ if (candidate) addScore(scores, candidate, 1);
310
+ }
311
+
312
+ return chooseBestProject(scores);
313
+ }
314
+
315
+ function findPrevAttributed(events, idx) {
316
+ for (let i = idx - 1; i >= 0 && idx - i <= LOOKAROUND_WINDOW; i--) {
317
+ if (events[i].project) return events[i].project;
318
+ }
319
+ return null;
320
+ }
321
+
322
+ function findNextAttributed(events, idx) {
323
+ for (let i = idx + 1; i < events.length && i - idx <= LOOKAROUND_WINDOW; i++) {
324
+ if (events[i].project) return events[i].project;
325
+ }
326
+ return null;
327
+ }
328
+
329
+ function attributeSessionEvents(session, events) {
330
+ const list = Array.isArray(events) ? events : [];
331
+ if (!list.length) return { events: [], projectFilters: [] };
332
+
333
+ const candidates = buildCandidateProjects(session, list);
334
+ const byNorm = buildCandidateLookup(candidates);
335
+ const withOrder = list.map((event, idx) => ({ idx, event }));
336
+
337
+ withOrder.sort((a, b) => {
338
+ const ta = Date.parse(a.event.timestamp || 0) || 0;
339
+ const tb = Date.parse(b.event.timestamp || 0) || 0;
340
+ if (ta !== tb) return ta - tb;
341
+ return String(a.event.id || '').localeCompare(String(b.event.id || ''));
342
+ });
343
+
344
+ const callProjectByBase = new Map();
345
+ const attributedOrdered = withOrder.map(({ idx, event }) => {
346
+ const base = {
347
+ ...event,
348
+ project: null,
349
+ project_confidence: 0
350
+ };
351
+
352
+ const scored = scoreEvent(base, candidates, byNorm);
353
+ if (scored.project) {
354
+ base.project = scored.project;
355
+ base.project_confidence = scored.score;
356
+ }
357
+
358
+ if (base.type === 'tool_call' && base.project) {
359
+ const callBaseId = extractCallBaseId(base.id);
360
+ if (callBaseId) callProjectByBase.set(callBaseId, base.project);
361
+ }
362
+
363
+ return { idx, event: base };
364
+ });
365
+
366
+ const orderedEvents = attributedOrdered.map(entry => entry.event);
367
+
368
+ for (let i = 0; i < orderedEvents.length; i++) {
369
+ const current = orderedEvents[i];
370
+ if (current.project) continue;
371
+
372
+ if (current.type === 'tool_result') {
373
+ const callBaseId = extractCallBaseId(current.id);
374
+ const linkedProject = callProjectByBase.get(callBaseId);
375
+ if (linkedProject) {
376
+ current.project = linkedProject;
377
+ current.project_confidence = 3;
378
+ continue;
379
+ }
380
+ }
381
+
382
+ if (current.type !== 'message') continue;
383
+
384
+ const prevProject = findPrevAttributed(orderedEvents, i);
385
+ const nextProject = findNextAttributed(orderedEvents, i);
386
+
387
+ if (prevProject && nextProject && prevProject === nextProject) {
388
+ current.project = prevProject;
389
+ current.project_confidence = 2;
390
+ continue;
391
+ }
392
+
393
+ if (prevProject && !nextProject) {
394
+ current.project = prevProject;
395
+ current.project_confidence = 2;
396
+ }
397
+ }
398
+
399
+ const eventsOut = new Array(list.length);
400
+ for (const entry of attributedOrdered) {
401
+ eventsOut[entry.idx] = entry.event;
402
+ }
403
+
404
+ const counts = new Map();
405
+ for (const event of eventsOut) {
406
+ if (!event.project || event.project_confidence < MIN_CONFIDENCE) {
407
+ event.project = null;
408
+ event.project_confidence = 0;
409
+ continue;
410
+ }
411
+ counts.set(event.project, (counts.get(event.project) || 0) + 1);
412
+ }
413
+
414
+ const projectFilters = [...counts.entries()]
415
+ .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
416
+ .map(([project, eventCount]) => ({ project, eventCount }));
417
+
418
+ return { events: eventsOut, projectFilters };
419
+ }
420
+
421
+ function attributeEventDelta(session, deltaEvents, contextEvents = []) {
422
+ const delta = Array.isArray(deltaEvents) ? deltaEvents : [];
423
+ if (!delta.length) return [];
424
+
425
+ const context = Array.isArray(contextEvents) ? contextEvents : [];
426
+ const merged = [...context, ...delta];
427
+ const attributed = attributeSessionEvents(session, merged).events;
428
+
429
+ const byId = new Map();
430
+ for (const event of attributed) {
431
+ if (!event || !event.id) continue;
432
+ byId.set(event.id, event);
433
+ }
434
+
435
+ return delta.map(event => byId.get(event.id) || { ...event, project: null, project_confidence: 0 });
436
+ }
437
+
438
+ module.exports = {
439
+ attributeSessionEvents,
440
+ attributeEventDelta,
441
+ extractProjectFromPath,
442
+ isInternalProjectTag
443
+ };
package/public/style.css CHANGED
@@ -162,6 +162,8 @@ body {
162
162
  -webkit-font-smoothing: antialiased;
163
163
  -moz-osx-font-smoothing: grayscale;
164
164
  font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
165
+ overscroll-behavior-y: none;
166
+ overflow-x: hidden;
165
167
  }
166
168
 
167
169
  html.cmdk-open,
@@ -563,6 +565,8 @@ body.cmdk-open {
563
565
  cursor: pointer;
564
566
  transition: all var(--duration-normal) var(--ease-out);
565
567
  position: relative;
568
+ overflow: hidden;
569
+ max-width: 100%;
566
570
  }
567
571
 
568
572
  .session-item:hover {
@@ -1550,7 +1554,8 @@ mark {
1550
1554
  bottom: calc(24px + env(safe-area-inset-bottom, 0px));
1551
1555
  left: 50%;
1552
1556
  right: auto;
1553
- transform: translateX(-50%);
1557
+ transform: translateX(-50%) translateZ(0);
1558
+ -webkit-transform: translateX(-50%) translateZ(0);
1554
1559
  width: auto;
1555
1560
  height: auto;
1556
1561
  border-right: none;