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.
- package/CHANGELOG.md +30 -0
- package/README.md +10 -10
- package/installer/path-manager.js +3 -2
- package/package.json +1 -1
- package/packages/ampcode/commands/friction/friction.js +320 -116
- package/packages/ampcode/commands/remember.md +74 -44
- package/packages/claude/commands/friction/friction.js +320 -116
- package/packages/claude/commands/remember.md +74 -44
- package/packages/droid/commands/friction/friction.js +320 -116
- package/packages/droid/commands/remember.md +74 -44
- package/packages/opencode/command/friction/friction.js +320 -116
- package/packages/opencode/command/remember.md +74 -44
|
@@ -26,22 +26,25 @@ const path = require('path');
|
|
|
26
26
|
|
|
27
27
|
const CONFIG = {
|
|
28
28
|
weights: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
user_curse:
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
request_interrupted:
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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 &&
|
|
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: '
|
|
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
|
-
|
|
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
|
-
'
|
|
1787
|
-
'
|
|
1788
|
-
'false_success',
|
|
1858
|
+
'user_correction',
|
|
1859
|
+
'user_curse',
|
|
1789
1860
|
'interrupt_cascade',
|
|
1790
1861
|
];
|
|
1791
1862
|
|
|
1792
|
-
|
|
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
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
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
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
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
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
const
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
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
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
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
|
|
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
|
-
|
|
1963
|
-
|
|
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
|
-
|
|
1968
|
-
|
|
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
|
-
//
|
|
1994
|
-
|
|
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
|
|
2198
|
+
console.log('No sessions to analyze.');
|
|
1998
2199
|
return 0;
|
|
1999
2200
|
}
|
|
2000
2201
|
|
|
2001
|
-
console.log(`
|
|
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(`
|
|
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 |
|
|
2061
|
-
reviewLines.push('
|
|
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.
|
|
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
|
|
2368
|
+
console.log('\n[2/2] Extracting antigens from user-reaction signals...\n');
|
|
2165
2369
|
extractMain(sessionsDir);
|
|
2166
2370
|
|
|
2167
2371
|
// Final summary
|