agentacta 2026.3.26 → 2026.3.27

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/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.3.27",
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"
@@ -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
+ };