liteagents 2.4.7 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -8
- package/docs/LONG_TERM_MEMORY.md +449 -0
- package/package.json +2 -2
- package/packages/ampcode/AGENT.md +3 -1
- package/packages/ampcode/agents/context-builder.md +14 -12
- package/packages/ampcode/commands/docs-builder/templates.md +29 -0
- package/packages/ampcode/commands/docs-builder.md +54 -3
- package/packages/ampcode/commands/friction/friction.js +2168 -0
- package/packages/ampcode/commands/friction.md +139 -0
- package/packages/ampcode/commands/remember.md +110 -0
- package/packages/claude/CLAUDE.md +3 -1
- package/packages/claude/agents/context-builder.md +7 -4
- package/packages/claude/commands/friction/friction.js +2168 -0
- package/packages/claude/commands/friction.md +139 -0
- package/packages/claude/commands/remember.md +110 -0
- package/packages/claude/skills/docs-builder/SKILL.md +53 -2
- package/packages/claude/skills/docs-builder/references/templates.md +29 -0
- package/packages/droid/AGENTS.md +3 -1
- package/packages/droid/commands/docs-builder/templates.md +29 -0
- package/packages/droid/commands/docs-builder.md +54 -3
- package/packages/droid/commands/friction/friction.js +2168 -0
- package/packages/droid/commands/friction.md +139 -0
- package/packages/droid/commands/remember.md +110 -0
- package/packages/droid/droids/context-builder.md +15 -13
- package/packages/opencode/AGENTS.md +3 -1
- package/packages/opencode/agent/context-builder.md +14 -12
- package/packages/opencode/command/docs-builder/templates.md +29 -0
- package/packages/opencode/command/docs-builder.md +54 -3
- package/packages/opencode/command/friction/friction.js +2168 -0
- package/packages/opencode/command/friction.md +139 -0
- package/packages/opencode/command/remember.md +110 -0
- package/packages/opencode/opencode.jsonc +8 -0
- package/packages/subagentic-manual.md +33 -15
- package/packages/ampcode/README.md +0 -17
- package/packages/claude/README.md +0 -23
- package/packages/droid/README.md +0 -17
- package/packages/opencode/README.md +0 -17
|
@@ -0,0 +1,2168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Friction analysis pipeline - analyze sessions and extract antigens.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node friction.js <sessions-directory>
|
|
7
|
+
* node friction.js ~/.claude/projects/-home-hamr-PycharmProjects-liteagents/
|
|
8
|
+
*
|
|
9
|
+
* Outputs (all in .claude/friction/):
|
|
10
|
+
* friction_analysis.json - Per-session analysis
|
|
11
|
+
* friction_summary.json - Aggregate stats
|
|
12
|
+
* friction_raw.jsonl - Raw signals
|
|
13
|
+
* antigen_candidates.json - Raw antigen candidates
|
|
14
|
+
* antigen_clusters.json - Clustered antigen patterns
|
|
15
|
+
* antigen_review.md - Clustered review file
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// EMBEDDED CONFIG (from friction_config.json)
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
const CONFIG = {
|
|
28
|
+
weights: {
|
|
29
|
+
exit_error: 1,
|
|
30
|
+
exit_success: 0,
|
|
31
|
+
user_curse: 5,
|
|
32
|
+
user_negation: 0.5,
|
|
33
|
+
user_intervention: 10,
|
|
34
|
+
tool_loop: 6,
|
|
35
|
+
false_success: 8,
|
|
36
|
+
request_interrupted: 2.5,
|
|
37
|
+
long_silence: 0.5,
|
|
38
|
+
repeated_question: 1,
|
|
39
|
+
compaction: 0.5,
|
|
40
|
+
interrupt_cascade: 5,
|
|
41
|
+
rapid_exit: 6,
|
|
42
|
+
no_resolution: 8,
|
|
43
|
+
session_abandoned: 10,
|
|
44
|
+
sibling_tool_error: 0.5,
|
|
45
|
+
},
|
|
46
|
+
thresholds: {
|
|
47
|
+
friction_peak: 15,
|
|
48
|
+
intervention_predictability: 0.50,
|
|
49
|
+
signal_noise_ratio: 1.5,
|
|
50
|
+
long_silence_minutes: 10,
|
|
51
|
+
},
|
|
52
|
+
notes: {
|
|
53
|
+
model: 'Threshold monitor - friction accumulates, no subtraction',
|
|
54
|
+
exit_error: 'Single error = noise (+1)',
|
|
55
|
+
exit_success: 'Zero weight - tracked as momentum only',
|
|
56
|
+
user_intervention: 'Gold signal - user gave up (/stash)',
|
|
57
|
+
user_curse: 'Reliable frustration indicator',
|
|
58
|
+
false_success: 'Trust violation - LLM claimed success but failed',
|
|
59
|
+
tool_loop: 'Agent stuck - same tool 3x',
|
|
60
|
+
user_negation: 'Low weight - still noisy after filtering',
|
|
61
|
+
request_interrupted: 'User hit Ctrl+C / Escape - impatience signal',
|
|
62
|
+
long_silence: 'User walked away >10 min - disengagement',
|
|
63
|
+
repeated_question: 'User asked same thing twice - confusion/frustration',
|
|
64
|
+
compaction: 'Context overflow - memory loss indicator',
|
|
65
|
+
interrupt_cascade: 'Multiple ESC/Ctrl+C within 60s - escalating frustration',
|
|
66
|
+
rapid_exit: 'Quick quit (<3 turns) after error - immediate rejection',
|
|
67
|
+
no_resolution: 'Errors without success - unresolved session',
|
|
68
|
+
session_abandoned: 'High friction at end, no clean exit - gave up silently',
|
|
69
|
+
sibling_tool_error: 'SDK cascade - parallel tool batch canceled when one fails',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// UTILITY FUNCTIONS
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
function loadConfig() {
|
|
78
|
+
return CONFIG;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseISODate(s) {
|
|
82
|
+
if (!s) return null;
|
|
83
|
+
try {
|
|
84
|
+
return new Date(s.replace('Z', '+00:00'));
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatDuration(minutes) {
|
|
91
|
+
if (minutes < 60) return `${minutes}m`;
|
|
92
|
+
const hours = Math.floor(minutes / 60);
|
|
93
|
+
const mins = minutes % 60;
|
|
94
|
+
return mins ? `${hours}h${mins}m` : `${hours}h`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Glob-like file listing. Returns files matching a pattern in a directory.
|
|
99
|
+
* Supports simple *.ext matching and recursive **\/*.ext matching.
|
|
100
|
+
*/
|
|
101
|
+
function globFiles(dir, pattern, recursive) {
|
|
102
|
+
const results = [];
|
|
103
|
+
if (!fs.existsSync(dir)) return results;
|
|
104
|
+
|
|
105
|
+
const ext = pattern.replace('*', '');
|
|
106
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const fullPath = path.join(dir, entry.name);
|
|
109
|
+
if (entry.isFile() && entry.name.endsWith(ext)) {
|
|
110
|
+
results.push(fullPath);
|
|
111
|
+
} else if (recursive && entry.isDirectory()) {
|
|
112
|
+
results.push(...globFiles(fullPath, pattern, true));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function listDirs(dir) {
|
|
119
|
+
if (!fs.existsSync(dir)) return [];
|
|
120
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
121
|
+
.filter(e => e.isDirectory())
|
|
122
|
+
.map(e => path.join(dir, e.name));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// =============================================================================
|
|
126
|
+
// FRICTION ANALYZE - derive_session_name
|
|
127
|
+
// =============================================================================
|
|
128
|
+
|
|
129
|
+
function deriveSessionName(sessionFile, metadata) {
|
|
130
|
+
const parent = path.basename(path.dirname(sessionFile));
|
|
131
|
+
|
|
132
|
+
let project;
|
|
133
|
+
if (parent.startsWith('-')) {
|
|
134
|
+
const prefixes = [
|
|
135
|
+
'-home-hamr-PycharmProjects-',
|
|
136
|
+
'-home-hamr-Documents-PycharmProjects-',
|
|
137
|
+
'-home-hamr-',
|
|
138
|
+
'-home-',
|
|
139
|
+
'-',
|
|
140
|
+
];
|
|
141
|
+
let found = false;
|
|
142
|
+
for (const prefix of prefixes) {
|
|
143
|
+
if (parent.startsWith(prefix)) {
|
|
144
|
+
project = parent.slice(prefix.length);
|
|
145
|
+
found = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!found) {
|
|
150
|
+
project = parent.slice(1);
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
project = parent;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let dateStr = '';
|
|
157
|
+
if (metadata.started_at) {
|
|
158
|
+
try {
|
|
159
|
+
const dt = new Date(metadata.started_at.replace('Z', '+00:00'));
|
|
160
|
+
if (!isNaN(dt.getTime())) {
|
|
161
|
+
const mm = String(dt.getMonth() + 1).padStart(2, '0');
|
|
162
|
+
const dd = String(dt.getDate()).padStart(2, '0');
|
|
163
|
+
const hh = String(dt.getHours()).padStart(2, '0');
|
|
164
|
+
const mi = String(dt.getMinutes()).padStart(2, '0');
|
|
165
|
+
dateStr = `${mm}${dd}-${hh}${mi}`;
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// pass
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!dateStr) {
|
|
173
|
+
try {
|
|
174
|
+
const stat = fs.statSync(sessionFile);
|
|
175
|
+
const mtime = stat.mtime;
|
|
176
|
+
const mm = String(mtime.getMonth() + 1).padStart(2, '0');
|
|
177
|
+
const dd = String(mtime.getDate()).padStart(2, '0');
|
|
178
|
+
const hh = String(mtime.getHours()).padStart(2, '0');
|
|
179
|
+
const mi = String(mtime.getMinutes()).padStart(2, '0');
|
|
180
|
+
dateStr = `${mm}${dd}-${hh}${mi}`;
|
|
181
|
+
} catch {
|
|
182
|
+
dateStr = 'unknown';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const shortId = path.basename(sessionFile, '.jsonl').slice(0, 8);
|
|
187
|
+
return `${project}/${dateStr}-${shortId}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// FRICTION ANALYZE - extractToolNameFromResult
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
function extractToolNameFromResult(result) {
|
|
195
|
+
const match = result.match(/●\s+(\w+)\(/);
|
|
196
|
+
return match ? match[1] : 'unknown';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// =============================================================================
|
|
200
|
+
// FRICTION ANALYZE - extract_signals
|
|
201
|
+
// =============================================================================
|
|
202
|
+
|
|
203
|
+
function extractSignals(sessionFile) {
|
|
204
|
+
const signals = [];
|
|
205
|
+
let llmClaimedSuccess = false;
|
|
206
|
+
const toolHistory = [];
|
|
207
|
+
const metadata = {};
|
|
208
|
+
|
|
209
|
+
const raw = fs.readFileSync(sessionFile, 'utf-8');
|
|
210
|
+
const events = raw.split('\n')
|
|
211
|
+
.filter(line => line.trim())
|
|
212
|
+
.map(line => JSON.parse(line));
|
|
213
|
+
|
|
214
|
+
let turnCount = 0;
|
|
215
|
+
const userMessages = [];
|
|
216
|
+
let prevUserTs = null;
|
|
217
|
+
|
|
218
|
+
for (const event of events) {
|
|
219
|
+
if ('gitBranch' in event) metadata.git_branch = event.gitBranch;
|
|
220
|
+
if ('cwd' in event) metadata.cwd = event.cwd;
|
|
221
|
+
if ('timestamp' in event) {
|
|
222
|
+
if (!('started_at' in metadata)) metadata.started_at = event.timestamp;
|
|
223
|
+
metadata.ended_at = event.timestamp;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (event.type === 'user') {
|
|
227
|
+
const content = (event.message || {}).content;
|
|
228
|
+
const ts = event.timestamp || '';
|
|
229
|
+
|
|
230
|
+
if (typeof content === 'string') {
|
|
231
|
+
turnCount++;
|
|
232
|
+
const msgKey = content.slice(0, 100).toLowerCase().trim();
|
|
233
|
+
userMessages.push([ts, msgKey]);
|
|
234
|
+
|
|
235
|
+
if (prevUserTs && ts) {
|
|
236
|
+
try {
|
|
237
|
+
const t1 = new Date(prevUserTs.replace('Z', '+00:00'));
|
|
238
|
+
const t2 = new Date(ts.replace('Z', '+00:00'));
|
|
239
|
+
const gapMin = (t2 - t1) / 60000;
|
|
240
|
+
if (gapMin > 10) {
|
|
241
|
+
signals.push({
|
|
242
|
+
ts,
|
|
243
|
+
source: 'user',
|
|
244
|
+
signal: 'long_silence',
|
|
245
|
+
details: `${Math.round(gapMin)} min gap`,
|
|
246
|
+
gap_minutes: gapMin,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// pass
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
prevUserTs = ts;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Check for compaction/summary events
|
|
258
|
+
if (event.type === 'summary') {
|
|
259
|
+
const summaryText = event.summary || '';
|
|
260
|
+
if (summaryText && !summaryText.toLowerCase().includes('exited')) {
|
|
261
|
+
const ts = event.timestamp || metadata.ended_at || '';
|
|
262
|
+
signals.push({
|
|
263
|
+
ts,
|
|
264
|
+
source: 'system',
|
|
265
|
+
signal: 'compaction',
|
|
266
|
+
details: summaryText.slice(0, 50),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
metadata.turn_count = turnCount;
|
|
273
|
+
const isInteractive = turnCount > 1;
|
|
274
|
+
|
|
275
|
+
// Detect repeated questions
|
|
276
|
+
const seenMessages = {};
|
|
277
|
+
for (const [ts, msgKey] of userMessages) {
|
|
278
|
+
if (msgKey in seenMessages && msgKey.length > 20) {
|
|
279
|
+
signals.push({
|
|
280
|
+
ts,
|
|
281
|
+
source: 'user',
|
|
282
|
+
signal: 'repeated_question',
|
|
283
|
+
details: msgKey.slice(0, 50),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
seenMessages[msgKey] = ts;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Extract signals from events
|
|
290
|
+
for (const event of events) {
|
|
291
|
+
const ts = event.timestamp || '';
|
|
292
|
+
|
|
293
|
+
let content = null;
|
|
294
|
+
if (event.type === 'user') {
|
|
295
|
+
content = (event.message || {}).content;
|
|
296
|
+
} else if (event.type === 'progress') {
|
|
297
|
+
const data = event.data || {};
|
|
298
|
+
const message = data.message || {};
|
|
299
|
+
if (message.type === 'user') {
|
|
300
|
+
content = (message.message || {}).content;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (content !== null && content !== undefined) {
|
|
305
|
+
// Handle list of content blocks
|
|
306
|
+
if (Array.isArray(content)) {
|
|
307
|
+
let foundText = false;
|
|
308
|
+
for (const block of content) {
|
|
309
|
+
if (typeof block === 'object' && block !== null && block.type === 'tool_result') {
|
|
310
|
+
const result = String(block.content || '');
|
|
311
|
+
|
|
312
|
+
if (/Exit code 137|Request interrupted|interrupted by user/i.test(result)) {
|
|
313
|
+
signals.push({
|
|
314
|
+
ts,
|
|
315
|
+
source: 'user',
|
|
316
|
+
signal: 'request_interrupted',
|
|
317
|
+
details: result.slice(0, 100),
|
|
318
|
+
});
|
|
319
|
+
} else if (/<tool_use_error>Sibling tool call errored<\/tool_use_error>/i.test(result)) {
|
|
320
|
+
signals.push({
|
|
321
|
+
ts,
|
|
322
|
+
source: 'system',
|
|
323
|
+
signal: 'sibling_tool_error',
|
|
324
|
+
details: result.slice(0, 100),
|
|
325
|
+
tool_name: extractToolNameFromResult(result),
|
|
326
|
+
});
|
|
327
|
+
} else if (
|
|
328
|
+
(/Exit code [1-9]/.test(result) && !result.includes('Exit code 137')) ||
|
|
329
|
+
/Traceback \(most recent|CalledProcessError/.test(result)
|
|
330
|
+
) {
|
|
331
|
+
signals.push({
|
|
332
|
+
ts,
|
|
333
|
+
source: 'tool',
|
|
334
|
+
signal: 'exit_error',
|
|
335
|
+
details: result.slice(0, 100),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (llmClaimedSuccess) {
|
|
339
|
+
signals.push({
|
|
340
|
+
ts,
|
|
341
|
+
source: 'llm',
|
|
342
|
+
signal: 'false_success',
|
|
343
|
+
details: 'LLM claimed success but tool failed',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
llmClaimedSuccess = false;
|
|
347
|
+
} else if (/Exit code 0/.test(result)) {
|
|
348
|
+
signals.push({
|
|
349
|
+
ts,
|
|
350
|
+
source: 'tool',
|
|
351
|
+
signal: 'exit_success',
|
|
352
|
+
details: '',
|
|
353
|
+
});
|
|
354
|
+
llmClaimedSuccess = false;
|
|
355
|
+
}
|
|
356
|
+
} else if (typeof block === 'object' && block !== null && block.type === 'text') {
|
|
357
|
+
const text = block.text || '';
|
|
358
|
+
if (text) {
|
|
359
|
+
content = text; // Fall through to user message handling below
|
|
360
|
+
foundText = true;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (!foundText) {
|
|
366
|
+
content = null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Handle single dict tool_result (legacy format)
|
|
371
|
+
if (typeof content === 'object' && content !== null && !Array.isArray(content) && content.type === 'tool_result') {
|
|
372
|
+
const result = String(content.content || '');
|
|
373
|
+
|
|
374
|
+
if (/Exit code [1-9]|Traceback \(most recent|CalledProcessError/.test(result)) {
|
|
375
|
+
signals.push({
|
|
376
|
+
ts,
|
|
377
|
+
source: 'tool',
|
|
378
|
+
signal: 'exit_error',
|
|
379
|
+
details: result.slice(0, 100),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
if (llmClaimedSuccess) {
|
|
383
|
+
signals.push({
|
|
384
|
+
ts,
|
|
385
|
+
source: 'llm',
|
|
386
|
+
signal: 'false_success',
|
|
387
|
+
details: 'LLM claimed success but tool failed',
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
} else if (/Exit code 0/.test(result)) {
|
|
391
|
+
signals.push({
|
|
392
|
+
ts,
|
|
393
|
+
source: 'tool',
|
|
394
|
+
signal: 'exit_success',
|
|
395
|
+
details: '',
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
llmClaimedSuccess = false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// User messages (GOLD)
|
|
403
|
+
if (typeof content === 'string') {
|
|
404
|
+
if (content.toLowerCase().includes('/stash')) {
|
|
405
|
+
signals.push({
|
|
406
|
+
ts,
|
|
407
|
+
source: 'user',
|
|
408
|
+
signal: 'user_intervention',
|
|
409
|
+
details: 'stash',
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (/Request interrupted|interrupted by user/i.test(content)) {
|
|
414
|
+
signals.push({
|
|
415
|
+
ts,
|
|
416
|
+
source: 'user',
|
|
417
|
+
signal: 'request_interrupted',
|
|
418
|
+
details: content.slice(0, 100),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (/\b(fuck|shit|damn)\b/i.test(content)) {
|
|
423
|
+
signals.push({
|
|
424
|
+
ts,
|
|
425
|
+
source: 'user',
|
|
426
|
+
signal: 'user_curse',
|
|
427
|
+
details: content.slice(0, 50),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (isInteractive && /\b(no|didn't work|still broken)\b/i.test(content)) {
|
|
432
|
+
signals.push({
|
|
433
|
+
ts,
|
|
434
|
+
source: 'user',
|
|
435
|
+
signal: 'user_negation',
|
|
436
|
+
details: content.slice(0, 50),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Assistant messages (LLM patterns)
|
|
443
|
+
if (event.type === 'assistant') {
|
|
444
|
+
const assistantContent = (event.message || {}).content || [];
|
|
445
|
+
|
|
446
|
+
if (Array.isArray(assistantContent)) {
|
|
447
|
+
// Success claims
|
|
448
|
+
const text = assistantContent
|
|
449
|
+
.filter(b => b.type === 'text')
|
|
450
|
+
.map(b => b.text || '')
|
|
451
|
+
.join(' ');
|
|
452
|
+
if (/\b(done|complete|success|✅)\b/i.test(text)) {
|
|
453
|
+
llmClaimedSuccess = true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Tool loops
|
|
457
|
+
for (const block of assistantContent) {
|
|
458
|
+
if (block.type === 'tool_use') {
|
|
459
|
+
const toolName = block.name;
|
|
460
|
+
const sig = JSON.stringify([toolName, JSON.stringify(block.input || {})]);
|
|
461
|
+
toolHistory.push(sig);
|
|
462
|
+
|
|
463
|
+
let count = 0;
|
|
464
|
+
for (const h of toolHistory) {
|
|
465
|
+
if (h === sig) count++;
|
|
466
|
+
}
|
|
467
|
+
if (count >= 3) {
|
|
468
|
+
signals.push({
|
|
469
|
+
ts,
|
|
470
|
+
source: 'llm',
|
|
471
|
+
signal: 'tool_loop',
|
|
472
|
+
details: `${toolName} called ${count}x`,
|
|
473
|
+
tool: toolName,
|
|
474
|
+
loop_count: count,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// === POST-PROCESSING SIGNALS (session-level analysis) ===
|
|
484
|
+
|
|
485
|
+
// 0. Deduplicate sibling_tool_error batches
|
|
486
|
+
const deduplicatedSignals = [];
|
|
487
|
+
let siblingBatchStart = null;
|
|
488
|
+
let siblingBatchCount = 0;
|
|
489
|
+
let siblingBatchTools = [];
|
|
490
|
+
|
|
491
|
+
for (const sig of signals) {
|
|
492
|
+
if (sig.signal === 'sibling_tool_error') {
|
|
493
|
+
const currentTs = sig.ts;
|
|
494
|
+
|
|
495
|
+
if (siblingBatchStart === null || currentTs !== siblingBatchStart) {
|
|
496
|
+
if (siblingBatchStart !== null) {
|
|
497
|
+
deduplicatedSignals.push({
|
|
498
|
+
ts: siblingBatchStart,
|
|
499
|
+
source: 'system',
|
|
500
|
+
signal: 'sibling_tool_error',
|
|
501
|
+
details: `${siblingBatchCount} sibling errors in parallel batch`,
|
|
502
|
+
batch_size: siblingBatchCount,
|
|
503
|
+
tools_affected: siblingBatchTools,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
siblingBatchStart = currentTs;
|
|
507
|
+
siblingBatchCount = 1;
|
|
508
|
+
siblingBatchTools = [sig.tool_name || 'unknown'];
|
|
509
|
+
} else {
|
|
510
|
+
siblingBatchCount++;
|
|
511
|
+
siblingBatchTools.push(sig.tool_name || 'unknown');
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
if (siblingBatchStart !== null) {
|
|
515
|
+
deduplicatedSignals.push({
|
|
516
|
+
ts: siblingBatchStart,
|
|
517
|
+
source: 'system',
|
|
518
|
+
signal: 'sibling_tool_error',
|
|
519
|
+
details: `${siblingBatchCount} sibling errors in parallel batch`,
|
|
520
|
+
batch_size: siblingBatchCount,
|
|
521
|
+
tools_affected: siblingBatchTools,
|
|
522
|
+
});
|
|
523
|
+
siblingBatchStart = null;
|
|
524
|
+
siblingBatchCount = 0;
|
|
525
|
+
siblingBatchTools = [];
|
|
526
|
+
}
|
|
527
|
+
deduplicatedSignals.push(sig);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (siblingBatchStart !== null) {
|
|
532
|
+
deduplicatedSignals.push({
|
|
533
|
+
ts: siblingBatchStart,
|
|
534
|
+
source: 'system',
|
|
535
|
+
signal: 'sibling_tool_error',
|
|
536
|
+
details: `${siblingBatchCount} sibling errors in parallel batch`,
|
|
537
|
+
batch_size: siblingBatchCount,
|
|
538
|
+
tools_affected: siblingBatchTools,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Replace signals with deduplicated version
|
|
543
|
+
const finalSignals = deduplicatedSignals;
|
|
544
|
+
|
|
545
|
+
// 1. interrupt_cascade: 2+ request_interrupted within 60s
|
|
546
|
+
const interruptTimes = [];
|
|
547
|
+
for (const sig of finalSignals) {
|
|
548
|
+
if (sig.signal === 'request_interrupted' && sig.ts) {
|
|
549
|
+
try {
|
|
550
|
+
const t = new Date(sig.ts.replace('Z', '+00:00'));
|
|
551
|
+
if (!isNaN(t.getTime())) interruptTimes.push(t);
|
|
552
|
+
} catch {
|
|
553
|
+
// pass
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
for (let i = 1; i < interruptTimes.length; i++) {
|
|
559
|
+
const gapSec = (interruptTimes[i] - interruptTimes[i - 1]) / 1000;
|
|
560
|
+
if (gapSec <= 60) {
|
|
561
|
+
finalSignals.push({
|
|
562
|
+
ts: interruptTimes[i].toISOString(),
|
|
563
|
+
source: 'user',
|
|
564
|
+
signal: 'interrupt_cascade',
|
|
565
|
+
details: `${Math.round(gapSec)}s between interrupts`,
|
|
566
|
+
gap_seconds: gapSec,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 2. Analyze signal sequence for session-end patterns
|
|
572
|
+
const hasErrors = finalSignals.some(s => s.signal === 'exit_error');
|
|
573
|
+
const hasSuccess = finalSignals.some(s => s.signal === 'exit_success');
|
|
574
|
+
const hasIntervention = finalSignals.some(s => s.signal === 'user_intervention');
|
|
575
|
+
|
|
576
|
+
const last5Signals = finalSignals.slice(-5).map(s => s.signal);
|
|
577
|
+
|
|
578
|
+
const frictionWeights = {
|
|
579
|
+
exit_error: 1,
|
|
580
|
+
user_curse: 5,
|
|
581
|
+
user_negation: 1,
|
|
582
|
+
tool_loop: 6,
|
|
583
|
+
false_success: 8,
|
|
584
|
+
request_interrupted: 4,
|
|
585
|
+
interrupt_cascade: 7,
|
|
586
|
+
repeated_question: 3,
|
|
587
|
+
};
|
|
588
|
+
let last5Friction = 0;
|
|
589
|
+
for (const s of last5Signals) {
|
|
590
|
+
last5Friction += frictionWeights[s] || 0;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 3. rapid_exit
|
|
594
|
+
if (turnCount <= 3 && turnCount > 0) {
|
|
595
|
+
if (last5Signals.length > 0 &&
|
|
596
|
+
(last5Signals[last5Signals.length - 1] === 'exit_error' ||
|
|
597
|
+
last5Signals[last5Signals.length - 1] === 'request_interrupted')) {
|
|
598
|
+
finalSignals.push({
|
|
599
|
+
ts: metadata.ended_at || '',
|
|
600
|
+
source: 'session',
|
|
601
|
+
signal: 'rapid_exit',
|
|
602
|
+
details: `${turnCount} turns, ended with ${last5Signals[last5Signals.length - 1]}`,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 4. no_resolution
|
|
608
|
+
if (hasErrors && !hasSuccess && !hasIntervention && turnCount > 1) {
|
|
609
|
+
const errorCount = finalSignals.filter(s => s.signal === 'exit_error').length;
|
|
610
|
+
finalSignals.push({
|
|
611
|
+
ts: metadata.ended_at || '',
|
|
612
|
+
source: 'session',
|
|
613
|
+
signal: 'no_resolution',
|
|
614
|
+
details: `${errorCount} errors, no success`,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// 5. session_abandoned
|
|
619
|
+
if (last5Friction >= 8 && !hasSuccess && !hasIntervention && turnCount > 2) {
|
|
620
|
+
finalSignals.push({
|
|
621
|
+
ts: metadata.ended_at || '',
|
|
622
|
+
source: 'session',
|
|
623
|
+
signal: 'session_abandoned',
|
|
624
|
+
details: `friction ${last5Friction} in last 5 signals, no resolution`,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return [finalSignals, metadata];
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// =============================================================================
|
|
632
|
+
// FRICTION ANALYZE - analyze_session
|
|
633
|
+
// =============================================================================
|
|
634
|
+
|
|
635
|
+
function analyzeSession(sessionId, signals, metadata, config) {
|
|
636
|
+
const weights = config.weights;
|
|
637
|
+
|
|
638
|
+
const bySource = {};
|
|
639
|
+
|
|
640
|
+
function getSource(source) {
|
|
641
|
+
if (!bySource[source]) {
|
|
642
|
+
bySource[source] = {
|
|
643
|
+
total_friction: 0,
|
|
644
|
+
signal_count: 0,
|
|
645
|
+
signals: {},
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
return bySource[source];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function getSignalData(sourceObj, signalType) {
|
|
652
|
+
if (!sourceObj.signals[signalType]) {
|
|
653
|
+
sourceObj.signals[signalType] = { count: 0, total_weight: 0 };
|
|
654
|
+
}
|
|
655
|
+
return sourceObj.signals[signalType];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const frictionTrajectory = [];
|
|
659
|
+
let runningFriction = 0;
|
|
660
|
+
let momentum = 0;
|
|
661
|
+
let errorCount = 0;
|
|
662
|
+
let successCount = 0;
|
|
663
|
+
|
|
664
|
+
for (const sig of signals) {
|
|
665
|
+
const source = sig.source;
|
|
666
|
+
const signalType = sig.signal;
|
|
667
|
+
const weight = weights[signalType] || 0;
|
|
668
|
+
|
|
669
|
+
if (signalType === 'exit_success') {
|
|
670
|
+
successCount++;
|
|
671
|
+
momentum++;
|
|
672
|
+
} else if (signalType === 'exit_error') {
|
|
673
|
+
errorCount++;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const sourceObj = getSource(source);
|
|
677
|
+
sourceObj.total_friction += weight;
|
|
678
|
+
sourceObj.signal_count += 1;
|
|
679
|
+
const sigData = getSignalData(sourceObj, signalType);
|
|
680
|
+
sigData.count += 1;
|
|
681
|
+
sigData.total_weight += weight;
|
|
682
|
+
|
|
683
|
+
if (weight > 0) {
|
|
684
|
+
runningFriction += weight;
|
|
685
|
+
frictionTrajectory.push(runningFriction);
|
|
686
|
+
} else if (frictionTrajectory.length > 0) {
|
|
687
|
+
frictionTrajectory.push(frictionTrajectory[frictionTrajectory.length - 1]);
|
|
688
|
+
} else {
|
|
689
|
+
frictionTrajectory.push(0);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Detect patterns
|
|
694
|
+
const patterns = [];
|
|
695
|
+
const peakFriction = frictionTrajectory.length > 0 ? Math.max(...frictionTrajectory) : 0;
|
|
696
|
+
const finalFriction = frictionTrajectory.length > 0 ? frictionTrajectory[frictionTrajectory.length - 1] : 0;
|
|
697
|
+
|
|
698
|
+
if (peakFriction >= config.thresholds.friction_peak && finalFriction < 5) {
|
|
699
|
+
patterns.push({
|
|
700
|
+
type: 'learning_moment',
|
|
701
|
+
friction_before: peakFriction,
|
|
702
|
+
friction_after: finalFriction,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Sequence detection
|
|
707
|
+
const signalSeq = signals.map(s => s.signal);
|
|
708
|
+
for (let i = 0; i < signalSeq.length - 2; i++) {
|
|
709
|
+
const seq = [signalSeq[i], signalSeq[i + 1], signalSeq[i + 2]];
|
|
710
|
+
if (seq[0] === 'exit_error' && seq[1] === 'false_success' && seq[2] === 'user_curse') {
|
|
711
|
+
patterns.push({ type: 'false_success_loop', sequence: seq, count: 1 });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Calculate duration
|
|
716
|
+
let durationMin = 0;
|
|
717
|
+
if (metadata.started_at && metadata.ended_at) {
|
|
718
|
+
try {
|
|
719
|
+
const start = new Date(metadata.started_at.replace('Z', '+00:00'));
|
|
720
|
+
const end = new Date(metadata.ended_at.replace('Z', '+00:00'));
|
|
721
|
+
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
|
|
722
|
+
durationMin = Math.floor((end - start) / 60000);
|
|
723
|
+
}
|
|
724
|
+
} catch {
|
|
725
|
+
// pass
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
metadata.duration_min = durationMin;
|
|
729
|
+
|
|
730
|
+
// Error ratio
|
|
731
|
+
const totalToolRuns = successCount + errorCount;
|
|
732
|
+
const errorRatio = totalToolRuns > 0 ? errorCount / totalToolRuns : 0;
|
|
733
|
+
|
|
734
|
+
// Session quality assessment
|
|
735
|
+
const hasIntervention = signals.some(s => s.signal === 'user_intervention');
|
|
736
|
+
const hasAbandoned = signals.some(s => s.signal === 'session_abandoned');
|
|
737
|
+
const hasCurse = signals.some(s => s.signal === 'user_curse');
|
|
738
|
+
const hasFalseSuccess = signals.some(s => s.signal === 'false_success');
|
|
739
|
+
|
|
740
|
+
let quality;
|
|
741
|
+
if (hasIntervention || hasAbandoned) {
|
|
742
|
+
quality = 'BAD';
|
|
743
|
+
} else if (hasCurse || hasFalseSuccess) {
|
|
744
|
+
quality = 'FRICTION';
|
|
745
|
+
} else if (peakFriction >= config.thresholds.friction_peak) {
|
|
746
|
+
quality = 'ROUGH';
|
|
747
|
+
} else if (errorRatio > 0.5 && errorCount > 3) {
|
|
748
|
+
quality = 'ROUGH';
|
|
749
|
+
} else if ((metadata.turn_count || 0) <= 1) {
|
|
750
|
+
quality = 'ONE-SHOT';
|
|
751
|
+
} else {
|
|
752
|
+
quality = 'OK';
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
session_id: sessionId,
|
|
757
|
+
session_metadata: metadata,
|
|
758
|
+
friction_summary: {
|
|
759
|
+
peak: peakFriction,
|
|
760
|
+
final: finalFriction,
|
|
761
|
+
total_signals: signals.length,
|
|
762
|
+
learning_moments: patterns.filter(p => p.type === 'learning_moment').length,
|
|
763
|
+
},
|
|
764
|
+
momentum: {
|
|
765
|
+
success_count: successCount,
|
|
766
|
+
error_count: errorCount,
|
|
767
|
+
error_ratio: Math.round(errorRatio * 100) / 100,
|
|
768
|
+
},
|
|
769
|
+
quality,
|
|
770
|
+
by_source: bySource,
|
|
771
|
+
friction_trajectory: frictionTrajectory,
|
|
772
|
+
patterns_detected: patterns,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// =============================================================================
|
|
777
|
+
// FRICTION ANALYZE - aggregate_sessions
|
|
778
|
+
// =============================================================================
|
|
779
|
+
|
|
780
|
+
function aggregateSessions(analyses, config) {
|
|
781
|
+
const aggregateBySource = {};
|
|
782
|
+
const byProject = {};
|
|
783
|
+
const allPatterns = [];
|
|
784
|
+
let highFrictionCount = 0;
|
|
785
|
+
let interventionCount = 0;
|
|
786
|
+
|
|
787
|
+
function getAggSource(source) {
|
|
788
|
+
if (!aggregateBySource[source]) {
|
|
789
|
+
aggregateBySource[source] = {
|
|
790
|
+
sessions_with_signals: 0,
|
|
791
|
+
total_friction: 0,
|
|
792
|
+
top_signals: {},
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
return aggregateBySource[source];
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function getProject(proj) {
|
|
799
|
+
if (!byProject[proj]) {
|
|
800
|
+
byProject[proj] = {
|
|
801
|
+
total_sessions: 0,
|
|
802
|
+
interactive_sessions: 0,
|
|
803
|
+
bad_sessions: 0,
|
|
804
|
+
total_friction: 0,
|
|
805
|
+
total_duration_min: 0,
|
|
806
|
+
total_turns: 0,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return byProject[proj];
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
for (const analysis of analyses) {
|
|
813
|
+
const peak = analysis.friction_summary.peak;
|
|
814
|
+
const quality = analysis.quality || 'UNKNOWN';
|
|
815
|
+
const sessionId = analysis.session_id || '';
|
|
816
|
+
const metadata = analysis.session_metadata || {};
|
|
817
|
+
|
|
818
|
+
const project = sessionId.includes('/') ? sessionId.split('/')[0] : 'unknown';
|
|
819
|
+
|
|
820
|
+
const proj = getProject(project);
|
|
821
|
+
proj.total_sessions++;
|
|
822
|
+
if ((metadata.turn_count || 0) > 1) proj.interactive_sessions++;
|
|
823
|
+
if (quality === 'BAD') proj.bad_sessions++;
|
|
824
|
+
proj.total_friction += peak;
|
|
825
|
+
proj.total_duration_min += metadata.duration_min || 0;
|
|
826
|
+
proj.total_turns += metadata.turn_count || 0;
|
|
827
|
+
|
|
828
|
+
if (peak >= config.thresholds.friction_peak) highFrictionCount++;
|
|
829
|
+
|
|
830
|
+
for (const [source, data] of Object.entries(analysis.by_source || {})) {
|
|
831
|
+
const aggSrc = getAggSource(source);
|
|
832
|
+
aggSrc.sessions_with_signals++;
|
|
833
|
+
aggSrc.total_friction += data.total_friction;
|
|
834
|
+
|
|
835
|
+
for (const [signalType, signalData] of Object.entries(data.signals || {})) {
|
|
836
|
+
aggSrc.top_signals[signalType] = (aggSrc.top_signals[signalType] || 0) + signalData.count;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
allPatterns.push(...analysis.patterns_detected);
|
|
841
|
+
|
|
842
|
+
// Interventions
|
|
843
|
+
if (analysis.by_source.user && analysis.by_source.user.signals.user_intervention) {
|
|
844
|
+
interventionCount++;
|
|
845
|
+
}
|
|
846
|
+
if (analysis.by_source.session && analysis.by_source.session.signals.session_abandoned) {
|
|
847
|
+
interventionCount++;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Calculate metrics
|
|
852
|
+
const interventionPred = highFrictionCount > 0 ? interventionCount / highFrictionCount : 0;
|
|
853
|
+
|
|
854
|
+
const totalObjective = ((aggregateBySource.tool || {}).total_friction || 0) +
|
|
855
|
+
((aggregateBySource.user || {}).total_friction || 0);
|
|
856
|
+
const totalLlm = (aggregateBySource.llm || {}).total_friction || 1;
|
|
857
|
+
const snr = totalLlm !== 0 ? Math.abs(totalObjective / totalLlm) : 0;
|
|
858
|
+
|
|
859
|
+
// Verdict
|
|
860
|
+
const thresholds = config.thresholds;
|
|
861
|
+
const reasons = [];
|
|
862
|
+
const actions = [];
|
|
863
|
+
let status;
|
|
864
|
+
|
|
865
|
+
if (snr < thresholds.signal_noise_ratio) {
|
|
866
|
+
status = 'BLOAT';
|
|
867
|
+
reasons.push(`Signal/noise ratio: ${snr.toFixed(1)} (threshold: ${thresholds.signal_noise_ratio})`);
|
|
868
|
+
} else if (interventionPred < thresholds.intervention_predictability) {
|
|
869
|
+
status = 'INCONCLUSIVE';
|
|
870
|
+
reasons.push(`Intervention predictability: ${Math.round(interventionPred * 100)}% (threshold: ${Math.round(thresholds.intervention_predictability * 100)}%)`);
|
|
871
|
+
} else {
|
|
872
|
+
status = 'USEFUL';
|
|
873
|
+
reasons.push(`Intervention predictability: ${Math.round(interventionPred * 100)}% (threshold: ${Math.round(thresholds.intervention_predictability * 100)}%)`);
|
|
874
|
+
reasons.push(`Signal/noise ratio: ${snr.toFixed(1)} (threshold: ${thresholds.signal_noise_ratio})`);
|
|
875
|
+
|
|
876
|
+
const userCurses = ((aggregateBySource.user || {}).top_signals || {}).user_curse || 0;
|
|
877
|
+
if (userCurses > 5) {
|
|
878
|
+
actions.push('Consider increasing user_curse weight (high occurrence)');
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const falseSuccessLoops = allPatterns.filter(p => p.type === 'false_success_loop').length;
|
|
882
|
+
if (falseSuccessLoops > 3) {
|
|
883
|
+
actions.push('Create antigen for false_success pattern');
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Convert aggregateBySource to output format
|
|
888
|
+
const aggregateBySourceDict = {};
|
|
889
|
+
for (const [source, data] of Object.entries(aggregateBySource)) {
|
|
890
|
+
const sessionsCount = data.sessions_with_signals;
|
|
891
|
+
// Sort top_signals by count descending (like Python's Counter.most_common)
|
|
892
|
+
const sortedSignals = Object.entries(data.top_signals)
|
|
893
|
+
.sort((a, b) => b[1] - a[1]);
|
|
894
|
+
const topSignals = {};
|
|
895
|
+
for (const [k, v] of sortedSignals) topSignals[k] = v;
|
|
896
|
+
|
|
897
|
+
aggregateBySourceDict[source] = {
|
|
898
|
+
sessions_with_signals: sessionsCount,
|
|
899
|
+
total_friction: data.total_friction,
|
|
900
|
+
avg_friction_per_session: sessionsCount > 0 ? data.total_friction / sessionsCount : 0,
|
|
901
|
+
top_signals: topSignals,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Common sequences (simplified, same as Python)
|
|
906
|
+
const commonSequences = [];
|
|
907
|
+
|
|
908
|
+
// Per-project stats
|
|
909
|
+
const projectStats = {};
|
|
910
|
+
for (const [project, data] of Object.entries(byProject)) {
|
|
911
|
+
const total = data.total_sessions;
|
|
912
|
+
const interactive = data.interactive_sessions;
|
|
913
|
+
projectStats[project] = {
|
|
914
|
+
total_sessions: total,
|
|
915
|
+
interactive_sessions: interactive,
|
|
916
|
+
bad_sessions: data.bad_sessions,
|
|
917
|
+
bad_rate: interactive > 0 ? Math.round(data.bad_sessions / interactive * 100) / 100 : 0,
|
|
918
|
+
avg_friction: total > 0 ? Math.round(data.total_friction / total * 10) / 10 : 0,
|
|
919
|
+
avg_duration_min: total > 0 ? Math.round(data.total_duration_min / total * 10) / 10 : 0,
|
|
920
|
+
avg_turns: total > 0 ? Math.round(data.total_turns / total * 10) / 10 : 0,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Overall averages
|
|
925
|
+
let totalInteractive = 0, totalBad = 0, totalFriction = 0, totalDuration = 0, totalTurns = 0;
|
|
926
|
+
for (const d of Object.values(byProject)) {
|
|
927
|
+
totalInteractive += d.interactive_sessions;
|
|
928
|
+
totalBad += d.bad_sessions;
|
|
929
|
+
totalFriction += d.total_friction;
|
|
930
|
+
totalDuration += d.total_duration_min;
|
|
931
|
+
totalTurns += d.total_turns;
|
|
932
|
+
}
|
|
933
|
+
const totalSessions = analyses.length;
|
|
934
|
+
|
|
935
|
+
const overallStats = {
|
|
936
|
+
total_sessions: totalSessions,
|
|
937
|
+
interactive_sessions: totalInteractive,
|
|
938
|
+
bad_sessions: totalBad,
|
|
939
|
+
bad_rate: totalInteractive > 0 ? Math.round(totalBad / totalInteractive * 100) / 100 : 0,
|
|
940
|
+
avg_friction: totalSessions > 0 ? Math.round(totalFriction / totalSessions * 10) / 10 : 0,
|
|
941
|
+
avg_duration_min: totalSessions > 0 ? Math.round(totalDuration / totalSessions * 10) / 10 : 0,
|
|
942
|
+
avg_turns: totalSessions > 0 ? Math.round(totalTurns / totalSessions * 10) / 10 : 0,
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
// Time-series: daily stats
|
|
946
|
+
const byDay = {};
|
|
947
|
+
for (const analysis of analyses) {
|
|
948
|
+
const started = (analysis.session_metadata || {}).started_at || '';
|
|
949
|
+
if (started) {
|
|
950
|
+
try {
|
|
951
|
+
const dt = new Date(started.replace('Z', '+00:00'));
|
|
952
|
+
if (!isNaN(dt.getTime())) {
|
|
953
|
+
const day = dt.toISOString().slice(0, 10);
|
|
954
|
+
if (!byDay[day]) byDay[day] = { total: 0, interactive: 0, bad: 0, friction: 0 };
|
|
955
|
+
byDay[day].total++;
|
|
956
|
+
if ((analysis.session_metadata || {}).turn_count > 1) byDay[day].interactive++;
|
|
957
|
+
if (analysis.quality === 'BAD') byDay[day].bad++;
|
|
958
|
+
byDay[day].friction += (analysis.friction_summary || {}).peak || 0;
|
|
959
|
+
}
|
|
960
|
+
} catch {
|
|
961
|
+
// pass
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const dailyStats = Object.keys(byDay).sort().map(day => {
|
|
967
|
+
const d = byDay[day];
|
|
968
|
+
return {
|
|
969
|
+
date: day,
|
|
970
|
+
total: d.total,
|
|
971
|
+
interactive: d.interactive,
|
|
972
|
+
bad: d.bad,
|
|
973
|
+
bad_rate: d.interactive > 0 ? Math.round(d.bad / d.interactive * 100) / 100 : 0,
|
|
974
|
+
avg_friction: d.total > 0 ? Math.round(d.friction / d.total * 10) / 10 : 0,
|
|
975
|
+
};
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// Best and worst sessions
|
|
979
|
+
const interactiveAnalyses = analyses.filter(a => (a.session_metadata || {}).turn_count > 1);
|
|
980
|
+
|
|
981
|
+
let worstSession = null;
|
|
982
|
+
let bestSession = null;
|
|
983
|
+
|
|
984
|
+
if (interactiveAnalyses.length > 0) {
|
|
985
|
+
worstSession = interactiveAnalyses.reduce((max, a) =>
|
|
986
|
+
(a.friction_summary.peak > max.friction_summary.peak) ? a : max
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
const okSessions = interactiveAnalyses.filter(a => a.quality === 'OK');
|
|
990
|
+
if (okSessions.length > 0) {
|
|
991
|
+
bestSession = okSessions.reduce((min, a) =>
|
|
992
|
+
(a.friction_summary.peak < min.friction_summary.peak) ? a : min
|
|
993
|
+
);
|
|
994
|
+
} else {
|
|
995
|
+
bestSession = interactiveAnalyses.reduce((min, a) =>
|
|
996
|
+
(a.friction_summary.peak < min.friction_summary.peak) ? a : min
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return {
|
|
1002
|
+
analyzed_at: new Date().toISOString(),
|
|
1003
|
+
sessions_analyzed: analyses.length,
|
|
1004
|
+
config_used: config,
|
|
1005
|
+
aggregate_by_source: aggregateBySourceDict,
|
|
1006
|
+
by_project: projectStats,
|
|
1007
|
+
overall: overallStats,
|
|
1008
|
+
daily_stats: dailyStats,
|
|
1009
|
+
best_session: bestSession ? {
|
|
1010
|
+
session_id: bestSession.session_id,
|
|
1011
|
+
quality: bestSession.quality,
|
|
1012
|
+
peak_friction: (bestSession.friction_summary || {}).peak || 0,
|
|
1013
|
+
turns: (bestSession.session_metadata || {}).turn_count || 0,
|
|
1014
|
+
duration_min: (bestSession.session_metadata || {}).duration_min || 0,
|
|
1015
|
+
} : null,
|
|
1016
|
+
worst_session: worstSession ? {
|
|
1017
|
+
session_id: worstSession.session_id,
|
|
1018
|
+
quality: worstSession.quality,
|
|
1019
|
+
peak_friction: (worstSession.friction_summary || {}).peak || 0,
|
|
1020
|
+
turns: (worstSession.session_metadata || {}).turn_count || 0,
|
|
1021
|
+
duration_min: (worstSession.session_metadata || {}).duration_min || 0,
|
|
1022
|
+
} : null,
|
|
1023
|
+
correlations: {
|
|
1024
|
+
high_friction_sessions: highFrictionCount,
|
|
1025
|
+
intervention_sessions: interventionCount,
|
|
1026
|
+
intervention_predictability: Math.round(interventionPred * 100) / 100,
|
|
1027
|
+
},
|
|
1028
|
+
common_sequences: commonSequences,
|
|
1029
|
+
verdict: { status, reasons, recommended_actions: actions },
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// =============================================================================
|
|
1034
|
+
// FRICTION ANALYZE - print helpers
|
|
1035
|
+
// =============================================================================
|
|
1036
|
+
|
|
1037
|
+
function printBox(title, lines, width) {
|
|
1038
|
+
width = width || 60;
|
|
1039
|
+
const hr = '\u2500'.repeat(width - 2);
|
|
1040
|
+
console.log(`\u250C${hr}\u2510`);
|
|
1041
|
+
console.log(`\u2502 ${title.toUpperCase().padEnd(width - 4)} \u2502`);
|
|
1042
|
+
console.log(`\u251C${hr}\u2524`);
|
|
1043
|
+
for (let line of lines) {
|
|
1044
|
+
if (line.length > width - 4) line = line.slice(0, width - 7) + '...';
|
|
1045
|
+
console.log(`\u2502 ${line.padEnd(width - 4)} \u2502`);
|
|
1046
|
+
}
|
|
1047
|
+
console.log(`\u2514${hr}\u2518`);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function printTable(headers, rows, colWidths) {
|
|
1051
|
+
if (!colWidths) {
|
|
1052
|
+
colWidths = headers.map((h, i) => {
|
|
1053
|
+
let max = String(h).length;
|
|
1054
|
+
for (const row of rows) {
|
|
1055
|
+
const len = String(row[i]).length;
|
|
1056
|
+
if (len > max) max = len;
|
|
1057
|
+
}
|
|
1058
|
+
return max + 2;
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const topBorder = '\u250C' + colWidths.map(w => '\u2500'.repeat(w)).join('\u252C') + '\u2510';
|
|
1063
|
+
const headerLine = '\u2502' + headers.map((h, i) => ` ${String(h).padEnd(colWidths[i] - 2)} `).join('\u2502') + '\u2502';
|
|
1064
|
+
const sep = '\u251C' + colWidths.map(w => '\u2500'.repeat(w)).join('\u253C') + '\u2524';
|
|
1065
|
+
|
|
1066
|
+
console.log(topBorder);
|
|
1067
|
+
console.log(headerLine);
|
|
1068
|
+
console.log(sep);
|
|
1069
|
+
|
|
1070
|
+
for (const row of rows) {
|
|
1071
|
+
const rowLine = '\u2502' + row.map((v, i) => ` ${String(v).padEnd(colWidths[i] - 2)} `).join('\u2502') + '\u2502';
|
|
1072
|
+
console.log(rowLine);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const bottomBorder = '\u2514' + colWidths.map(w => '\u2500'.repeat(w)).join('\u2534') + '\u2518';
|
|
1076
|
+
console.log(bottomBorder);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// =============================================================================
|
|
1080
|
+
// FRICTION ANALYZE - generate_detailed_report
|
|
1081
|
+
// =============================================================================
|
|
1082
|
+
|
|
1083
|
+
function generateDetailedReport(outputDir, analyses, summary, config, signalCounts, multiProject) {
|
|
1084
|
+
const report = [];
|
|
1085
|
+
|
|
1086
|
+
report.push('# Friction Analysis - Detailed Report\n\n');
|
|
1087
|
+
report.push(`**Generated:** ${new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC\n\n`);
|
|
1088
|
+
report.push(`**Sessions Analyzed:** ${analyses.length}\n`);
|
|
1089
|
+
report.push(`**Interactive Sessions:** ${summary.overall.interactive_sessions} (multi-turn conversations)\n`);
|
|
1090
|
+
report.push(`**BAD Sessions:** ${summary.overall.bad_sessions} (${Math.round(summary.overall.bad_rate * 100)}% of interactive)\n\n`);
|
|
1091
|
+
|
|
1092
|
+
// Glossary
|
|
1093
|
+
report.push('## Glossary\n\n');
|
|
1094
|
+
report.push('**Interactive Session:** A conversation with >1 turn (multi-turn dialogue). Single-turn sessions are filtered from BAD rate calculation.\n\n');
|
|
1095
|
+
report.push('**BAD Session:** User gave up via `/stash`, `/exit`, or silent abandonment (high friction with no resolution).\n\n');
|
|
1096
|
+
report.push('**Friction:** Cumulative weight of negative signals. Higher friction = more user frustration.\n\n');
|
|
1097
|
+
report.push('**Peak Friction:** Maximum friction reached during a session.\n\n');
|
|
1098
|
+
report.push('---\n\n');
|
|
1099
|
+
|
|
1100
|
+
// Executive summary
|
|
1101
|
+
report.push('## Executive Summary\n\n');
|
|
1102
|
+
const badRate = summary.overall.bad_rate;
|
|
1103
|
+
const overall = summary.overall;
|
|
1104
|
+
|
|
1105
|
+
if (badRate > 0.5) {
|
|
1106
|
+
report.push(`\u26A0\uFE0F **CRITICAL**: ${Math.round(badRate * 100)}% of interactive sessions end in failure. `);
|
|
1107
|
+
} else if (badRate > 0.3) {
|
|
1108
|
+
report.push(`\uD83D\uDFE1 **WARNING**: ${Math.round(badRate * 100)}% of interactive sessions end in failure. `);
|
|
1109
|
+
} else {
|
|
1110
|
+
report.push(`\u2705 **HEALTHY**: ${Math.round(badRate * 100)}% of interactive sessions end in failure. `);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
report.push(`Average session: ${overall.avg_turns.toFixed(1)} turns, ${overall.avg_friction.toFixed(1)} friction, ${Math.round(overall.avg_duration_min)} min.\n\n`);
|
|
1114
|
+
|
|
1115
|
+
// Top issues
|
|
1116
|
+
const topSignals = sortedEntries(signalCounts).slice(0, 3);
|
|
1117
|
+
report.push('**Top Issues:**\n');
|
|
1118
|
+
for (const [sig, count] of topSignals) {
|
|
1119
|
+
const weight = config.weights[sig] || 0;
|
|
1120
|
+
const total = count * weight;
|
|
1121
|
+
report.push(`- **${sig}** (${count} occurrences, ${Math.round(total)} total friction)\n`);
|
|
1122
|
+
}
|
|
1123
|
+
report.push('\n');
|
|
1124
|
+
report.push('---\n\n');
|
|
1125
|
+
|
|
1126
|
+
// Weight system
|
|
1127
|
+
report.push('## Friction Weight System\n\n');
|
|
1128
|
+
report.push('Each signal has a weight representing its severity. Friction accumulates as signals occur.\n\n');
|
|
1129
|
+
report.push('| Weight | Severity | Meaning |\n');
|
|
1130
|
+
report.push('|--------|----------|----------|\n');
|
|
1131
|
+
report.push('| +10 | CRITICAL | User gave up (intervention, abandonment) |\n');
|
|
1132
|
+
report.push('| +8 | SEVERE | LLM false claims or no progress (false_success, no_resolution) |\n');
|
|
1133
|
+
report.push('| +7 | HIGH | User frustration (interrupt_cascade) |\n');
|
|
1134
|
+
report.push('| +6 | MEDIUM | Stuck patterns (tool_loop, rapid_exit) |\n');
|
|
1135
|
+
report.push('| +4-5 | LOW-MEDIUM | User signals (request_interrupted, user_curse) |\n');
|
|
1136
|
+
report.push('| +1 | MINOR | Technical issues (exit_error, repeated_question) |\n');
|
|
1137
|
+
report.push('| +0.5 | NOISE | Context signals (compaction, long_silence, user_negation) |\n\n');
|
|
1138
|
+
report.push('---\n\n');
|
|
1139
|
+
|
|
1140
|
+
// Signal breakdown
|
|
1141
|
+
report.push('## Signal Breakdown\n\n');
|
|
1142
|
+
report.push('| Signal | Count | Weight | Total Friction | What It Means |\n');
|
|
1143
|
+
report.push('|--------|-------|--------|----------------|---------------|\n');
|
|
1144
|
+
|
|
1145
|
+
const signalMeanings = {
|
|
1146
|
+
exit_error: 'Command failed (exit code != 0)',
|
|
1147
|
+
compaction: 'Context overflow, conversation summarized',
|
|
1148
|
+
repeated_question: 'User asked same question twice',
|
|
1149
|
+
request_interrupted: 'User hit Ctrl+C or ESC',
|
|
1150
|
+
long_silence: 'User paused >10 min',
|
|
1151
|
+
user_negation: '"no", "didn\'t work", "still broken"',
|
|
1152
|
+
false_success: 'LLM claimed success after error',
|
|
1153
|
+
user_intervention: 'User gave up (/stash, /exit)',
|
|
1154
|
+
interrupt_cascade: '2+ interrupts within 60s',
|
|
1155
|
+
session_abandoned: 'High friction, no resolution',
|
|
1156
|
+
no_resolution: 'Errors without subsequent success',
|
|
1157
|
+
exit_success: 'Command succeeded (exit code 0)',
|
|
1158
|
+
tool_loop: 'Same tool called 3+ times',
|
|
1159
|
+
rapid_exit: '<3 turns, ends with error/interrupt',
|
|
1160
|
+
user_curse: 'User frustration (profanity)',
|
|
1161
|
+
sibling_tool_error: 'Parallel tools canceled (SDK cascade)',
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
for (const [sigType, count] of sortedEntries(signalCounts)) {
|
|
1165
|
+
const weight = config.weights[sigType] || 0;
|
|
1166
|
+
const total = count * weight;
|
|
1167
|
+
const meaning = signalMeanings[sigType] || 'Unknown signal';
|
|
1168
|
+
const weightStr = weight >= 0 ? `+${weight.toFixed(1)}` : weight.toFixed(1);
|
|
1169
|
+
report.push(`| ${sigType} | ${count} | ${weightStr} | ${total.toFixed(1)} | ${meaning} |\n`);
|
|
1170
|
+
}
|
|
1171
|
+
report.push('\n');
|
|
1172
|
+
|
|
1173
|
+
// Pattern analysis
|
|
1174
|
+
report.push('## Pattern Analysis\n\n');
|
|
1175
|
+
|
|
1176
|
+
const falseSuccessCount = signalCounts.false_success || 0;
|
|
1177
|
+
const exitErrorCount = signalCounts.exit_error || 0;
|
|
1178
|
+
const interruptCount = signalCounts.request_interrupted || 0;
|
|
1179
|
+
const interventionCountLocal = signalCounts.user_intervention || 0;
|
|
1180
|
+
|
|
1181
|
+
report.push('### Common Failure Patterns\n\n');
|
|
1182
|
+
|
|
1183
|
+
if (falseSuccessCount > 0) {
|
|
1184
|
+
report.push(`**False Success Loop** (${falseSuccessCount} occurrences): LLM claims task is complete after command fails. `);
|
|
1185
|
+
report.push('This indicates the LLM is not checking exit codes properly.\n\n');
|
|
1186
|
+
}
|
|
1187
|
+
if (exitErrorCount > 50) {
|
|
1188
|
+
report.push(`**High Error Rate** (${exitErrorCount} errors): Many commands are failing. `);
|
|
1189
|
+
report.push('This suggests either environment issues or LLM choosing wrong approaches.\n\n');
|
|
1190
|
+
}
|
|
1191
|
+
if (interruptCount > 20) {
|
|
1192
|
+
report.push(`**User Interruptions** (${interruptCount} interrupts): Users frequently canceling operations. `);
|
|
1193
|
+
report.push('Commands may be too slow, stuck, or heading in wrong direction.\n\n');
|
|
1194
|
+
}
|
|
1195
|
+
if (interventionCountLocal > 0) {
|
|
1196
|
+
const interventionRate = interventionCountLocal / summary.overall.interactive_sessions;
|
|
1197
|
+
report.push(`**Abandonment Rate** (${Math.round(interventionRate * 100)}%): ${interventionCountLocal}/${summary.overall.interactive_sessions} interactive sessions ended with user giving up. `);
|
|
1198
|
+
if (interventionRate > 0.3) {
|
|
1199
|
+
report.push('This is CRITICAL - users are frequently giving up.\n\n');
|
|
1200
|
+
} else {
|
|
1201
|
+
report.push('This is acceptable for complex tasks.\n\n');
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Friction level breakdown
|
|
1206
|
+
report.push('### Friction Level Breakdown\n\n');
|
|
1207
|
+
const lowFriction = analyses.filter(a => a.friction_summary.peak > 0 && a.friction_summary.peak < 15);
|
|
1208
|
+
const mediumFriction = analyses.filter(a => a.friction_summary.peak >= 15 && a.friction_summary.peak < 50);
|
|
1209
|
+
const highFriction = analyses.filter(a => a.friction_summary.peak >= 50);
|
|
1210
|
+
|
|
1211
|
+
report.push(`**Low Friction (0-15):** ${lowFriction.length} sessions - Normal operation, minor errors quickly resolved\n\n`);
|
|
1212
|
+
report.push(`**Medium Friction (15-50):** ${mediumFriction.length} sessions - Some struggles, multiple retries, but eventually successful\n\n`);
|
|
1213
|
+
report.push(`**High Friction (50+):** ${highFriction.length} sessions - Severe issues, user frustration, likely gave up\n\n`);
|
|
1214
|
+
report.push('---\n\n');
|
|
1215
|
+
|
|
1216
|
+
// High-friction sessions
|
|
1217
|
+
report.push('## Top Friction Sessions\n\n');
|
|
1218
|
+
let topFriction = analyses.slice().sort((a, b) => b.friction_summary.peak - a.friction_summary.peak).slice(0, 20);
|
|
1219
|
+
topFriction = topFriction.filter(a => a.friction_summary.peak > 0);
|
|
1220
|
+
|
|
1221
|
+
if (multiProject) {
|
|
1222
|
+
report.push('| Project | Session | Quality | Peak | Turns | Duration | Top Signals |\n');
|
|
1223
|
+
report.push('|---------|---------|---------|------|-------|----------|-------------|\n');
|
|
1224
|
+
} else {
|
|
1225
|
+
report.push('| Session | Quality | Peak | Turns | Duration | Top Signals |\n');
|
|
1226
|
+
report.push('|---------|---------|------|-------|----------|-------------|\n');
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
for (const a of topFriction) {
|
|
1230
|
+
const fullSid = a.session_id;
|
|
1231
|
+
const project = fullSid.includes('/') ? fullSid.split('/')[0] : '?';
|
|
1232
|
+
const sid = fullSid.includes('/') ? fullSid.split('/').slice(-1)[0] : fullSid;
|
|
1233
|
+
const peak = a.friction_summary.peak;
|
|
1234
|
+
const turns = (a.session_metadata || {}).turn_count || 0;
|
|
1235
|
+
const dur = (a.session_metadata || {}).duration_min || 0;
|
|
1236
|
+
const durStr = dur ? formatDuration(dur) : '-';
|
|
1237
|
+
const quality = a.quality || '?';
|
|
1238
|
+
|
|
1239
|
+
const topSigs = [];
|
|
1240
|
+
for (const [, data] of Object.entries(a.by_source || {})) {
|
|
1241
|
+
for (const [sigType, sigData] of Object.entries(data.signals || {})) {
|
|
1242
|
+
if (sigData.count > 0) {
|
|
1243
|
+
const shortName = sigType.replace('user_', '').replace('exit_', '');
|
|
1244
|
+
topSigs.push(`${shortName}:${sigData.count}`);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
const sigsStr = topSigs.length > 0 ? topSigs.slice(0, 3).join(', ') : '-';
|
|
1249
|
+
|
|
1250
|
+
if (multiProject) {
|
|
1251
|
+
report.push(`| ${project} | ${sid} | ${quality} | ${peak} | ${turns} | ${durStr} | ${sigsStr} |\n`);
|
|
1252
|
+
} else {
|
|
1253
|
+
report.push(`| ${sid} | ${quality} | ${peak} | ${turns} | ${durStr} | ${sigsStr} |\n`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
report.push('\n');
|
|
1257
|
+
|
|
1258
|
+
// Session quality breakdown
|
|
1259
|
+
report.push('## Session Quality Breakdown\n\n');
|
|
1260
|
+
const qualityCounts = {};
|
|
1261
|
+
for (const a of analyses) {
|
|
1262
|
+
const q = a.quality || 'UNKNOWN';
|
|
1263
|
+
qualityCounts[q] = (qualityCounts[q] || 0) + 1;
|
|
1264
|
+
}
|
|
1265
|
+
const qualityOrder = ['BAD', 'FRICTION', 'ROUGH', 'OK', 'ONE-SHOT'];
|
|
1266
|
+
const qualityDesc = {
|
|
1267
|
+
BAD: 'user gave up (/stash)',
|
|
1268
|
+
FRICTION: 'curse or false_success',
|
|
1269
|
+
ROUGH: 'high friction but completed',
|
|
1270
|
+
OK: 'no significant friction',
|
|
1271
|
+
'ONE-SHOT': 'single turn (filtered)',
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
report.push('| Quality | Count | Description |\n');
|
|
1275
|
+
report.push('|---------|-------|-------------|\n');
|
|
1276
|
+
for (const q of qualityOrder) {
|
|
1277
|
+
const count = qualityCounts[q] || 0;
|
|
1278
|
+
const desc = qualityDesc[q] || '';
|
|
1279
|
+
if (count > 0) {
|
|
1280
|
+
report.push(`| ${q} | ${count} | ${desc} |\n`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
report.push('\n');
|
|
1284
|
+
|
|
1285
|
+
// Per-project stats
|
|
1286
|
+
if (summary.by_project && Object.keys(summary.by_project).length > 0) {
|
|
1287
|
+
report.push('## Per-Project Statistics\n\n');
|
|
1288
|
+
report.push('| Project | Interactive | BAD | BAD % | Avg Friction | Avg Turns | Avg Duration |\n');
|
|
1289
|
+
report.push('|---------|-------------|-----|-------|--------------|-----------|-------------|\n');
|
|
1290
|
+
for (const proj of Object.keys(summary.by_project).sort()) {
|
|
1291
|
+
const stats = summary.by_project[proj];
|
|
1292
|
+
const badRatePct = stats.interactive_sessions > 0 ? `${Math.round(stats.bad_rate * 100)}%` : '-';
|
|
1293
|
+
const dur = stats.avg_duration_min || 0;
|
|
1294
|
+
const durStr = dur ? formatDuration(Math.round(dur)) : '-';
|
|
1295
|
+
report.push(`| ${proj} | ${stats.interactive_sessions} | ${stats.bad_sessions} | ${badRatePct} | ${stats.avg_friction.toFixed(1)} | ${stats.avg_turns.toFixed(1)} | ${durStr} |\n`);
|
|
1296
|
+
}
|
|
1297
|
+
report.push('\n');
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Recommendations
|
|
1301
|
+
report.push('## Recommendations\n\n');
|
|
1302
|
+
const recommendations = [];
|
|
1303
|
+
|
|
1304
|
+
if (falseSuccessCount > 10) {
|
|
1305
|
+
recommendations.push('**High Priority:** Add CLAUDE.md rule to verify exit codes before claiming success');
|
|
1306
|
+
}
|
|
1307
|
+
if (interruptCount > 20) {
|
|
1308
|
+
recommendations.push('**High Priority:** Commands timing out or stuck - review for heavy operations that need optimization');
|
|
1309
|
+
}
|
|
1310
|
+
if ((signalCounts.tool_loop || 0) > 3) {
|
|
1311
|
+
recommendations.push('**Medium Priority:** Add CLAUDE.md rule to detect and break out of tool loops');
|
|
1312
|
+
}
|
|
1313
|
+
if (interventionCountLocal / summary.overall.interactive_sessions > 0.4) {
|
|
1314
|
+
recommendations.push('**Critical:** >40% abandonment rate - major UX issues, review antigens for patterns');
|
|
1315
|
+
}
|
|
1316
|
+
if ((signalCounts.repeated_question || 0) > 20) {
|
|
1317
|
+
recommendations.push('**Medium Priority:** Many repeated questions - LLM not understanding user intent or context issues');
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (recommendations.length > 0) {
|
|
1321
|
+
recommendations.forEach((rec, i) => report.push(`${i + 1}. ${rec}\n\n`));
|
|
1322
|
+
} else {
|
|
1323
|
+
report.push('No critical issues detected. Continue monitoring.\n\n');
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
report.push('---\n\n');
|
|
1327
|
+
|
|
1328
|
+
// Daily trend
|
|
1329
|
+
if (summary.daily_stats && summary.daily_stats.length > 0) {
|
|
1330
|
+
report.push('## Daily Trend (Last 14 Days)\n\n');
|
|
1331
|
+
report.push('| Date | Interactive | BAD | Rate | Trend |\n');
|
|
1332
|
+
report.push('|------|-------------|-----|------|-------|\n');
|
|
1333
|
+
for (const day of summary.daily_stats.slice(-14)) {
|
|
1334
|
+
const dayBadRate = day.interactive > 0 ? day.bad_rate : 0;
|
|
1335
|
+
const badRatePct = day.interactive > 0 ? `${Math.round(dayBadRate * 100)}%` : '-';
|
|
1336
|
+
const barLen = Math.round(dayBadRate * 10);
|
|
1337
|
+
const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(10 - barLen);
|
|
1338
|
+
report.push(`| ${day.date} | ${day.interactive} | ${day.bad} | ${badRatePct} | ${bar} |\n`);
|
|
1339
|
+
}
|
|
1340
|
+
report.push('\n');
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Write report
|
|
1344
|
+
fs.writeFileSync(path.join(outputDir, 'report.md'), report.join(''));
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Sort object entries by value descending (like Python Counter.most_common).
|
|
1349
|
+
*/
|
|
1350
|
+
function sortedEntries(obj) {
|
|
1351
|
+
return Object.entries(obj).sort((a, b) => b[1] - a[1]);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// =============================================================================
|
|
1355
|
+
// FRICTION ANALYZE - main
|
|
1356
|
+
// =============================================================================
|
|
1357
|
+
|
|
1358
|
+
function analyzeMain(sessionsDir) {
|
|
1359
|
+
const inputPath = sessionsDir;
|
|
1360
|
+
const config = loadConfig();
|
|
1361
|
+
|
|
1362
|
+
// Find sessions
|
|
1363
|
+
let sessionFiles = [];
|
|
1364
|
+
let stat;
|
|
1365
|
+
try {
|
|
1366
|
+
stat = fs.statSync(inputPath);
|
|
1367
|
+
} catch {
|
|
1368
|
+
console.log(`No sessions found in ${inputPath}`);
|
|
1369
|
+
return 1;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
if (stat.isFile()) {
|
|
1373
|
+
sessionFiles = [inputPath];
|
|
1374
|
+
} else {
|
|
1375
|
+
// Try direct session files first
|
|
1376
|
+
sessionFiles = globFiles(inputPath, '*.jsonl', false)
|
|
1377
|
+
.filter(f => !path.basename(f).includes('sessions-index'));
|
|
1378
|
+
|
|
1379
|
+
// If no direct session files, check subdirectories
|
|
1380
|
+
if (sessionFiles.length === 0) {
|
|
1381
|
+
const projectDirs = listDirs(inputPath)
|
|
1382
|
+
.filter(d => !path.basename(d).startsWith('.'));
|
|
1383
|
+
|
|
1384
|
+
for (const projDir of projectDirs) {
|
|
1385
|
+
const projSessions = globFiles(projDir, '*.jsonl', false);
|
|
1386
|
+
if (projSessions.length > 0) {
|
|
1387
|
+
sessionFiles = [];
|
|
1388
|
+
for (const pd of projectDirs) {
|
|
1389
|
+
const files = globFiles(pd, '*.jsonl', false)
|
|
1390
|
+
.filter(f => !path.basename(f).includes('sessions-index'));
|
|
1391
|
+
sessionFiles.push(...files);
|
|
1392
|
+
}
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
if (sessionFiles.length === 0) {
|
|
1400
|
+
console.log(`No sessions found in ${inputPath}`);
|
|
1401
|
+
return 1;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Create output dir
|
|
1405
|
+
const outputDir = '.claude/friction';
|
|
1406
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
1407
|
+
|
|
1408
|
+
// Process each session
|
|
1409
|
+
const analyses = [];
|
|
1410
|
+
const allSignals = [];
|
|
1411
|
+
const errors = [];
|
|
1412
|
+
|
|
1413
|
+
const projectParents = new Set(sessionFiles.map(f => path.basename(path.dirname(f))));
|
|
1414
|
+
const multiProject = projectParents.size > 1;
|
|
1415
|
+
|
|
1416
|
+
if (multiProject) {
|
|
1417
|
+
console.log(`Found sessions from ${projectParents.size} projects\n`);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
for (const sessionFile of sessionFiles) {
|
|
1421
|
+
try {
|
|
1422
|
+
const [signals, metadata] = extractSignals(sessionFile);
|
|
1423
|
+
const sessionName = deriveSessionName(sessionFile, metadata);
|
|
1424
|
+
|
|
1425
|
+
for (const sig of signals) {
|
|
1426
|
+
sig.session = sessionName;
|
|
1427
|
+
allSignals.push(sig);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const analysis = analyzeSession(sessionName, signals, metadata, config);
|
|
1431
|
+
analyses.push(analysis);
|
|
1432
|
+
} catch (e) {
|
|
1433
|
+
errors.push([path.basename(sessionFile).slice(0, 12), String(e).slice(0, 40)]);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Write consolidated raw signals
|
|
1438
|
+
const rawLines = allSignals.map(sig => JSON.stringify(sig)).join('\n') + (allSignals.length > 0 ? '\n' : '');
|
|
1439
|
+
fs.writeFileSync(path.join(outputDir, 'friction_raw.jsonl'), rawLines);
|
|
1440
|
+
|
|
1441
|
+
// Write consolidated analysis
|
|
1442
|
+
fs.writeFileSync(path.join(outputDir, 'friction_analysis.json'), JSON.stringify(analyses, null, 2));
|
|
1443
|
+
|
|
1444
|
+
if (analyses.length === 0) {
|
|
1445
|
+
console.log('\nNo sessions could be analyzed');
|
|
1446
|
+
return 1;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Aggregate
|
|
1450
|
+
const summary = aggregateSessions(analyses, config);
|
|
1451
|
+
fs.writeFileSync(path.join(outputDir, 'friction_summary.json'), JSON.stringify(summary, null, 2));
|
|
1452
|
+
|
|
1453
|
+
// === CONCISE TERMINAL OUTPUT ===
|
|
1454
|
+
console.log();
|
|
1455
|
+
console.log('='.repeat(60));
|
|
1456
|
+
console.log('FRICTION ANALYSIS');
|
|
1457
|
+
console.log('='.repeat(60));
|
|
1458
|
+
console.log();
|
|
1459
|
+
|
|
1460
|
+
const agg = summary.aggregate_by_source;
|
|
1461
|
+
const corr = summary.correlations;
|
|
1462
|
+
const overall = summary.overall;
|
|
1463
|
+
|
|
1464
|
+
const interactive = overall.interactive_sessions;
|
|
1465
|
+
const badCount = overall.bad_sessions;
|
|
1466
|
+
const badRateVal = overall.bad_rate;
|
|
1467
|
+
|
|
1468
|
+
const projectsCount = Object.keys(summary.by_project || {}).length;
|
|
1469
|
+
console.log(`Analyzed: ${analyses.length} sessions (${interactive} interactive*) from ${projectsCount} project${projectsCount !== 1 ? 's' : ''}`);
|
|
1470
|
+
console.log(' *interactive = multi-turn conversations (>1 turn)');
|
|
1471
|
+
|
|
1472
|
+
const emoji = badRateVal > 0.5 ? '\uD83D\uDD34' : badRateVal > 0.3 ? '\uD83D\uDFE1' : '\u2705';
|
|
1473
|
+
console.log(`BAD Rate: ${Math.round(badRateVal * 100)}% (${badCount}/${interactive} interactive) ${emoji}`);
|
|
1474
|
+
console.log();
|
|
1475
|
+
|
|
1476
|
+
// Top signals
|
|
1477
|
+
const signalCounts = {};
|
|
1478
|
+
for (const sourceData of Object.values(agg)) {
|
|
1479
|
+
for (const [sigType, count] of Object.entries(sourceData.top_signals || {})) {
|
|
1480
|
+
signalCounts[sigType] = (signalCounts[sigType] || 0) + count;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
if (Object.keys(signalCounts).length > 0) {
|
|
1485
|
+
console.log('Top Signals:');
|
|
1486
|
+
const topSigs = sortedEntries(signalCounts).slice(0, 5);
|
|
1487
|
+
for (const [sigType, count] of topSigs) {
|
|
1488
|
+
const weight = config.weights[sigType] || 0;
|
|
1489
|
+
const totalFriction = count * weight;
|
|
1490
|
+
const sign = totalFriction >= 0 ? '+' : '';
|
|
1491
|
+
console.log(` ${sigType.padEnd(20)} ${String(count).padStart(3)} (${sign}${Math.round(totalFriction)} friction)`);
|
|
1492
|
+
}
|
|
1493
|
+
console.log();
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// Per-project stats
|
|
1497
|
+
if (summary.by_project && Object.keys(summary.by_project).length > 0) {
|
|
1498
|
+
console.log('Per-Project:');
|
|
1499
|
+
for (const proj of Object.keys(summary.by_project).sort()) {
|
|
1500
|
+
const stats = summary.by_project[proj];
|
|
1501
|
+
const projBadRate = stats.bad_rate;
|
|
1502
|
+
const projEmoji = projBadRate > 0.5 ? '\uD83D\uDD34' : projBadRate > 0.3 ? '\uD83D\uDFE1' : '\u2705';
|
|
1503
|
+
const badPct = stats.interactive_sessions > 0 ? `${Math.round(projBadRate * 100)}%` : '-';
|
|
1504
|
+
|
|
1505
|
+
const projSessions = analyses.filter(a => a.session_id.startsWith(`${proj}/`));
|
|
1506
|
+
const interactiveSessions = projSessions.filter(a => (a.session_metadata || {}).turn_count > 1);
|
|
1507
|
+
if (interactiveSessions.length > 0) {
|
|
1508
|
+
const frictions = interactiveSessions.map(a => a.friction_summary.peak).sort((a, b) => a - b);
|
|
1509
|
+
const median = frictions[Math.floor(frictions.length / 2)];
|
|
1510
|
+
console.log(` ${proj.padEnd(12)} ${badPct.padStart(4)} BAD (${stats.bad_sessions}/${stats.interactive_sessions}) median: ${median.toFixed(1)} ${projEmoji}`);
|
|
1511
|
+
} else {
|
|
1512
|
+
console.log(` ${proj.padEnd(12)} ${badPct.padStart(4)} BAD (${stats.bad_sessions}/${stats.interactive_sessions}) ${projEmoji}`);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
console.log();
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Best and worst
|
|
1519
|
+
if (summary.best_session && summary.worst_session) {
|
|
1520
|
+
const ws = summary.worst_session;
|
|
1521
|
+
const bs = summary.best_session;
|
|
1522
|
+
console.log('Session Extremes:');
|
|
1523
|
+
|
|
1524
|
+
const wsId = multiProject ? ws.session_id : (ws.session_id.includes('/') ? ws.session_id.split('/').slice(-1)[0] : ws.session_id);
|
|
1525
|
+
console.log(` WORST: ${wsId} peak=${ws.peak_friction} turns=${ws.turns}`);
|
|
1526
|
+
|
|
1527
|
+
const bsId = multiProject ? bs.session_id : (bs.session_id.includes('/') ? bs.session_id.split('/').slice(-1)[0] : bs.session_id);
|
|
1528
|
+
console.log(` BEST: ${bsId} peak=${bs.peak_friction} turns=${bs.turns}`);
|
|
1529
|
+
console.log();
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Last 2 weeks trend
|
|
1533
|
+
if (summary.daily_stats && summary.daily_stats.length > 0) {
|
|
1534
|
+
console.log('Last 2 Weeks:');
|
|
1535
|
+
for (const day of summary.daily_stats.slice(-14)) {
|
|
1536
|
+
if (day.interactive === 0) continue;
|
|
1537
|
+
const dayBadRate = day.bad_rate;
|
|
1538
|
+
const barLen = Math.round(dayBadRate * 10);
|
|
1539
|
+
const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(10 - barLen);
|
|
1540
|
+
console.log(` ${day.date} ${String(day.interactive).padStart(2)} sessions ${String(day.bad).padStart(2)} BAD ${bar} ${Math.round(dayBadRate * 100)}%`);
|
|
1541
|
+
}
|
|
1542
|
+
console.log();
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Verdict
|
|
1546
|
+
const verdict = summary.verdict;
|
|
1547
|
+
const statusEmoji = { USEFUL: '\u2713', INCONCLUSIVE: '?', BLOAT: '\u2717' };
|
|
1548
|
+
const statusVal = verdict.status;
|
|
1549
|
+
console.log(`Verdict: ${statusEmoji[statusVal] || '?'} ${statusVal}`);
|
|
1550
|
+
|
|
1551
|
+
const predictability = corr.intervention_predictability || 0;
|
|
1552
|
+
console.log(` Intervention predictability: ${Math.round(predictability * 100)}%`);
|
|
1553
|
+
if ('signal_noise_ratio' in summary) {
|
|
1554
|
+
console.log(` Signal/noise ratio: ${summary.signal_noise_ratio.toFixed(1)}`);
|
|
1555
|
+
}
|
|
1556
|
+
console.log();
|
|
1557
|
+
|
|
1558
|
+
// Generate detailed report
|
|
1559
|
+
generateDetailedReport(outputDir, analyses, summary, config, signalCounts, multiProject);
|
|
1560
|
+
|
|
1561
|
+
// Output files
|
|
1562
|
+
console.log('Outputs:');
|
|
1563
|
+
console.log(' \uD83D\uDCCA .claude/friction/report.md (detailed analysis)');
|
|
1564
|
+
console.log(' \uD83D\uDCCB .claude/friction/antigen_review.md (clustered failure patterns)');
|
|
1565
|
+
console.log(` \uD83D\uDCC1 .claude/friction/*.json (raw data: ${allSignals.length} signals, ${analyses.length} sessions)`);
|
|
1566
|
+
console.log();
|
|
1567
|
+
|
|
1568
|
+
console.log('Next: Review .claude/friction/report.md');
|
|
1569
|
+
console.log('='.repeat(60));
|
|
1570
|
+
|
|
1571
|
+
if (errors.length > 0) {
|
|
1572
|
+
console.log(`\n\u26A0 ${errors.length} sessions failed to parse`);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
return statusVal === 'USEFUL' ? 0 : 1;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// =============================================================================
|
|
1579
|
+
// ANTIGEN EXTRACT - find_session_file
|
|
1580
|
+
// =============================================================================
|
|
1581
|
+
|
|
1582
|
+
function findSessionFile(sessionsDir, sessionId) {
|
|
1583
|
+
let projectName = null;
|
|
1584
|
+
let shortId;
|
|
1585
|
+
|
|
1586
|
+
if (sessionId.includes('/')) {
|
|
1587
|
+
projectName = sessionId.split('/')[0];
|
|
1588
|
+
shortId = sessionId.split('/').slice(-1)[0].split('-').slice(-1)[0];
|
|
1589
|
+
} else {
|
|
1590
|
+
shortId = sessionId;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const sessionsPath = sessionsDir;
|
|
1594
|
+
|
|
1595
|
+
// First try direct search
|
|
1596
|
+
const directFiles = globFiles(sessionsPath, '*.jsonl', false);
|
|
1597
|
+
for (const f of directFiles) {
|
|
1598
|
+
if (path.basename(f).includes(shortId)) return f;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Search in subdirectories for project match
|
|
1602
|
+
if (projectName) {
|
|
1603
|
+
const subdirs = listDirs(sessionsPath);
|
|
1604
|
+
for (const subdir of subdirs) {
|
|
1605
|
+
if (path.basename(subdir).endsWith(projectName)) {
|
|
1606
|
+
const files = globFiles(subdir, '*.jsonl', false);
|
|
1607
|
+
for (const f of files) {
|
|
1608
|
+
if (path.basename(f).includes(shortId)) return f;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// Fallback: recursive search
|
|
1615
|
+
const allFiles = globFiles(sessionsPath, '*.jsonl', true);
|
|
1616
|
+
for (const f of allFiles) {
|
|
1617
|
+
if (path.basename(f).includes(shortId) && !path.basename(f).includes('sessions-index')) {
|
|
1618
|
+
return f;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// =============================================================================
|
|
1626
|
+
// ANTIGEN EXTRACT - extract helpers
|
|
1627
|
+
// =============================================================================
|
|
1628
|
+
|
|
1629
|
+
function extractContextWindow(sessionFile, anchorTs, windowSize) {
|
|
1630
|
+
windowSize = windowSize || 5;
|
|
1631
|
+
|
|
1632
|
+
const raw = fs.readFileSync(sessionFile, 'utf-8');
|
|
1633
|
+
const events = raw.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
|
|
1634
|
+
|
|
1635
|
+
const turns = [];
|
|
1636
|
+
for (const event of events) {
|
|
1637
|
+
if (event.type === 'user' || event.type === 'assistant') {
|
|
1638
|
+
const ts = event.timestamp || '';
|
|
1639
|
+
turns.push({ ts, type: event.type, event });
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Find anchor position
|
|
1644
|
+
let anchorIdx = null;
|
|
1645
|
+
for (let i = 0; i < turns.length; i++) {
|
|
1646
|
+
if (turns[i].ts === anchorTs) {
|
|
1647
|
+
anchorIdx = i;
|
|
1648
|
+
break;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// If exact match not found, find closest before
|
|
1653
|
+
if (anchorIdx === null) {
|
|
1654
|
+
for (let i = 0; i < turns.length; i++) {
|
|
1655
|
+
if (turns[i].ts && anchorTs && turns[i].ts <= anchorTs) {
|
|
1656
|
+
anchorIdx = i;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
if (anchorIdx === null) return [];
|
|
1662
|
+
|
|
1663
|
+
const startIdx = Math.max(0, anchorIdx - windowSize);
|
|
1664
|
+
return turns.slice(startIdx, anchorIdx + 1);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function extractFilesFromTurn(event) {
|
|
1668
|
+
const files = new Set();
|
|
1669
|
+
const content = (event.message || {}).content || '';
|
|
1670
|
+
const filePattern = /[\w/.-]+\.(?:py|js|ts|md|json|yaml|yml)/g;
|
|
1671
|
+
|
|
1672
|
+
if (Array.isArray(content)) {
|
|
1673
|
+
for (const block of content) {
|
|
1674
|
+
if (typeof block === 'object' && block !== null) {
|
|
1675
|
+
if (block.type === 'tool_use') {
|
|
1676
|
+
const inp = block.input || {};
|
|
1677
|
+
if (inp.file_path) files.add(inp.file_path);
|
|
1678
|
+
if (inp.path) files.add(inp.path);
|
|
1679
|
+
if (inp.command) {
|
|
1680
|
+
const matches = inp.command.match(filePattern) || [];
|
|
1681
|
+
for (const m of matches) files.add(m);
|
|
1682
|
+
}
|
|
1683
|
+
} else if (block.type === 'tool_result') {
|
|
1684
|
+
const text = String(block.content || '');
|
|
1685
|
+
const matches = text.match(filePattern) || [];
|
|
1686
|
+
for (const m of matches) files.add(m);
|
|
1687
|
+
} else if (block.type === 'text') {
|
|
1688
|
+
const text = block.text || '';
|
|
1689
|
+
const matches = text.match(filePattern) || [];
|
|
1690
|
+
for (const m of matches) files.add(m);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
} else if (typeof content === 'string') {
|
|
1695
|
+
const matches = content.match(filePattern) || [];
|
|
1696
|
+
for (const m of matches) files.add(m);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
return files;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function extractToolsFromTurn(event) {
|
|
1703
|
+
const tools = [];
|
|
1704
|
+
const content = (event.message || {}).content || '';
|
|
1705
|
+
|
|
1706
|
+
if (Array.isArray(content)) {
|
|
1707
|
+
for (const block of content) {
|
|
1708
|
+
if (typeof block === 'object' && block !== null) {
|
|
1709
|
+
if (block.type === 'tool_use') {
|
|
1710
|
+
const toolName = block.name || 'unknown';
|
|
1711
|
+
tools.push({ tool: toolName, action: 'call' });
|
|
1712
|
+
} else if (block.type === 'tool_result') {
|
|
1713
|
+
const result = String(block.content || '');
|
|
1714
|
+
if (result.includes('Exit code 0')) {
|
|
1715
|
+
tools.push({ tool: 'result', action: 'success' });
|
|
1716
|
+
} else if (/Exit code [1-9]|Traceback|Error/.test(result)) {
|
|
1717
|
+
tools.push({ tool: 'result', action: 'error' });
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
return tools;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function extractErrorsFromTurn(event) {
|
|
1728
|
+
const errors = [];
|
|
1729
|
+
const content = (event.message || {}).content || '';
|
|
1730
|
+
|
|
1731
|
+
if (Array.isArray(content)) {
|
|
1732
|
+
for (const block of content) {
|
|
1733
|
+
if (typeof block === 'object' && block !== null && block.type === 'tool_result') {
|
|
1734
|
+
const result = String(block.content || '');
|
|
1735
|
+
if (/Exit code [1-9]|Traceback|Error|error:/i.test(result)) {
|
|
1736
|
+
const lines = result.split('\n');
|
|
1737
|
+
for (const line of lines.slice(0, 5)) {
|
|
1738
|
+
if (/Error|error:|Traceback|Exit code [1-9]/i.test(line)) {
|
|
1739
|
+
errors.push(line.trim().slice(0, 200));
|
|
1740
|
+
break;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
return errors;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function extractUserMessage(event) {
|
|
1752
|
+
const content = (event.message || {}).content || '';
|
|
1753
|
+
|
|
1754
|
+
let text = '';
|
|
1755
|
+
if (typeof content === 'string') {
|
|
1756
|
+
text = content;
|
|
1757
|
+
} else if (Array.isArray(content)) {
|
|
1758
|
+
for (const block of content) {
|
|
1759
|
+
if (typeof block === 'object' && block !== null && block.type === 'text') {
|
|
1760
|
+
text = block.text || '';
|
|
1761
|
+
break;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// Filter out system-injected markup (not real user messages)
|
|
1767
|
+
if (!text) return '';
|
|
1768
|
+
const trimmed = text.trim();
|
|
1769
|
+
if (trimmed.startsWith('<local-command-caveat>')) return '';
|
|
1770
|
+
if (trimmed.startsWith('<command-message>')) return '';
|
|
1771
|
+
if (trimmed.startsWith('<command-name>')) return '';
|
|
1772
|
+
if (trimmed.startsWith('<system-reminder>')) return '';
|
|
1773
|
+
if (trimmed.startsWith('<local-command-stdout>')) return '';
|
|
1774
|
+
|
|
1775
|
+
return text.slice(0, 500);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// =============================================================================
|
|
1779
|
+
// ANTIGEN EXTRACT - analyze_bad_session
|
|
1780
|
+
// =============================================================================
|
|
1781
|
+
|
|
1782
|
+
function analyzeBadSession(sessionFile, analysis, signals) {
|
|
1783
|
+
const sessionId = analysis.session_id;
|
|
1784
|
+
|
|
1785
|
+
const anchorSignals = [
|
|
1786
|
+
'user_intervention',
|
|
1787
|
+
'session_abandoned',
|
|
1788
|
+
'false_success',
|
|
1789
|
+
'interrupt_cascade',
|
|
1790
|
+
];
|
|
1791
|
+
|
|
1792
|
+
let anchors = signals.filter(s => s.session === sessionId && anchorSignals.includes(s.signal));
|
|
1793
|
+
|
|
1794
|
+
if (anchors.length === 0) {
|
|
1795
|
+
const sessionSignals = signals.filter(s => s.session === sessionId);
|
|
1796
|
+
if (sessionSignals.length > 0) {
|
|
1797
|
+
anchors = [sessionSignals[sessionSignals.length - 1]];
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const candidates = [];
|
|
1802
|
+
|
|
1803
|
+
for (const anchor of anchors) {
|
|
1804
|
+
const anchorTs = anchor.ts || '';
|
|
1805
|
+
const anchorSignal = anchor.signal || 'unknown';
|
|
1806
|
+
|
|
1807
|
+
const window = extractContextWindow(sessionFile, anchorTs, 5);
|
|
1808
|
+
if (window.length === 0) continue;
|
|
1809
|
+
|
|
1810
|
+
const allFiles = new Set();
|
|
1811
|
+
const allTools = [];
|
|
1812
|
+
const allErrors = [];
|
|
1813
|
+
const userMessagesArr = [];
|
|
1814
|
+
|
|
1815
|
+
for (const turn of window) {
|
|
1816
|
+
const event = turn.event;
|
|
1817
|
+
for (const f of extractFilesFromTurn(event)) allFiles.add(f);
|
|
1818
|
+
allTools.push(...extractToolsFromTurn(event));
|
|
1819
|
+
allErrors.push(...extractErrorsFromTurn(event));
|
|
1820
|
+
if (turn.type === 'user') {
|
|
1821
|
+
const msg = extractUserMessage(event);
|
|
1822
|
+
if (msg && !msg.startsWith('[Request interrupted')) {
|
|
1823
|
+
userMessagesArr.push(msg);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Build tool sequence string
|
|
1829
|
+
const toolSeq = [];
|
|
1830
|
+
for (const t of allTools) {
|
|
1831
|
+
if (t.action === 'call') {
|
|
1832
|
+
toolSeq.push(t.tool);
|
|
1833
|
+
} else if (t.action === 'error') {
|
|
1834
|
+
if (toolSeq.length > 0) toolSeq[toolSeq.length - 1] += ':error';
|
|
1835
|
+
} else if (t.action === 'success') {
|
|
1836
|
+
if (toolSeq.length > 0) toolSeq[toolSeq.length - 1] += ':ok';
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// Extract keywords
|
|
1841
|
+
const keywords = new Set();
|
|
1842
|
+
for (const msg of userMessagesArr) {
|
|
1843
|
+
const words = (msg.toLowerCase().match(/\b[a-z]{4,}\b/g) || []).slice(0, 20);
|
|
1844
|
+
for (const w of words) keywords.add(w);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const common = new Set([
|
|
1848
|
+
'this', 'that', 'with', 'from', 'have', 'what', 'when', 'where',
|
|
1849
|
+
'which', 'there', 'their', 'would', 'could', 'should', 'about',
|
|
1850
|
+
'been', 'were', 'they', 'them', 'then', 'than', 'these', 'those',
|
|
1851
|
+
'some', 'into', 'only', 'other', 'also', 'just', 'more', 'very',
|
|
1852
|
+
'here', 'after', 'before', 'being', 'doing', 'make', 'made',
|
|
1853
|
+
'like', 'want', 'need', 'file', 'code',
|
|
1854
|
+
]);
|
|
1855
|
+
for (const c of common) keywords.delete(c);
|
|
1856
|
+
|
|
1857
|
+
const candidate = {
|
|
1858
|
+
session_id: sessionId,
|
|
1859
|
+
anchor_signal: anchorSignal,
|
|
1860
|
+
anchor_ts: anchorTs,
|
|
1861
|
+
peak_friction: (analysis.friction_summary || {}).peak || 0,
|
|
1862
|
+
turns_in_window: window.length,
|
|
1863
|
+
files: Array.from(allFiles).sort().slice(0, 10),
|
|
1864
|
+
tool_sequence: toolSeq.slice(0, 15),
|
|
1865
|
+
errors: allErrors.slice(0, 5),
|
|
1866
|
+
keywords: Array.from(keywords).sort().slice(0, 15),
|
|
1867
|
+
user_context: userMessagesArr.slice(0, 3),
|
|
1868
|
+
inhibitory_instruction: '# TODO: Write prevention instruction based on pattern above',
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
candidates.push(candidate);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
return candidates;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// =============================================================================
|
|
1878
|
+
// ANTIGEN EXTRACT - clusterCandidates
|
|
1879
|
+
// =============================================================================
|
|
1880
|
+
|
|
1881
|
+
function clusterCandidates(allCandidates) {
|
|
1882
|
+
const signalWeights = {
|
|
1883
|
+
user_intervention: 10,
|
|
1884
|
+
session_abandoned: 10,
|
|
1885
|
+
false_success: 8,
|
|
1886
|
+
no_resolution: 8,
|
|
1887
|
+
interrupt_cascade: 5,
|
|
1888
|
+
tool_loop: 6,
|
|
1889
|
+
rapid_exit: 6,
|
|
1890
|
+
};
|
|
1891
|
+
|
|
1892
|
+
const clusterMap = {};
|
|
1893
|
+
|
|
1894
|
+
for (const c of allCandidates) {
|
|
1895
|
+
// Normalize tool sequence: strip :error/:ok suffixes for grouping
|
|
1896
|
+
const toolNorm = c.tool_sequence
|
|
1897
|
+
.map(t => t.replace(/:error|:ok/g, ''))
|
|
1898
|
+
.join(',') || '(none)';
|
|
1899
|
+
const key = c.anchor_signal + '|' + toolNorm;
|
|
1900
|
+
|
|
1901
|
+
if (!(key in clusterMap)) {
|
|
1902
|
+
clusterMap[key] = {
|
|
1903
|
+
anchor_signal: c.anchor_signal,
|
|
1904
|
+
tool_pattern: toolNorm,
|
|
1905
|
+
count: 0,
|
|
1906
|
+
sessions: {},
|
|
1907
|
+
contexts: [],
|
|
1908
|
+
errors: [],
|
|
1909
|
+
files: {},
|
|
1910
|
+
keywords: {},
|
|
1911
|
+
peaks: [],
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const cl = clusterMap[key];
|
|
1916
|
+
cl.count++;
|
|
1917
|
+
cl.sessions[c.session_id] = true;
|
|
1918
|
+
cl.peaks.push(c.peak_friction);
|
|
1919
|
+
|
|
1920
|
+
// Collect unique user contexts (up to 5 per cluster, deduplicated)
|
|
1921
|
+
if (c.user_context.length > 0 && cl.contexts.length < 5) {
|
|
1922
|
+
for (const ctx of c.user_context.slice(0, 1)) {
|
|
1923
|
+
if (ctx.length > 10 && !cl.contexts.includes(ctx)) cl.contexts.push(ctx);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// Collect unique errors (up to 5 per cluster)
|
|
1928
|
+
if (c.errors.length > 0 && cl.errors.length < 5) {
|
|
1929
|
+
for (const err of c.errors.slice(0, 1)) {
|
|
1930
|
+
if (!cl.errors.includes(err)) cl.errors.push(err);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// Tally files and keywords
|
|
1935
|
+
for (const f of c.files) cl.files[f] = (cl.files[f] || 0) + 1;
|
|
1936
|
+
for (const kw of c.keywords) cl.keywords[kw] = (cl.keywords[kw] || 0) + 1;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// Score and sort clusters
|
|
1940
|
+
const clusters = Object.values(clusterMap).map(cl => {
|
|
1941
|
+
const weight = signalWeights[cl.anchor_signal] || 1;
|
|
1942
|
+
const peaks = cl.peaks.sort((a, b) => a - b);
|
|
1943
|
+
return {
|
|
1944
|
+
anchor_signal: cl.anchor_signal,
|
|
1945
|
+
tool_pattern: cl.tool_pattern,
|
|
1946
|
+
count: cl.count,
|
|
1947
|
+
score: cl.count * weight,
|
|
1948
|
+
sessions: Object.keys(cl.sessions).length,
|
|
1949
|
+
median_peak: peaks[Math.floor(peaks.length / 2)],
|
|
1950
|
+
max_peak: peaks[peaks.length - 1],
|
|
1951
|
+
contexts: cl.contexts,
|
|
1952
|
+
errors: cl.errors,
|
|
1953
|
+
top_files: sortedEntries(cl.files).slice(0, 5).map(([f]) => f),
|
|
1954
|
+
top_keywords: sortedEntries(cl.keywords).slice(0, 10).map(([k]) => k),
|
|
1955
|
+
};
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
clusters.sort((a, b) => b.score - a.score);
|
|
1959
|
+
return clusters;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// =============================================================================
|
|
1963
|
+
// ANTIGEN EXTRACT - main
|
|
1964
|
+
// =============================================================================
|
|
1965
|
+
|
|
1966
|
+
function extractMain(sessionsDir) {
|
|
1967
|
+
// Load friction analysis
|
|
1968
|
+
const analysisFile = '.claude/friction/friction_analysis.json';
|
|
1969
|
+
if (!fs.existsSync(analysisFile)) {
|
|
1970
|
+
console.log('Error: Run friction analysis first to generate .claude/friction/friction_analysis.json');
|
|
1971
|
+
return 1;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
const analyses = JSON.parse(fs.readFileSync(analysisFile, 'utf-8'));
|
|
1975
|
+
|
|
1976
|
+
// Load raw signals
|
|
1977
|
+
const rawFile = '.claude/friction/friction_raw.jsonl';
|
|
1978
|
+
let signals = [];
|
|
1979
|
+
if (fs.existsSync(rawFile)) {
|
|
1980
|
+
const rawContent = fs.readFileSync(rawFile, 'utf-8');
|
|
1981
|
+
signals = rawContent.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// Find BAD sessions
|
|
1985
|
+
const badSessions = analyses.filter(a => a.quality === 'BAD');
|
|
1986
|
+
|
|
1987
|
+
if (badSessions.length === 0) {
|
|
1988
|
+
console.log('No BAD sessions found. Nothing to extract.');
|
|
1989
|
+
return 0;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
console.log(`Extracting antigens from ${badSessions.length} BAD sessions...\n`);
|
|
1993
|
+
|
|
1994
|
+
// Extract antigens
|
|
1995
|
+
const allCandidates = [];
|
|
1996
|
+
const failed = [];
|
|
1997
|
+
|
|
1998
|
+
const sortedBad = badSessions.slice().sort((a, b) =>
|
|
1999
|
+
((b.friction_summary || {}).peak || 0) - ((a.friction_summary || {}).peak || 0)
|
|
2000
|
+
);
|
|
2001
|
+
|
|
2002
|
+
for (const analysis of sortedBad) {
|
|
2003
|
+
const sessionId = analysis.session_id;
|
|
2004
|
+
const sessionFile = findSessionFile(sessionsDir, sessionId);
|
|
2005
|
+
|
|
2006
|
+
if (!sessionFile) {
|
|
2007
|
+
failed.push(sessionId);
|
|
2008
|
+
continue;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
const candidates = analyzeBadSession(sessionFile, analysis, signals);
|
|
2012
|
+
allCandidates.push(...candidates);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// Cluster candidates by (anchor_signal, tool_pattern)
|
|
2016
|
+
const clusters = clusterCandidates(allCandidates);
|
|
2017
|
+
|
|
2018
|
+
// Terminal output
|
|
2019
|
+
console.log(`\u2713 ${allCandidates.length} raw candidates \u2192 ${clusters.length} clusters`);
|
|
2020
|
+
const top5 = clusters.slice(0, 5);
|
|
2021
|
+
for (const cl of top5) {
|
|
2022
|
+
console.log(` ${String(cl.count).padStart(3)}x ${cl.anchor_signal} | ${cl.tool_pattern} (${cl.sessions} sessions, score: ${cl.score})`);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
if (failed.length > 0) {
|
|
2026
|
+
console.log(`\n\u26A0 Could not find session files for ${failed.length} sessions`);
|
|
2027
|
+
}
|
|
2028
|
+
console.log();
|
|
2029
|
+
|
|
2030
|
+
// Save outputs
|
|
2031
|
+
const outputDir = '.claude/friction';
|
|
2032
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
2033
|
+
|
|
2034
|
+
// Raw candidates (kept for debugging)
|
|
2035
|
+
fs.writeFileSync(path.join(outputDir, 'antigen_candidates.json'), JSON.stringify(allCandidates, null, 2));
|
|
2036
|
+
|
|
2037
|
+
// Clustered output (primary artifact)
|
|
2038
|
+
fs.writeFileSync(path.join(outputDir, 'antigen_clusters.json'), JSON.stringify(clusters, null, 2));
|
|
2039
|
+
|
|
2040
|
+
// Clustered review markdown (top 25)
|
|
2041
|
+
const maxClusters = 25;
|
|
2042
|
+
const reviewClusters = clusters.slice(0, maxClusters);
|
|
2043
|
+
const reviewLines = [];
|
|
2044
|
+
|
|
2045
|
+
reviewLines.push('# Friction Antigen Clusters\n\n');
|
|
2046
|
+
reviewLines.push(`Generated: ${new Date().toISOString()}\n`);
|
|
2047
|
+
reviewLines.push(`BAD sessions: ${badSessions.length} | Raw candidates: ${allCandidates.length} | Clusters: ${clusters.length}\n\n`);
|
|
2048
|
+
|
|
2049
|
+
// Summary table
|
|
2050
|
+
reviewLines.push('## Cluster Summary\n\n');
|
|
2051
|
+
reviewLines.push('| # | Signal | Tool Pattern | Count | Sessions | Score | Median Peak |\n');
|
|
2052
|
+
reviewLines.push('|---|--------|-------------|-------|----------|-------|-------------|\n');
|
|
2053
|
+
reviewClusters.forEach((cl, idx) => {
|
|
2054
|
+
reviewLines.push(`| ${idx + 1} | ${cl.anchor_signal} | ${cl.tool_pattern} | ${cl.count} | ${cl.sessions} | ${cl.score} | ${cl.median_peak} |\n`);
|
|
2055
|
+
});
|
|
2056
|
+
reviewLines.push('\n---\n\n');
|
|
2057
|
+
|
|
2058
|
+
// Detailed clusters
|
|
2059
|
+
reviewClusters.forEach((cl, idx) => {
|
|
2060
|
+
reviewLines.push(`## Cluster ${idx + 1}: ${cl.anchor_signal} | ${cl.tool_pattern}\n\n`);
|
|
2061
|
+
reviewLines.push(`**Occurrences:** ${cl.count} across ${cl.sessions} sessions | **Score:** ${cl.score} | **Median peak:** ${cl.median_peak} | **Max peak:** ${cl.max_peak}\n\n`);
|
|
2062
|
+
|
|
2063
|
+
if (cl.contexts.length > 0) {
|
|
2064
|
+
reviewLines.push('### User Context (what the user said)\n\n');
|
|
2065
|
+
for (const ctx of cl.contexts.slice(0, 3)) {
|
|
2066
|
+
const truncated = ctx.length > 300 ? ctx.slice(0, 300) + '...' : ctx;
|
|
2067
|
+
reviewLines.push(`> ${truncated}\n\n`);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
if (cl.errors.length > 0) {
|
|
2072
|
+
reviewLines.push('### Errors\n\n');
|
|
2073
|
+
reviewLines.push('```\n');
|
|
2074
|
+
for (const err of cl.errors.slice(0, 3)) {
|
|
2075
|
+
reviewLines.push(`${err}\n`);
|
|
2076
|
+
}
|
|
2077
|
+
reviewLines.push('```\n\n');
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
if (cl.top_files.length > 0) {
|
|
2081
|
+
reviewLines.push('### Files involved\n\n');
|
|
2082
|
+
for (const f of cl.top_files) {
|
|
2083
|
+
reviewLines.push(`- \`${f}\`\n`);
|
|
2084
|
+
}
|
|
2085
|
+
reviewLines.push('\n');
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
if (cl.top_keywords.length > 0) {
|
|
2089
|
+
reviewLines.push(`**Keywords:** ${cl.top_keywords.join(', ')}\n\n`);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
reviewLines.push('---\n\n');
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
fs.writeFileSync(path.join(outputDir, 'antigen_review.md'), reviewLines.join(''));
|
|
2096
|
+
console.log('Output: .claude/friction/antigen_review.md\n');
|
|
2097
|
+
|
|
2098
|
+
return 0;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
// =============================================================================
|
|
2102
|
+
// PIPELINE ENTRY POINT
|
|
2103
|
+
// =============================================================================
|
|
2104
|
+
|
|
2105
|
+
function main() {
|
|
2106
|
+
if (process.argv.length < 3) {
|
|
2107
|
+
console.log(`
|
|
2108
|
+
Friction analysis pipeline - analyze sessions and extract antigens.
|
|
2109
|
+
|
|
2110
|
+
Usage:
|
|
2111
|
+
node friction.js <sessions-directory>
|
|
2112
|
+
node friction.js ~/.claude/projects/-home-hamr-PycharmProjects-liteagents/
|
|
2113
|
+
|
|
2114
|
+
Outputs (all in .claude/friction/):
|
|
2115
|
+
friction_analysis.json - Per-session analysis
|
|
2116
|
+
friction_summary.json - Aggregate stats
|
|
2117
|
+
friction_raw.jsonl - Raw signals
|
|
2118
|
+
antigen_candidates.json - Raw antigen candidates
|
|
2119
|
+
antigen_clusters.json - Clustered antigen patterns
|
|
2120
|
+
antigen_review.md - Clustered review file
|
|
2121
|
+
`);
|
|
2122
|
+
return 1;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
const sessionsDir = process.argv[2];
|
|
2126
|
+
|
|
2127
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
2128
|
+
console.log(`Error: ${sessionsDir} does not exist`);
|
|
2129
|
+
return 1;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
console.log('='.repeat(60));
|
|
2133
|
+
console.log(' FRICTION ANALYSIS PIPELINE');
|
|
2134
|
+
console.log('='.repeat(60));
|
|
2135
|
+
|
|
2136
|
+
// Step 1: Analyze sessions
|
|
2137
|
+
console.log('\n[1/2] Analyzing sessions...\n');
|
|
2138
|
+
analyzeMain(sessionsDir);
|
|
2139
|
+
|
|
2140
|
+
// Check if analysis produced output
|
|
2141
|
+
const analysisFile = '.claude/friction/friction_analysis.json';
|
|
2142
|
+
if (!fs.existsSync(analysisFile)) {
|
|
2143
|
+
console.log('\nNo analysis output. Check session directory.');
|
|
2144
|
+
return 1;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// Step 2: Extract antigens
|
|
2148
|
+
console.log('\n' + '='.repeat(60));
|
|
2149
|
+
console.log('\n[2/2] Extracting antigens from BAD sessions...\n');
|
|
2150
|
+
extractMain(sessionsDir);
|
|
2151
|
+
|
|
2152
|
+
// Final summary
|
|
2153
|
+
console.log('\n' + '='.repeat(60));
|
|
2154
|
+
console.log(' DONE');
|
|
2155
|
+
console.log('='.repeat(60));
|
|
2156
|
+
|
|
2157
|
+
const reviewFile = '.claude/friction/antigen_review.md';
|
|
2158
|
+
if (fs.existsSync(reviewFile)) {
|
|
2159
|
+
console.log('\nReview your antigens:');
|
|
2160
|
+
console.log(` cat ${reviewFile}`);
|
|
2161
|
+
console.log('\nOr feed to LLM:');
|
|
2162
|
+
console.log(` cat ${reviewFile} | claude "write CLAUDE.md rules to prevent these patterns"`);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
return 0;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
process.exit(main());
|