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.
- package/delta-attribution-context.js +57 -0
- package/indexer.js +122 -19
- package/insights.js +260 -0
- package/package.json +5 -2
- package/project-attribution.js +443 -0
- package/public/style.css +6 -1
|
@@ -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)
|
|
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
|
-
|
|
67
|
+
addDir({ path: p, agent: 'codex-cli', recursive: true });
|
|
61
68
|
} else {
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
"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;
|