liteagents 2.8.2 → 2.9.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.
@@ -26,22 +26,25 @@ const path = require('path');
26
26
 
27
27
  const CONFIG = {
28
28
  weights: {
29
- exit_error: 1,
30
- exit_success: 0,
31
- user_curse: 5,
32
- user_negation: 0.5,
33
- user_intervention: 10,
29
+ // OBSERVED (primary) — the user reacted; hard to fake, high trust.
30
+ user_correction: 8,
31
+ user_curse: 8,
32
+ interrupt_cascade: 8,
34
33
  tool_loop: 6,
35
- false_success: 8,
36
- request_interrupted: 2.5,
34
+ repeated_question: 5,
35
+ request_interrupted: 3,
36
+ // INFERRED (corroboration only) — machine proxies; noisy, never seed alone.
37
+ exit_error: 0.5,
38
+ false_success: 1,
39
+ no_resolution: 0.5,
40
+ session_abandoned: 1,
41
+ user_intervention: 1,
42
+ rapid_exit: 1,
37
43
  long_silence: 0.5,
38
- repeated_question: 1,
39
44
  compaction: 0.5,
40
- interrupt_cascade: 5,
41
- rapid_exit: 6,
42
- no_resolution: 8,
43
- session_abandoned: 10,
44
45
  sibling_tool_error: 0.5,
46
+ exit_success: 0,
47
+ checkpoint: 0, // a gated stash/abandon/silence with no preceding reaction — routine, ignored
45
48
  },
46
49
  thresholds: {
47
50
  friction_peak: 15,
@@ -196,6 +199,31 @@ function extractToolNameFromResult(result) {
196
199
  return match ? match[1] : 'unknown';
197
200
  }
198
201
 
202
+ // A user turn that is mostly pasted shell prompts/output (SSH session dumps,
203
+ // command logs) is context the user pasted — not a reaction to the agent.
204
+ // Treating it as friction pollutes antigens (e.g. keywords like "postconf",
205
+ // "sendmail"). A *prompted* command line ("> sudo …", "$ git …") is an
206
+ // unambiguous paste even at 2 lines; otherwise require shell lines to dominate
207
+ // a 3+ line block, so a real 2-line correction ("no\nls the logs please")
208
+ // stays a correction.
209
+ const SHELL_CMD = /(sudo|ls|cd|cat|rm|cp|mv|mkdir|chmod|chown|export|source|ssh|scp|sed|awk|grep|echo|curl|wget|tar|systemctl|service|journalctl|apt|apt-get|dpkg|yum|dnf|rpm|npm|npx|node|pip|git|docker|postconf|postfix|opendkim|certbot|nginx|dig|host|nslookup|ping|traceroute|df|du|free|ps|uname|tail|head)\b/;
210
+ const SHELL_OUT = /(No such file or directory|command not found|cannot access|Permission denied|Exit code\s*\d|Traceback \(most recent|: line \d+:|^E: )/;
211
+ function looksLikeTerminalPaste(text) {
212
+ if (typeof text !== 'string') return false;
213
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
214
+ if (lines.length < 2) return false;
215
+ const prompted = lines.filter(l =>
216
+ /^[\w.-]+@[\w.-]+:\S*[#$]/.test(l) || // host prompt: "root@terribic:~#"
217
+ (/^[>$#]\s+/.test(l) && SHELL_CMD.test(l)) // "> dig …", "$ npm ci"
218
+ ).length;
219
+ if (prompted >= 1) return true;
220
+ if (lines.length < 3) return false;
221
+ const shellish = lines.filter(l =>
222
+ new RegExp('^' + SHELL_CMD.source).test(l) || SHELL_OUT.test(l)
223
+ ).length;
224
+ return shellish / lines.length >= 0.5;
225
+ }
226
+
199
227
  // =============================================================================
200
228
  // FRICTION ANALYZE - extract_signals
201
229
  // =============================================================================
@@ -419,20 +447,34 @@ function extractSignals(sessionFile) {
419
447
  });
420
448
  }
421
449
 
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
- });
450
+ const isPaste = looksLikeTerminalPaste(content);
451
+
452
+ // Curse only counts as friction when aimed at the agent's work — not
453
+ // rhetorical/narrative profanity ("does anyone search any shit?", a
454
+ // pasted reddit story). Keep it when the turn is a short reaction, or
455
+ // when an agent-directed token sits next to the curse; otherwise the
456
+ // profanity is buried in a long narrative and isn't friction.
457
+ const curseMatch = content.match(/\b(fuck|shit|damn)\b/i);
458
+ if (curseMatch && !isPaste) {
459
+ const i = curseMatch.index;
460
+ const near = content.slice(Math.max(0, i - 40), i + 40);
461
+ const shortReaction = content.trim().length <= 120;
462
+ const directed = /\b(you|your|you'?ve|you'?re|stop|quit|keep|again|why)\b/i.test(near);
463
+ if (shortReaction || directed) {
464
+ signals.push({
465
+ ts,
466
+ source: 'user',
467
+ signal: 'user_curse',
468
+ details: content.slice(0, 50),
469
+ });
470
+ }
429
471
  }
430
472
 
431
- if (isInteractive && /\b(no|didn't work|still broken)\b/i.test(content)) {
473
+ if (!isPaste && isInteractive && /^\s*(no\b|nope\b|wrong\b|don'?t\b|didn'?t work|that'?s not|not what|stop\b|revert\b|undo\b|still broken)/i.test(content)) {
432
474
  signals.push({
433
475
  ts,
434
476
  source: 'user',
435
- signal: 'user_negation',
477
+ signal: 'user_correction',
436
478
  details: content.slice(0, 50),
437
479
  });
438
480
  }
@@ -578,7 +620,7 @@ function extractSignals(sessionFile) {
578
620
  const frictionWeights = {
579
621
  exit_error: 1,
580
622
  user_curse: 5,
581
- user_negation: 1,
623
+ user_correction: 1,
582
624
  tool_loop: 6,
583
625
  false_success: 8,
584
626
  request_interrupted: 4,
@@ -625,6 +667,29 @@ function extractSignals(sessionFile) {
625
667
  });
626
668
  }
627
669
 
670
+ // FIX #1/#2: stash, abandonment and silence are "unresolved markers", not
671
+ // friction on their own. A clean-start stash, a context-switch, or an idle gap
672
+ // is routine. They only count when a real user reaction (correction / curse /
673
+ // interrupt) preceded them in the recent signals — i.e. a frustrated thread
674
+ // that was then dropped. Otherwise demote to a zero-weight checkpoint.
675
+ const GATED = new Set(['user_intervention', 'session_abandoned', 'long_silence']);
676
+ const REACTION = new Set(['user_correction', 'user_curse', 'interrupt_cascade']);
677
+ for (let i = 0; i < finalSignals.length; i++) {
678
+ if (!GATED.has(finalSignals[i].signal)) continue;
679
+ let precededByReaction = false;
680
+ for (let j = i - 1, seen = 0; j >= 0 && seen < 8; j--, seen++) {
681
+ if (REACTION.has(finalSignals[j].signal)) { precededByReaction = true; break; }
682
+ }
683
+ if (!precededByReaction) {
684
+ finalSignals[i] = {
685
+ ...finalSignals[i],
686
+ signal: 'checkpoint',
687
+ gated_from: finalSignals[i].signal,
688
+ details: 'routine/idle — no preceding user reaction',
689
+ };
690
+ }
691
+ }
692
+
628
693
  return [finalSignals, metadata];
629
694
  }
630
695
 
@@ -1782,21 +1847,20 @@ function extractUserMessage(event) {
1782
1847
  function analyzeBadSession(sessionFile, analysis, signals) {
1783
1848
  const sessionId = analysis.session_id;
1784
1849
 
1850
+ // NEW: anchor antigens only on OBSERVED user-reaction signals. Inferred
1851
+ // proxies (false_success/session_abandoned/user_intervention) never seed —
1852
+ // they only color severity. No fallback: a session with no observed reaction
1853
+ // produces no candidate (silence is not an antigen).
1854
+ // Seed only on genuine USER REACTIONS. tool_loop / repeated_question are
1855
+ // agent-behavior signals (no user text to cluster, and repeated_question
1856
+ // over-fires on tool output) — they corroborate severity, never seed.
1785
1857
  const anchorSignals = [
1786
- 'user_intervention',
1787
- 'session_abandoned',
1788
- 'false_success',
1858
+ 'user_correction',
1859
+ 'user_curse',
1789
1860
  'interrupt_cascade',
1790
1861
  ];
1791
1862
 
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
- }
1863
+ const anchors = signals.filter(s => s.session === sessionId && anchorSignals.includes(s.signal));
1800
1864
 
1801
1865
  const candidates = [];
1802
1866
 
@@ -1819,7 +1883,7 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1819
1883
  allErrors.push(...extractErrorsFromTurn(event));
1820
1884
  if (turn.type === 'user') {
1821
1885
  const msg = extractUserMessage(event);
1822
- if (msg && !msg.startsWith('[Request interrupted')) {
1886
+ if (msg && !msg.startsWith('[Request interrupted') && !looksLikeTerminalPaste(msg)) {
1823
1887
  userMessagesArr.push(msg);
1824
1888
  }
1825
1889
  }
@@ -1854,6 +1918,25 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1854
1918
  ]);
1855
1919
  for (const c of common) keywords.delete(c);
1856
1920
 
1921
+ // FIX #4: capture the agent's last action + result just before the reaction.
1922
+ // Often it's a *claimed* success (exit 0) the user is contradicting, not a
1923
+ // crash — so record the action and whether it reported ok/error, plus any
1924
+ // error line. This is the technical half of the antigen (the trigger).
1925
+ const calls = allTools.filter(t => t.action === 'call').map(t => t.tool);
1926
+ const sawError = allTools.some(t => t.action === 'error');
1927
+ const sawSuccess = allTools.some(t => t.action === 'success');
1928
+ const preceding = {
1929
+ action: calls.slice(-2).join(' → ') || 'none',
1930
+ result: sawError ? 'error' : (sawSuccess ? 'claimed success (exit 0)' : 'unknown'),
1931
+ error: allErrors[allErrors.length - 1] || null,
1932
+ };
1933
+
1934
+ // FIX #3: self/context corrections ("wrong project", "nevermind", "my bad")
1935
+ // are the user redirecting THEMSELVES, not an antigen against the agent. Flag
1936
+ // them so the clusterer won't mark them severe; the LLM makes the final call.
1937
+ const selfPhrase = /\b(wrong (project|window|repo|directory|folder)|never ?mind|nvm|scratch that|ignore (that|this)|disregard|my bad|oops)\b/i;
1938
+ const self_suspect = userMessagesArr.some(m => selfPhrase.test(m));
1939
+
1857
1940
  const candidate = {
1858
1941
  session_id: sessionId,
1859
1942
  anchor_signal: anchorSignal,
@@ -1863,6 +1946,8 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1863
1946
  files: Array.from(allFiles).sort().slice(0, 10),
1864
1947
  tool_sequence: toolSeq.slice(0, 15),
1865
1948
  errors: allErrors.slice(0, 5),
1949
+ preceding,
1950
+ self_suspect,
1866
1951
  keywords: Array.from(keywords).sort().slice(0, 15),
1867
1952
  user_context: userMessagesArr.slice(0, 3),
1868
1953
  inhibitory_instruction: '# TODO: Write prevention instruction based on pattern above',
@@ -1879,93 +1964,208 @@ function analyzeBadSession(sessionFile, analysis, signals) {
1879
1964
  // =============================================================================
1880
1965
 
1881
1966
  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,
1967
+ // NEW: cluster by CONTENT (keyword overlap of what the user actually said),
1968
+ // not by (anchor_signal, tool_pattern). Inferred signals were already barred
1969
+ // from seeding upstream; here they survive only as corroborating "errors"
1970
+ // that color a cluster's severity. Recurrence across sessions is the score.
1971
+ const SIM = 0.5; // overlap-coefficient threshold to join a content cluster
1972
+
1973
+ // Ubiquitous path/file tokens that carry no topical meaning — if we cluster on
1974
+ // these we re-create OLD's over-merge (everything touches README/package.json).
1975
+ const PATH_STOP = new Set([
1976
+ 'home', 'hamr', 'documents', 'pycharmprojects', 'projects', 'claude', 'stash',
1977
+ 'memory', 'commands', 'command', 'skills', 'skill', 'src', 'lib', 'app', 'dist',
1978
+ 'build', 'node_modules', 'public', 'assets', 'utils', 'util', 'config', 'scripts',
1979
+ 'readme', 'package', 'index', 'main', 'test', 'tests', 'spec', 'lock',
1980
+ 'components', 'component', 'styles', 'style', 'types', 'data', 'templates',
1981
+ 'md', 'js', 'ts', 'jsx', 'tsx', 'py', 'sh', 'txt', 'html', 'css', 'json',
1982
+ 'yaml', 'yml', 'toml', 'env', 'log', 'tmp',
1983
+ ]);
1984
+
1985
+ // English fillers to drop from phrase matching (path tokens use PATH_STOP).
1986
+ const STOP = new Set([
1987
+ 'the', 'and', 'you', 'for', 'not', 'but', 'was', 'are', 'get', 'use', 'one', 'out',
1988
+ 'can', 'all', 'any', 'has', 'had', 'have', 'this', 'that', 'with', 'from', 'what',
1989
+ 'when', 'where', 'which', 'there', 'their', 'would', 'could', 'should', 'about',
1990
+ 'been', 'were', 'they', 'them', 'then', 'than', 'these', 'those', 'some', 'into',
1991
+ 'only', 'other', 'also', 'just', 'more', 'very', 'here', 'after', 'before', 'being',
1992
+ 'doing', 'make', 'made', 'like', 'want', 'need', 'your', 'dont', 'did', 'does',
1993
+ 'done', 'now', 'yet', 'too', 'will', 'wont', 'cant', 'got', 'let',
1994
+ ]);
1995
+
1996
+ // Significant words in order (>=3 chars, not a filler / path token).
1997
+ const unigrams = (text) =>
1998
+ (String(text).toLowerCase().match(/\b[a-z']{3,}\b/g) || [])
1999
+ .filter(w => !STOP.has(w) && !PATH_STOP.has(w));
2000
+
2001
+ // SHINGLES = unigrams + adjacent bigrams (word proximity + phrase repetition).
2002
+ // A repeated phrase like "wrong project" scores as one shingle AND its two words.
2003
+ const shingles = (texts) => {
2004
+ const m = new Map();
2005
+ for (const t of texts) {
2006
+ const u = unigrams(t);
2007
+ for (const w of u) m.set(w, (m.get(w) || 0) + 1);
2008
+ for (let i = 0; i < u.length - 1; i++) {
2009
+ const bg = u[i] + ' ' + u[i + 1];
2010
+ m.set(bg, (m.get(bg) || 0) + 1);
2011
+ }
2012
+ }
2013
+ return m;
1890
2014
  };
1891
2015
 
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
- };
2016
+ const fileTokens = (sessionId, files) => {
2017
+ const proj = (sessionId || '').split('/')[0].toLowerCase();
2018
+ const out = [];
2019
+ for (const f of (files || [])) {
2020
+ for (const seg of String(f).toLowerCase().split(/[/._\-\s]+/)) {
2021
+ if (seg.length >= 4 && seg !== proj && !PATH_STOP.has(seg)) out.push(seg);
2022
+ }
1913
2023
  }
2024
+ return out;
2025
+ };
1914
2026
 
1915
- const cl = clusterMap[key];
1916
- cl.count++;
1917
- cl.sessions[c.session_id] = true;
1918
- cl.peaks.push(c.peak_friction);
2027
+ // Pick the quote from a session that best contains a cluster's seed phrase —
2028
+ // a multi-topic session should be shown by the line that actually matched.
2029
+ const bestQuote = (texts, seedSig) => {
2030
+ let best = texts[0] || '', bestN = -1;
2031
+ for (const t of texts) {
2032
+ const u = unigrams(t);
2033
+ let n = 0;
2034
+ for (const w of u) if (seedSig.has(w)) n++;
2035
+ for (let i = 0; i < u.length - 1; i++) if (seedSig.has(u[i] + ' ' + u[i + 1])) n++;
2036
+ if (n > bestN) { bestN = n; best = t; }
2037
+ }
2038
+ return best;
2039
+ };
1919
2040
 
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
- }
2041
+ // ---- Stage 1: INTRA-SESSION one consolidated signal per session ----
2042
+ // Frustration/repetition is short and reuses words; pool a session's reaction
2043
+ // texts so the repeated/overlapping part becomes that session's signal.
2044
+ const bySession = {};
2045
+ for (const c of allCandidates) {
2046
+ const id = c.session_id;
2047
+ if (!bySession[id]) bySession[id] = { id, texts: [], files: new Set(), signals: {}, errors: [], peak: 0, preceding: null, selfVotes: 0, total: 0 };
2048
+ const b = bySession[id];
2049
+ for (const m of (c.user_context || [])) if (m && m.length > 2) b.texts.push(m);
2050
+ for (const f of (c.files || [])) b.files.add(f);
2051
+ b.signals[c.anchor_signal] = (b.signals[c.anchor_signal] || 0) + 1;
2052
+ for (const e of (c.errors || [])) if (!b.errors.includes(e)) b.errors.push(e);
2053
+ b.peak = Math.max(b.peak, c.peak_friction || 0);
2054
+ b.total++;
2055
+ if (c.self_suspect) b.selfVotes++;
2056
+ // keep the most informative preceding action/error (#4)
2057
+ if (c.preceding && (!b.preceding || (c.preceding.action !== 'none' || c.preceding.error))) b.preceding = c.preceding;
2058
+ }
2059
+
2060
+ const sessionSignals = Object.values(bySession).map(b => {
2061
+ const sh = shingles(b.texts);
2062
+ if (sh.size < 2) { // terse/empty → fall back to file referent
2063
+ for (const seg of fileTokens(b.id, b.files)) sh.set(seg, (sh.get(seg) || 0) + 1);
1925
2064
  }
2065
+ return {
2066
+ id: b.id,
2067
+ sig: new Set(sh.keys()),
2068
+ signals: b.signals,
2069
+ errors: b.errors,
2070
+ peak: b.peak,
2071
+ texts: b.texts,
2072
+ preceding: b.preceding,
2073
+ anySelf: b.selfVotes > 0, // at least one self-correction → warn, LLM confirms target
2074
+ };
2075
+ });
1926
2076
 
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);
2077
+ // ---- Stage 2: CROSS-SESSION match session signals by shared shingles ----
2078
+ const clusters = [];
2079
+ for (const ss of sessionSignals) {
2080
+ // Merge on a shared PHRASE (bigram = word proximity) — the strong signal —
2081
+ // or on strong unigram overlap. A single generic shared word won't merge.
2082
+ let best = null, bestSim = 0;
2083
+ if (ss.sig.size >= 2) {
2084
+ for (const cl of clusters) {
2085
+ let bi = 0, uni = 0;
2086
+ for (const x of ss.sig) if (cl.seedSig.has(x)) { x.includes(' ') ? bi++ : uni++; }
2087
+ const sim = (bi + uni) / Math.min(ss.sig.size, cl.seedSig.size);
2088
+ const mergeable = bi >= 1 && sim >= SIM;
2089
+ if (mergeable && sim > bestSim) { bestSim = sim; best = cl; }
1931
2090
  }
1932
2091
  }
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
- // Session IDs follow "project/dateStr-shortId", so the first segment is
1944
- // the project. Preserve both the ID list and the deduped project set so
1945
- // downstream renderers can surface which repos produced the cluster.
2092
+ let cl;
2093
+ if (best) {
2094
+ cl = best;
2095
+ } else {
2096
+ cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null };
2097
+ clusters.push(cl);
2098
+ }
2099
+ for (const s of ss.sig) { cl.sig.add(s); cl.shCount.set(s, (cl.shCount.get(s) || 0) + 1); }
2100
+ cl.sessions[ss.id] = true;
2101
+ for (const [k, v] of Object.entries(ss.signals)) cl.signals[k] = (cl.signals[k] || 0) + v;
2102
+ const q = bestQuote(ss.texts, cl.seedSig);
2103
+ if (q && cl.contexts.length < 5 && !cl.contexts.includes(q)) cl.contexts.push(q);
2104
+ for (const e of ss.errors) if (!cl.errors.includes(e) && cl.errors.length < 5) cl.errors.push(e);
2105
+ cl.peaks.push(ss.peak);
2106
+ if (ss.anySelf) cl.anySelf = true;
2107
+ if (ss.preceding && (!cl.preceding || (ss.preceding.action !== 'none' || ss.preceding.error))) cl.preceding = ss.preceding;
2108
+ }
2109
+
2110
+ const out = clusters.map(cl => {
2111
+ const peaks = cl.peaks.slice().sort((a, b) => a - b);
2112
+ const topSh = [...cl.shCount.entries()]
2113
+ .sort((a, b) => b[1] - a[1] || b[0].length - a[0].length).map(([k]) => k);
1946
2114
  const sessionIds = Object.keys(cl.sessions);
1947
2115
  const projects = [...new Set(
1948
2116
  sessionIds.map(s => s.includes('/') ? s.split('/')[0] : 'unknown')
1949
2117
  )].sort();
2118
+ const nSessions = sessionIds.length;
2119
+ const signalNames = Object.keys(cl.signals);
2120
+ const dominant = sortedEntries(cl.signals)[0] ? sortedEntries(cl.signals)[0][0] : 'unknown';
2121
+
2122
+ // Severity: an explicit AGENT-directed reaction (curse / interrupt, or a
2123
+ // correction that isn't the user redirecting themselves) is severe; machine
2124
+ // corroboration (errors) also escalates. #3: judge self-correction from the
2125
+ // MATCHED quotes — a cluster whose grouping phrase is "wrong project" etc. is
2126
+ // the user redirecting themselves, not an antigen → not severe.
2127
+ const SELF_RE = /\b(wrong (project|window|repo|directory|folder)|never ?mind|nvm|scratch that|ignore (that|this)|disregard|my bad|oops)\b/i;
2128
+ const allSelf = cl.contexts.length > 0 && cl.contexts.every(q => SELF_RE.test(q || ''));
2129
+ const severe = signalNames.some(s => s === 'user_curse' || s === 'interrupt_cascade')
2130
+ || (signalNames.includes('user_correction') && !allSelf)
2131
+ || cl.errors.length > 0;
2132
+ const recurring = nSessions >= 3; // recurrence × severity → artifact (the 2×2)
2133
+ let artifact;
2134
+ if (recurring && severe) artifact = 'antigen';
2135
+ else if (recurring && !severe) artifact = 'fact';
2136
+ else if (!recurring && severe) artifact = 'episode';
2137
+ else artifact = 'drop';
2138
+ const confidence = nSessions >= 5 ? 'high' : nSessions >= 3 ? 'medium' : 'low';
2139
+ const theme = topSh.slice(0, 4).join(' / ') || '(thin)';
2140
+
1950
2141
  return {
1951
- anchor_signal: cl.anchor_signal,
1952
- tool_pattern: cl.tool_pattern,
1953
- count: cl.count,
1954
- score: cl.count * weight,
1955
- sessions: sessionIds.length,
2142
+ theme,
2143
+ suggested_artifact: artifact,
2144
+ confidence,
2145
+ severity: severe ? 'severe' : 'mild',
2146
+ signals: cl.signals,
2147
+ // backward-compat fields for existing renderers:
2148
+ anchor_signal: dominant,
2149
+ tool_pattern: `${artifact}/${severe ? 'severe' : 'mild'}`,
2150
+ count: nSessions,
2151
+ score: nSessions * (severe ? 2 : 1),
2152
+ sessions: nSessions,
1956
2153
  session_ids: sessionIds,
1957
- projects: projects,
2154
+ projects,
1958
2155
  median_peak: peaks[Math.floor(peaks.length / 2)],
1959
2156
  max_peak: peaks[peaks.length - 1],
1960
2157
  contexts: cl.contexts,
1961
2158
  errors: cl.errors,
1962
- top_files: sortedEntries(cl.files).slice(0, 5).map(([f]) => f),
1963
- top_keywords: sortedEntries(cl.keywords).slice(0, 10).map(([k]) => k),
2159
+ preceding: cl.preceding, // #4: agent action + result just before the reaction
2160
+ self_suspect: allSelf || cl.anySelf, // #3: a self-correction is present — LLM confirms target (advisory)
2161
+ top_keywords: topSh.slice(0, 10),
1964
2162
  };
1965
2163
  });
1966
2164
 
1967
- clusters.sort((a, b) => b.score - a.score);
1968
- return clusters;
2165
+ // Drop the noise tier (one-off + mild); rank by recurrence then severity.
2166
+ const kept = out.filter(c => c.suggested_artifact !== 'drop');
2167
+ kept.sort((a, b) => b.score - a.score || b.sessions - a.sessions);
2168
+ return kept;
1969
2169
  }
1970
2170
 
1971
2171
  // =============================================================================
@@ -1990,15 +2190,16 @@ function extractMain(sessionsDir) {
1990
2190
  signals = rawContent.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
1991
2191
  }
1992
2192
 
1993
- // Find BAD sessions
1994
- const badSessions = analyses.filter(a => a.quality === 'BAD');
2193
+ // NEW: no per-session BAD verdict. Seed from ALL sessions; the observed-only
2194
+ // anchor filter in analyzeBadSession decides which produce candidates.
2195
+ const badSessions = analyses;
1995
2196
 
1996
2197
  if (badSessions.length === 0) {
1997
- console.log('No BAD sessions found. Nothing to extract.');
2198
+ console.log('No sessions to analyze.');
1998
2199
  return 0;
1999
2200
  }
2000
2201
 
2001
- console.log(`Extracting antigens from ${badSessions.length} BAD sessions...\n`);
2202
+ console.log(`Scanning ${badSessions.length} sessions for observed user-reaction signals...\n`);
2002
2203
 
2003
2204
  // Extract antigens
2004
2205
  const allCandidates = [];
@@ -2053,17 +2254,17 @@ function extractMain(sessionsDir) {
2053
2254
 
2054
2255
  reviewLines.push('# Friction Antigen Clusters\n\n');
2055
2256
  reviewLines.push(`Generated: ${new Date().toISOString()}\n`);
2056
- reviewLines.push(`BAD sessions: ${badSessions.length} | Raw candidates: ${allCandidates.length} | Clusters: ${clusters.length}\n\n`);
2257
+ reviewLines.push(`Sessions scanned: ${badSessions.length} | Reaction candidates: ${allCandidates.length} | Clusters: ${clusters.length}\n\n`);
2057
2258
 
2058
2259
  // Summary table
2059
2260
  reviewLines.push('## Cluster Summary\n\n');
2060
- reviewLines.push('| # | Signal | Tool Pattern | Count | Sessions | Projects | Score | Median Peak |\n');
2061
- reviewLines.push('|---|--------|-------------|-------|----------|----------|-------|-------------|\n');
2261
+ reviewLines.push('| # | Signal | Artifact/Severity | Sessions | Projects | Score | Median Peak |\n');
2262
+ reviewLines.push('|---|--------|-------------------|----------|----------|-------|-------------|\n');
2062
2263
  reviewClusters.forEach((cl, idx) => {
2063
2264
  const projs = cl.projects || [];
2064
2265
  const projectsShort = projs.slice(0, 3).join(', ') +
2065
2266
  (projs.length > 3 ? `, +${projs.length - 3}` : '');
2066
- reviewLines.push(`| ${idx + 1} | ${cl.anchor_signal} | ${cl.tool_pattern} | ${cl.count} | ${cl.sessions} | ${projectsShort || '-'} | ${cl.score} | ${cl.median_peak} |\n`);
2267
+ reviewLines.push(`| ${idx + 1} | ${cl.anchor_signal} | ${cl.tool_pattern} | ${cl.sessions} | ${projectsShort || '-'} | ${cl.score} | ${cl.median_peak} |\n`);
2067
2268
  });
2068
2269
  reviewLines.push('\n---\n\n');
2069
2270
 
@@ -2075,6 +2276,10 @@ function extractMain(sessionsDir) {
2075
2276
  reviewLines.push(`**Projects:** ${cl.projects.join(', ')}\n\n`);
2076
2277
  }
2077
2278
 
2279
+ if (cl.self_suspect) {
2280
+ reviewLines.push('> ⚠️ **Looks like user self-correction** (e.g. "wrong project") — LLM should confirm target before treating as an antigen.\n\n');
2281
+ }
2282
+
2078
2283
  if (cl.contexts.length > 0) {
2079
2284
  reviewLines.push('### User Context (what the user said)\n\n');
2080
2285
  for (const ctx of cl.contexts.slice(0, 3)) {
@@ -2083,6 +2288,13 @@ function extractMain(sessionsDir) {
2083
2288
  }
2084
2289
  }
2085
2290
 
2291
+ if (cl.preceding && (cl.preceding.action !== 'none' || cl.preceding.error)) {
2292
+ reviewLines.push('### Trigger (agent action just before)\n\n');
2293
+ reviewLines.push(`- **Action:** ${cl.preceding.action} → ${cl.preceding.result}\n`);
2294
+ if (cl.preceding.error) reviewLines.push(`- **Error:** \`${cl.preceding.error}\`\n`);
2295
+ reviewLines.push('\n');
2296
+ }
2297
+
2086
2298
  if (cl.errors.length > 0) {
2087
2299
  reviewLines.push('### Errors\n\n');
2088
2300
  reviewLines.push('```\n');
@@ -2092,14 +2304,6 @@ function extractMain(sessionsDir) {
2092
2304
  reviewLines.push('```\n\n');
2093
2305
  }
2094
2306
 
2095
- if (cl.top_files.length > 0) {
2096
- reviewLines.push('### Files involved\n\n');
2097
- for (const f of cl.top_files) {
2098
- reviewLines.push(`- \`${f}\`\n`);
2099
- }
2100
- reviewLines.push('\n');
2101
- }
2102
-
2103
2307
  if (cl.top_keywords.length > 0) {
2104
2308
  reviewLines.push(`**Keywords:** ${cl.top_keywords.join(', ')}\n\n`);
2105
2309
  }
@@ -2161,7 +2365,7 @@ Outputs (all in .opencode/friction/):
2161
2365
 
2162
2366
  // Step 2: Extract antigens
2163
2367
  console.log('\n' + '='.repeat(60));
2164
- console.log('\n[2/2] Extracting antigens from BAD sessions...\n');
2368
+ console.log('\n[2/2] Extracting antigens from user-reaction signals...\n');
2165
2369
  extractMain(sessionsDir);
2166
2370
 
2167
2371
  // Final summary