liteagents 2.8.3 → 2.10.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 +51 -0
- package/README.md +25 -31
- package/installer/cli.js +6 -2
- package/package.json +3 -3
- package/packages/ampcode/AGENT.md +9 -15
- package/packages/ampcode/agents/code-developer.md +11 -12
- package/packages/ampcode/agents/quality-assurance.md +1 -1
- package/packages/{droid/commands/systematic-debugging.md → ampcode/commands/debug-method.md} +9 -9
- package/packages/ampcode/commands/diff-review.md +78 -0
- package/packages/ampcode/commands/friction/friction.js +348 -122
- package/packages/ampcode/commands/optimize.md +45 -4
- package/packages/ampcode/commands/refactor.md +33 -1
- package/packages/ampcode/commands/remember.md +107 -46
- package/packages/ampcode/commands/security.md +28 -1
- package/packages/ampcode/commands/stash.md +7 -0
- package/packages/{droid/commands/test-driven-development.md → ampcode/commands/tdd-flow.md} +2 -2
- package/packages/ampcode/commands/test-generate.md +64 -15
- package/packages/{opencode/command/testing-anti-patterns.md → ampcode/commands/test-traps.md} +77 -3
- package/packages/{droid/commands/root-cause-tracing.md → ampcode/commands/trace-back.md} +3 -3
- package/packages/ampcode/commands/{verification-before-completion.md → verify-done.md} +3 -3
- package/packages/claude/CLAUDE.md +9 -15
- package/packages/claude/agents/code-developer.md +11 -12
- package/packages/claude/agents/quality-assurance.md +1 -1
- package/packages/claude/commands/diff-review.md +78 -0
- package/packages/claude/commands/friction/friction.js +348 -122
- package/packages/claude/commands/optimize.md +45 -4
- package/packages/claude/commands/refactor.md +33 -1
- package/packages/claude/commands/remember.md +107 -46
- package/packages/claude/commands/security.md +28 -1
- package/packages/claude/commands/stash.md +7 -0
- package/packages/claude/commands/test-generate.md +64 -15
- package/packages/claude/plugins/live-canvas-marketplace/plugins/live-canvas-channel/package-lock.json +3 -3
- package/packages/claude/skills/{systematic-debugging → debug-method}/CREATION-LOG.md +1 -1
- package/packages/claude/skills/{systematic-debugging → debug-method}/SKILL.md +9 -9
- package/packages/claude/skills/{systematic-debugging → debug-method}/test-academic.md +1 -1
- package/packages/claude/skills/{systematic-debugging → debug-method}/test-pressure-1.md +1 -1
- package/packages/claude/skills/{systematic-debugging → debug-method}/test-pressure-2.md +1 -1
- package/packages/claude/skills/{systematic-debugging → debug-method}/test-pressure-3.md +1 -1
- package/packages/claude/skills/{test-driven-development → tdd-flow}/SKILL.md +3 -3
- package/packages/claude/skills/{testing-anti-patterns → test-traps}/SKILL.md +77 -3
- package/packages/claude/skills/{root-cause-tracing → trace-back}/SKILL.md +3 -3
- package/packages/claude/skills/{verification-before-completion → verify-done}/SKILL.md +3 -3
- package/packages/droid/AGENTS.md +8 -14
- package/packages/{opencode/command/systematic-debugging.md → droid/commands/debug-method.md} +9 -9
- package/packages/droid/commands/diff-review.md +78 -0
- package/packages/droid/commands/friction/friction.js +348 -122
- package/packages/droid/commands/optimize.md +45 -4
- package/packages/droid/commands/refactor.md +33 -1
- package/packages/droid/commands/remember.md +107 -46
- package/packages/droid/commands/security.md +28 -1
- package/packages/droid/commands/stash.md +7 -0
- package/packages/{opencode/command/test-driven-development.md → droid/commands/tdd-flow.md} +2 -2
- package/packages/droid/commands/test-generate.md +64 -15
- package/packages/droid/commands/{testing-anti-patterns.md → test-traps.md} +77 -3
- package/packages/{opencode/command/root-cause-tracing.md → droid/commands/trace-back.md} +3 -3
- package/packages/droid/commands/{verification-before-completion.md → verify-done.md} +3 -3
- package/packages/droid/droids/code-developer.md +11 -12
- package/packages/droid/droids/quality-assurance.md +1 -1
- package/packages/opencode/AGENTS.md +8 -14
- package/packages/opencode/agent/code-developer.md +11 -12
- package/packages/opencode/agent/quality-assurance.md +1 -1
- package/packages/{ampcode/commands/systematic-debugging.md → opencode/command/debug-method.md} +9 -9
- package/packages/opencode/command/diff-review.md +78 -0
- package/packages/opencode/command/friction/friction.js +348 -122
- package/packages/opencode/command/optimize.md +45 -4
- package/packages/opencode/command/refactor.md +33 -1
- package/packages/opencode/command/remember.md +107 -46
- package/packages/opencode/command/security.md +28 -1
- package/packages/opencode/command/stash.md +7 -0
- package/packages/{ampcode/commands/test-driven-development.md → opencode/command/tdd-flow.md} +2 -2
- package/packages/opencode/command/test-generate.md +64 -15
- package/packages/{ampcode/commands/testing-anti-patterns.md → opencode/command/test-traps.md} +77 -3
- package/packages/{ampcode/commands/root-cause-tracing.md → opencode/command/trace-back.md} +3 -3
- package/packages/opencode/command/{verification-before-completion.md → verify-done.md} +3 -3
- package/packages/opencode/opencode.jsonc +13 -37
- package/packages/subagentic-manual.md +55 -51
- package/packages/ampcode/commands/code-review.md +0 -107
- package/packages/ampcode/commands/condition-based-waiting.md +0 -122
- package/packages/ampcode/commands/debug.md +0 -20
- package/packages/ampcode/commands/explain.md +0 -18
- package/packages/ampcode/commands/friction.md +0 -139
- package/packages/ampcode/commands/git-commit.md +0 -14
- package/packages/ampcode/commands/review.md +0 -18
- package/packages/claude/commands/debug.md +0 -20
- package/packages/claude/commands/explain.md +0 -18
- package/packages/claude/commands/friction.md +0 -139
- package/packages/claude/commands/git-commit.md +0 -14
- package/packages/claude/commands/review.md +0 -18
- package/packages/claude/skills/code-review/SKILL.md +0 -107
- package/packages/claude/skills/code-review/code-reviewer.md +0 -146
- package/packages/claude/skills/condition-based-waiting/SKILL.md +0 -122
- package/packages/droid/commands/code-review.md +0 -107
- package/packages/droid/commands/condition-based-waiting.md +0 -122
- package/packages/droid/commands/debug.md +0 -20
- package/packages/droid/commands/explain.md +0 -18
- package/packages/droid/commands/friction.md +0 -139
- package/packages/droid/commands/git-commit.md +0 -14
- package/packages/droid/commands/review.md +0 -18
- package/packages/opencode/command/code-review.md +0 -107
- package/packages/opencode/command/condition-based-waiting.md +0 -122
- package/packages/opencode/command/debug.md +0 -20
- package/packages/opencode/command/explain.md +0 -18
- package/packages/opencode/command/friction.md +0 -139
- package/packages/opencode/command/git-commit.md +0 -14
- package/packages/opencode/command/review.md +0 -18
- /package/packages/ampcode/commands/{condition-based-waiting → test-traps}/example.ts +0 -0
- /package/packages/ampcode/commands/{root-cause-tracing → trace-back}/find-polluter.sh +0 -0
- /package/packages/claude/skills/{condition-based-waiting → test-traps}/example.ts +0 -0
- /package/packages/claude/skills/{root-cause-tracing → trace-back}/find-polluter.sh +0 -0
- /package/packages/droid/commands/{condition-based-waiting → test-traps}/example.ts +0 -0
- /package/packages/droid/commands/{root-cause-tracing → trace-back}/find-polluter.sh +0 -0
- /package/packages/opencode/command/{condition-based-waiting → test-traps}/example.ts +0 -0
- /package/packages/opencode/command/{root-cause-tracing → trace-back}/find-polluter.sh +0 -0
|
@@ -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,
|
|
@@ -78,6 +81,24 @@ function loadConfig() {
|
|
|
78
81
|
return CONFIG;
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Parse newline-delimited JSON, skipping (and warning about) malformed lines
|
|
86
|
+
* instead of crashing the whole run on one bad record.
|
|
87
|
+
*/
|
|
88
|
+
function parseJsonl(raw, source) {
|
|
89
|
+
const records = [];
|
|
90
|
+
const lines = raw.split('\n');
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
if (!lines[i].trim()) continue;
|
|
93
|
+
try {
|
|
94
|
+
records.push(JSON.parse(lines[i]));
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(`Warning: skipping malformed JSONL line ${i + 1}${source ? ` in ${source}` : ''}: ${e.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return records;
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
function parseISODate(s) {
|
|
82
103
|
if (!s) return null;
|
|
83
104
|
try {
|
|
@@ -196,6 +217,31 @@ function extractToolNameFromResult(result) {
|
|
|
196
217
|
return match ? match[1] : 'unknown';
|
|
197
218
|
}
|
|
198
219
|
|
|
220
|
+
// A user turn that is mostly pasted shell prompts/output (SSH session dumps,
|
|
221
|
+
// command logs) is context the user pasted — not a reaction to the agent.
|
|
222
|
+
// Treating it as friction pollutes antigens (e.g. keywords like "postconf",
|
|
223
|
+
// "sendmail"). A *prompted* command line ("> sudo …", "$ git …") is an
|
|
224
|
+
// unambiguous paste even at 2 lines; otherwise require shell lines to dominate
|
|
225
|
+
// a 3+ line block, so a real 2-line correction ("no\nls the logs please")
|
|
226
|
+
// stays a correction.
|
|
227
|
+
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/;
|
|
228
|
+
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: )/;
|
|
229
|
+
function looksLikeTerminalPaste(text) {
|
|
230
|
+
if (typeof text !== 'string') return false;
|
|
231
|
+
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
|
232
|
+
if (lines.length < 2) return false;
|
|
233
|
+
const prompted = lines.filter(l =>
|
|
234
|
+
/^[\w.-]+@[\w.-]+:\S*[#$]/.test(l) || // host prompt: "root@terribic:~#"
|
|
235
|
+
(/^[>$#]\s+/.test(l) && SHELL_CMD.test(l)) // "> dig …", "$ npm ci"
|
|
236
|
+
).length;
|
|
237
|
+
if (prompted >= 1) return true;
|
|
238
|
+
if (lines.length < 3) return false;
|
|
239
|
+
const shellish = lines.filter(l =>
|
|
240
|
+
new RegExp('^' + SHELL_CMD.source).test(l) || SHELL_OUT.test(l)
|
|
241
|
+
).length;
|
|
242
|
+
return shellish / lines.length >= 0.5;
|
|
243
|
+
}
|
|
244
|
+
|
|
199
245
|
// =============================================================================
|
|
200
246
|
// FRICTION ANALYZE - extract_signals
|
|
201
247
|
// =============================================================================
|
|
@@ -207,9 +253,7 @@ function extractSignals(sessionFile) {
|
|
|
207
253
|
const metadata = {};
|
|
208
254
|
|
|
209
255
|
const raw = fs.readFileSync(sessionFile, 'utf-8');
|
|
210
|
-
const events = raw
|
|
211
|
-
.filter(line => line.trim())
|
|
212
|
-
.map(line => JSON.parse(line));
|
|
256
|
+
const events = parseJsonl(raw, sessionFile);
|
|
213
257
|
|
|
214
258
|
let turnCount = 0;
|
|
215
259
|
const userMessages = [];
|
|
@@ -419,20 +463,34 @@ function extractSignals(sessionFile) {
|
|
|
419
463
|
});
|
|
420
464
|
}
|
|
421
465
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
466
|
+
const isPaste = looksLikeTerminalPaste(content);
|
|
467
|
+
|
|
468
|
+
// Curse only counts as friction when aimed at the agent's work — not
|
|
469
|
+
// rhetorical/narrative profanity ("does anyone search any shit?", a
|
|
470
|
+
// pasted reddit story). Keep it when the turn is a short reaction, or
|
|
471
|
+
// when an agent-directed token sits next to the curse; otherwise the
|
|
472
|
+
// profanity is buried in a long narrative and isn't friction.
|
|
473
|
+
const curseMatch = content.match(/\b(fuck|shit|damn)\b/i);
|
|
474
|
+
if (curseMatch && !isPaste) {
|
|
475
|
+
const i = curseMatch.index;
|
|
476
|
+
const near = content.slice(Math.max(0, i - 40), i + 40);
|
|
477
|
+
const shortReaction = content.trim().length <= 120;
|
|
478
|
+
const directed = /\b(you|your|you'?ve|you'?re|stop|quit|keep|again|why)\b/i.test(near);
|
|
479
|
+
if (shortReaction || directed) {
|
|
480
|
+
signals.push({
|
|
481
|
+
ts,
|
|
482
|
+
source: 'user',
|
|
483
|
+
signal: 'user_curse',
|
|
484
|
+
details: content.slice(0, 50),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
429
487
|
}
|
|
430
488
|
|
|
431
|
-
if (isInteractive &&
|
|
489
|
+
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
490
|
signals.push({
|
|
433
491
|
ts,
|
|
434
492
|
source: 'user',
|
|
435
|
-
signal: '
|
|
493
|
+
signal: 'user_correction',
|
|
436
494
|
details: content.slice(0, 50),
|
|
437
495
|
});
|
|
438
496
|
}
|
|
@@ -578,7 +636,7 @@ function extractSignals(sessionFile) {
|
|
|
578
636
|
const frictionWeights = {
|
|
579
637
|
exit_error: 1,
|
|
580
638
|
user_curse: 5,
|
|
581
|
-
|
|
639
|
+
user_correction: 1,
|
|
582
640
|
tool_loop: 6,
|
|
583
641
|
false_success: 8,
|
|
584
642
|
request_interrupted: 4,
|
|
@@ -625,6 +683,29 @@ function extractSignals(sessionFile) {
|
|
|
625
683
|
});
|
|
626
684
|
}
|
|
627
685
|
|
|
686
|
+
// FIX #1/#2: stash, abandonment and silence are "unresolved markers", not
|
|
687
|
+
// friction on their own. A clean-start stash, a context-switch, or an idle gap
|
|
688
|
+
// is routine. They only count when a real user reaction (correction / curse /
|
|
689
|
+
// interrupt) preceded them in the recent signals — i.e. a frustrated thread
|
|
690
|
+
// that was then dropped. Otherwise demote to a zero-weight checkpoint.
|
|
691
|
+
const GATED = new Set(['user_intervention', 'session_abandoned', 'long_silence']);
|
|
692
|
+
const REACTION = new Set(['user_correction', 'user_curse', 'interrupt_cascade']);
|
|
693
|
+
for (let i = 0; i < finalSignals.length; i++) {
|
|
694
|
+
if (!GATED.has(finalSignals[i].signal)) continue;
|
|
695
|
+
let precededByReaction = false;
|
|
696
|
+
for (let j = i - 1, seen = 0; j >= 0 && seen < 8; j--, seen++) {
|
|
697
|
+
if (REACTION.has(finalSignals[j].signal)) { precededByReaction = true; break; }
|
|
698
|
+
}
|
|
699
|
+
if (!precededByReaction) {
|
|
700
|
+
finalSignals[i] = {
|
|
701
|
+
...finalSignals[i],
|
|
702
|
+
signal: 'checkpoint',
|
|
703
|
+
gated_from: finalSignals[i].signal,
|
|
704
|
+
details: 'routine/idle — no preceding user reaction',
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
628
709
|
return [finalSignals, metadata];
|
|
629
710
|
}
|
|
630
711
|
|
|
@@ -1630,7 +1711,7 @@ function extractContextWindow(sessionFile, anchorTs, windowSize) {
|
|
|
1630
1711
|
windowSize = windowSize || 5;
|
|
1631
1712
|
|
|
1632
1713
|
const raw = fs.readFileSync(sessionFile, 'utf-8');
|
|
1633
|
-
const events = raw
|
|
1714
|
+
const events = parseJsonl(raw, sessionFile);
|
|
1634
1715
|
|
|
1635
1716
|
const turns = [];
|
|
1636
1717
|
for (const event of events) {
|
|
@@ -1782,21 +1863,20 @@ function extractUserMessage(event) {
|
|
|
1782
1863
|
function analyzeBadSession(sessionFile, analysis, signals) {
|
|
1783
1864
|
const sessionId = analysis.session_id;
|
|
1784
1865
|
|
|
1866
|
+
// NEW: anchor antigens only on OBSERVED user-reaction signals. Inferred
|
|
1867
|
+
// proxies (false_success/session_abandoned/user_intervention) never seed —
|
|
1868
|
+
// they only color severity. No fallback: a session with no observed reaction
|
|
1869
|
+
// produces no candidate (silence is not an antigen).
|
|
1870
|
+
// Seed only on genuine USER REACTIONS. tool_loop / repeated_question are
|
|
1871
|
+
// agent-behavior signals (no user text to cluster, and repeated_question
|
|
1872
|
+
// over-fires on tool output) — they corroborate severity, never seed.
|
|
1785
1873
|
const anchorSignals = [
|
|
1786
|
-
'
|
|
1787
|
-
'
|
|
1788
|
-
'false_success',
|
|
1874
|
+
'user_correction',
|
|
1875
|
+
'user_curse',
|
|
1789
1876
|
'interrupt_cascade',
|
|
1790
1877
|
];
|
|
1791
1878
|
|
|
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
|
-
}
|
|
1879
|
+
const anchors = signals.filter(s => s.session === sessionId && anchorSignals.includes(s.signal));
|
|
1800
1880
|
|
|
1801
1881
|
const candidates = [];
|
|
1802
1882
|
|
|
@@ -1819,7 +1899,7 @@ function analyzeBadSession(sessionFile, analysis, signals) {
|
|
|
1819
1899
|
allErrors.push(...extractErrorsFromTurn(event));
|
|
1820
1900
|
if (turn.type === 'user') {
|
|
1821
1901
|
const msg = extractUserMessage(event);
|
|
1822
|
-
if (msg && !msg.startsWith('[Request interrupted')) {
|
|
1902
|
+
if (msg && !msg.startsWith('[Request interrupted') && !looksLikeTerminalPaste(msg)) {
|
|
1823
1903
|
userMessagesArr.push(msg);
|
|
1824
1904
|
}
|
|
1825
1905
|
}
|
|
@@ -1854,6 +1934,25 @@ function analyzeBadSession(sessionFile, analysis, signals) {
|
|
|
1854
1934
|
]);
|
|
1855
1935
|
for (const c of common) keywords.delete(c);
|
|
1856
1936
|
|
|
1937
|
+
// FIX #4: capture the agent's last action + result just before the reaction.
|
|
1938
|
+
// Often it's a *claimed* success (exit 0) the user is contradicting, not a
|
|
1939
|
+
// crash — so record the action and whether it reported ok/error, plus any
|
|
1940
|
+
// error line. This is the technical half of the antigen (the trigger).
|
|
1941
|
+
const calls = allTools.filter(t => t.action === 'call').map(t => t.tool);
|
|
1942
|
+
const sawError = allTools.some(t => t.action === 'error');
|
|
1943
|
+
const sawSuccess = allTools.some(t => t.action === 'success');
|
|
1944
|
+
const preceding = {
|
|
1945
|
+
action: calls.slice(-2).join(' → ') || 'none',
|
|
1946
|
+
result: sawError ? 'error' : (sawSuccess ? 'claimed success (exit 0)' : 'unknown'),
|
|
1947
|
+
error: allErrors[allErrors.length - 1] || null,
|
|
1948
|
+
};
|
|
1949
|
+
|
|
1950
|
+
// FIX #3: self/context corrections ("wrong project", "nevermind", "my bad")
|
|
1951
|
+
// are the user redirecting THEMSELVES, not an antigen against the agent. Flag
|
|
1952
|
+
// them so the clusterer won't mark them severe; the LLM makes the final call.
|
|
1953
|
+
const selfPhrase = /\b(wrong (project|window|repo|directory|folder)|never ?mind|nvm|scratch that|ignore (that|this)|disregard|my bad|oops)\b/i;
|
|
1954
|
+
const self_suspect = userMessagesArr.some(m => selfPhrase.test(m));
|
|
1955
|
+
|
|
1857
1956
|
const candidate = {
|
|
1858
1957
|
session_id: sessionId,
|
|
1859
1958
|
anchor_signal: anchorSignal,
|
|
@@ -1863,6 +1962,8 @@ function analyzeBadSession(sessionFile, analysis, signals) {
|
|
|
1863
1962
|
files: Array.from(allFiles).sort().slice(0, 10),
|
|
1864
1963
|
tool_sequence: toolSeq.slice(0, 15),
|
|
1865
1964
|
errors: allErrors.slice(0, 5),
|
|
1965
|
+
preceding,
|
|
1966
|
+
self_suspect,
|
|
1866
1967
|
keywords: Array.from(keywords).sort().slice(0, 15),
|
|
1867
1968
|
user_context: userMessagesArr.slice(0, 3),
|
|
1868
1969
|
inhibitory_instruction: '# TODO: Write prevention instruction based on pattern above',
|
|
@@ -1879,93 +1980,208 @@ function analyzeBadSession(sessionFile, analysis, signals) {
|
|
|
1879
1980
|
// =============================================================================
|
|
1880
1981
|
|
|
1881
1982
|
function clusterCandidates(allCandidates) {
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1983
|
+
// NEW: cluster by CONTENT (keyword overlap of what the user actually said),
|
|
1984
|
+
// not by (anchor_signal, tool_pattern). Inferred signals were already barred
|
|
1985
|
+
// from seeding upstream; here they survive only as corroborating "errors"
|
|
1986
|
+
// that color a cluster's severity. Recurrence across sessions is the score.
|
|
1987
|
+
const SIM = 0.5; // overlap-coefficient threshold to join a content cluster
|
|
1988
|
+
|
|
1989
|
+
// Ubiquitous path/file tokens that carry no topical meaning — if we cluster on
|
|
1990
|
+
// these we re-create OLD's over-merge (everything touches README/package.json).
|
|
1991
|
+
const PATH_STOP = new Set([
|
|
1992
|
+
'home', 'hamr', 'documents', 'pycharmprojects', 'projects', 'claude', 'stash',
|
|
1993
|
+
'memory', 'commands', 'command', 'skills', 'skill', 'src', 'lib', 'app', 'dist',
|
|
1994
|
+
'build', 'node_modules', 'public', 'assets', 'utils', 'util', 'config', 'scripts',
|
|
1995
|
+
'readme', 'package', 'index', 'main', 'test', 'tests', 'spec', 'lock',
|
|
1996
|
+
'components', 'component', 'styles', 'style', 'types', 'data', 'templates',
|
|
1997
|
+
'md', 'js', 'ts', 'jsx', 'tsx', 'py', 'sh', 'txt', 'html', 'css', 'json',
|
|
1998
|
+
'yaml', 'yml', 'toml', 'env', 'log', 'tmp',
|
|
1999
|
+
]);
|
|
2000
|
+
|
|
2001
|
+
// English fillers to drop from phrase matching (path tokens use PATH_STOP).
|
|
2002
|
+
const STOP = new Set([
|
|
2003
|
+
'the', 'and', 'you', 'for', 'not', 'but', 'was', 'are', 'get', 'use', 'one', 'out',
|
|
2004
|
+
'can', 'all', 'any', 'has', 'had', 'have', 'this', 'that', 'with', 'from', 'what',
|
|
2005
|
+
'when', 'where', 'which', 'there', 'their', 'would', 'could', 'should', 'about',
|
|
2006
|
+
'been', 'were', 'they', 'them', 'then', 'than', 'these', 'those', 'some', 'into',
|
|
2007
|
+
'only', 'other', 'also', 'just', 'more', 'very', 'here', 'after', 'before', 'being',
|
|
2008
|
+
'doing', 'make', 'made', 'like', 'want', 'need', 'your', 'dont', 'did', 'does',
|
|
2009
|
+
'done', 'now', 'yet', 'too', 'will', 'wont', 'cant', 'got', 'let',
|
|
2010
|
+
]);
|
|
2011
|
+
|
|
2012
|
+
// Significant words in order (>=3 chars, not a filler / path token).
|
|
2013
|
+
const unigrams = (text) =>
|
|
2014
|
+
(String(text).toLowerCase().match(/\b[a-z']{3,}\b/g) || [])
|
|
2015
|
+
.filter(w => !STOP.has(w) && !PATH_STOP.has(w));
|
|
2016
|
+
|
|
2017
|
+
// SHINGLES = unigrams + adjacent bigrams (word proximity + phrase repetition).
|
|
2018
|
+
// A repeated phrase like "wrong project" scores as one shingle AND its two words.
|
|
2019
|
+
const shingles = (texts) => {
|
|
2020
|
+
const m = new Map();
|
|
2021
|
+
for (const t of texts) {
|
|
2022
|
+
const u = unigrams(t);
|
|
2023
|
+
for (const w of u) m.set(w, (m.get(w) || 0) + 1);
|
|
2024
|
+
for (let i = 0; i < u.length - 1; i++) {
|
|
2025
|
+
const bg = u[i] + ' ' + u[i + 1];
|
|
2026
|
+
m.set(bg, (m.get(bg) || 0) + 1);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return m;
|
|
1890
2030
|
};
|
|
1891
2031
|
|
|
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
|
-
};
|
|
2032
|
+
const fileTokens = (sessionId, files) => {
|
|
2033
|
+
const proj = (sessionId || '').split('/')[0].toLowerCase();
|
|
2034
|
+
const out = [];
|
|
2035
|
+
for (const f of (files || [])) {
|
|
2036
|
+
for (const seg of String(f).toLowerCase().split(/[/._\-\s]+/)) {
|
|
2037
|
+
if (seg.length >= 4 && seg !== proj && !PATH_STOP.has(seg)) out.push(seg);
|
|
2038
|
+
}
|
|
1913
2039
|
}
|
|
2040
|
+
return out;
|
|
2041
|
+
};
|
|
1914
2042
|
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2043
|
+
// Pick the quote from a session that best contains a cluster's seed phrase —
|
|
2044
|
+
// a multi-topic session should be shown by the line that actually matched.
|
|
2045
|
+
const bestQuote = (texts, seedSig) => {
|
|
2046
|
+
let best = texts[0] || '', bestN = -1;
|
|
2047
|
+
for (const t of texts) {
|
|
2048
|
+
const u = unigrams(t);
|
|
2049
|
+
let n = 0;
|
|
2050
|
+
for (const w of u) if (seedSig.has(w)) n++;
|
|
2051
|
+
for (let i = 0; i < u.length - 1; i++) if (seedSig.has(u[i] + ' ' + u[i + 1])) n++;
|
|
2052
|
+
if (n > bestN) { bestN = n; best = t; }
|
|
2053
|
+
}
|
|
2054
|
+
return best;
|
|
2055
|
+
};
|
|
1919
2056
|
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2057
|
+
// ---- Stage 1: INTRA-SESSION — one consolidated signal per session ----
|
|
2058
|
+
// Frustration/repetition is short and reuses words; pool a session's reaction
|
|
2059
|
+
// texts so the repeated/overlapping part becomes that session's signal.
|
|
2060
|
+
const bySession = {};
|
|
2061
|
+
for (const c of allCandidates) {
|
|
2062
|
+
const id = c.session_id;
|
|
2063
|
+
if (!bySession[id]) bySession[id] = { id, texts: [], files: new Set(), signals: {}, errors: [], peak: 0, preceding: null, selfVotes: 0, total: 0 };
|
|
2064
|
+
const b = bySession[id];
|
|
2065
|
+
for (const m of (c.user_context || [])) if (m && m.length > 2) b.texts.push(m);
|
|
2066
|
+
for (const f of (c.files || [])) b.files.add(f);
|
|
2067
|
+
b.signals[c.anchor_signal] = (b.signals[c.anchor_signal] || 0) + 1;
|
|
2068
|
+
for (const e of (c.errors || [])) if (!b.errors.includes(e)) b.errors.push(e);
|
|
2069
|
+
b.peak = Math.max(b.peak, c.peak_friction || 0);
|
|
2070
|
+
b.total++;
|
|
2071
|
+
if (c.self_suspect) b.selfVotes++;
|
|
2072
|
+
// keep the most informative preceding action/error (#4)
|
|
2073
|
+
if (c.preceding && (!b.preceding || (c.preceding.action !== 'none' || c.preceding.error))) b.preceding = c.preceding;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const sessionSignals = Object.values(bySession).map(b => {
|
|
2077
|
+
const sh = shingles(b.texts);
|
|
2078
|
+
if (sh.size < 2) { // terse/empty → fall back to file referent
|
|
2079
|
+
for (const seg of fileTokens(b.id, b.files)) sh.set(seg, (sh.get(seg) || 0) + 1);
|
|
1925
2080
|
}
|
|
2081
|
+
return {
|
|
2082
|
+
id: b.id,
|
|
2083
|
+
sig: new Set(sh.keys()),
|
|
2084
|
+
signals: b.signals,
|
|
2085
|
+
errors: b.errors,
|
|
2086
|
+
peak: b.peak,
|
|
2087
|
+
texts: b.texts,
|
|
2088
|
+
preceding: b.preceding,
|
|
2089
|
+
anySelf: b.selfVotes > 0, // at least one self-correction → warn, LLM confirms target
|
|
2090
|
+
};
|
|
2091
|
+
});
|
|
1926
2092
|
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2093
|
+
// ---- Stage 2: CROSS-SESSION — match session signals by shared shingles ----
|
|
2094
|
+
const clusters = [];
|
|
2095
|
+
for (const ss of sessionSignals) {
|
|
2096
|
+
// Merge on a shared PHRASE (bigram = word proximity) — the strong signal —
|
|
2097
|
+
// or on strong unigram overlap. A single generic shared word won't merge.
|
|
2098
|
+
let best = null, bestSim = 0;
|
|
2099
|
+
if (ss.sig.size >= 2) {
|
|
2100
|
+
for (const cl of clusters) {
|
|
2101
|
+
let bi = 0, uni = 0;
|
|
2102
|
+
for (const x of ss.sig) if (cl.seedSig.has(x)) { x.includes(' ') ? bi++ : uni++; }
|
|
2103
|
+
const sim = (bi + uni) / Math.min(ss.sig.size, cl.seedSig.size);
|
|
2104
|
+
const mergeable = bi >= 1 && sim >= SIM;
|
|
2105
|
+
if (mergeable && sim > bestSim) { bestSim = sim; best = cl; }
|
|
1931
2106
|
}
|
|
1932
2107
|
}
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
const
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
2108
|
+
let cl;
|
|
2109
|
+
if (best) {
|
|
2110
|
+
cl = best;
|
|
2111
|
+
} else {
|
|
2112
|
+
cl = { sig: new Set(), seedSig: new Set(ss.sig), shCount: new Map(), sessions: {}, signals: {}, contexts: [], errors: [], peaks: [], anySelf: false, preceding: null };
|
|
2113
|
+
clusters.push(cl);
|
|
2114
|
+
}
|
|
2115
|
+
for (const s of ss.sig) { cl.sig.add(s); cl.shCount.set(s, (cl.shCount.get(s) || 0) + 1); }
|
|
2116
|
+
cl.sessions[ss.id] = true;
|
|
2117
|
+
for (const [k, v] of Object.entries(ss.signals)) cl.signals[k] = (cl.signals[k] || 0) + v;
|
|
2118
|
+
const q = bestQuote(ss.texts, cl.seedSig);
|
|
2119
|
+
if (q && cl.contexts.length < 5 && !cl.contexts.includes(q)) cl.contexts.push(q);
|
|
2120
|
+
for (const e of ss.errors) if (!cl.errors.includes(e) && cl.errors.length < 5) cl.errors.push(e);
|
|
2121
|
+
cl.peaks.push(ss.peak);
|
|
2122
|
+
if (ss.anySelf) cl.anySelf = true;
|
|
2123
|
+
if (ss.preceding && (!cl.preceding || (ss.preceding.action !== 'none' || ss.preceding.error))) cl.preceding = ss.preceding;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
const out = clusters.map(cl => {
|
|
2127
|
+
const peaks = cl.peaks.slice().sort((a, b) => a - b);
|
|
2128
|
+
const topSh = [...cl.shCount.entries()]
|
|
2129
|
+
.sort((a, b) => b[1] - a[1] || b[0].length - a[0].length).map(([k]) => k);
|
|
1946
2130
|
const sessionIds = Object.keys(cl.sessions);
|
|
1947
2131
|
const projects = [...new Set(
|
|
1948
2132
|
sessionIds.map(s => s.includes('/') ? s.split('/')[0] : 'unknown')
|
|
1949
2133
|
)].sort();
|
|
2134
|
+
const nSessions = sessionIds.length;
|
|
2135
|
+
const signalNames = Object.keys(cl.signals);
|
|
2136
|
+
const dominant = sortedEntries(cl.signals)[0] ? sortedEntries(cl.signals)[0][0] : 'unknown';
|
|
2137
|
+
|
|
2138
|
+
// Severity: an explicit AGENT-directed reaction (curse / interrupt, or a
|
|
2139
|
+
// correction that isn't the user redirecting themselves) is severe; machine
|
|
2140
|
+
// corroboration (errors) also escalates. #3: judge self-correction from the
|
|
2141
|
+
// MATCHED quotes — a cluster whose grouping phrase is "wrong project" etc. is
|
|
2142
|
+
// the user redirecting themselves, not an antigen → not severe.
|
|
2143
|
+
const SELF_RE = /\b(wrong (project|window|repo|directory|folder)|never ?mind|nvm|scratch that|ignore (that|this)|disregard|my bad|oops)\b/i;
|
|
2144
|
+
const allSelf = cl.contexts.length > 0 && cl.contexts.every(q => SELF_RE.test(q || ''));
|
|
2145
|
+
const severe = signalNames.some(s => s === 'user_curse' || s === 'interrupt_cascade')
|
|
2146
|
+
|| (signalNames.includes('user_correction') && !allSelf)
|
|
2147
|
+
|| cl.errors.length > 0;
|
|
2148
|
+
const recurring = nSessions >= 3; // recurrence × severity → artifact (the 2×2)
|
|
2149
|
+
let artifact;
|
|
2150
|
+
if (recurring && severe) artifact = 'antigen';
|
|
2151
|
+
else if (recurring && !severe) artifact = 'fact';
|
|
2152
|
+
else if (!recurring && severe) artifact = 'episode';
|
|
2153
|
+
else artifact = 'drop';
|
|
2154
|
+
const confidence = nSessions >= 5 ? 'high' : nSessions >= 3 ? 'medium' : 'low';
|
|
2155
|
+
const theme = topSh.slice(0, 4).join(' / ') || '(thin)';
|
|
2156
|
+
|
|
1950
2157
|
return {
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2158
|
+
theme,
|
|
2159
|
+
suggested_artifact: artifact,
|
|
2160
|
+
confidence,
|
|
2161
|
+
severity: severe ? 'severe' : 'mild',
|
|
2162
|
+
signals: cl.signals,
|
|
2163
|
+
// backward-compat fields for existing renderers:
|
|
2164
|
+
anchor_signal: dominant,
|
|
2165
|
+
tool_pattern: `${artifact}/${severe ? 'severe' : 'mild'}`,
|
|
2166
|
+
count: nSessions,
|
|
2167
|
+
score: nSessions * (severe ? 2 : 1),
|
|
2168
|
+
sessions: nSessions,
|
|
1956
2169
|
session_ids: sessionIds,
|
|
1957
|
-
projects
|
|
2170
|
+
projects,
|
|
1958
2171
|
median_peak: peaks[Math.floor(peaks.length / 2)],
|
|
1959
2172
|
max_peak: peaks[peaks.length - 1],
|
|
1960
2173
|
contexts: cl.contexts,
|
|
1961
2174
|
errors: cl.errors,
|
|
1962
|
-
|
|
1963
|
-
|
|
2175
|
+
preceding: cl.preceding, // #4: agent action + result just before the reaction
|
|
2176
|
+
self_suspect: allSelf || cl.anySelf, // #3: a self-correction is present — LLM confirms target (advisory)
|
|
2177
|
+
top_keywords: topSh.slice(0, 10),
|
|
1964
2178
|
};
|
|
1965
2179
|
});
|
|
1966
2180
|
|
|
1967
|
-
|
|
1968
|
-
|
|
2181
|
+
// Drop the noise tier (one-off + mild); rank by recurrence then severity.
|
|
2182
|
+
const kept = out.filter(c => c.suggested_artifact !== 'drop');
|
|
2183
|
+
kept.sort((a, b) => b.score - a.score || b.sessions - a.sessions);
|
|
2184
|
+
return kept;
|
|
1969
2185
|
}
|
|
1970
2186
|
|
|
1971
2187
|
// =============================================================================
|
|
@@ -1980,25 +2196,32 @@ function extractMain(sessionsDir) {
|
|
|
1980
2196
|
return 1;
|
|
1981
2197
|
}
|
|
1982
2198
|
|
|
1983
|
-
|
|
2199
|
+
let analyses;
|
|
2200
|
+
try {
|
|
2201
|
+
analyses = JSON.parse(fs.readFileSync(analysisFile, 'utf-8'));
|
|
2202
|
+
} catch (e) {
|
|
2203
|
+
console.log(`Error: ${analysisFile} is not valid JSON (${e.message}). Re-run friction analysis.`);
|
|
2204
|
+
return 1;
|
|
2205
|
+
}
|
|
1984
2206
|
|
|
1985
2207
|
// Load raw signals
|
|
1986
2208
|
const rawFile = '.amp/friction/friction_raw.jsonl';
|
|
1987
2209
|
let signals = [];
|
|
1988
2210
|
if (fs.existsSync(rawFile)) {
|
|
1989
2211
|
const rawContent = fs.readFileSync(rawFile, 'utf-8');
|
|
1990
|
-
signals = rawContent
|
|
2212
|
+
signals = parseJsonl(rawContent, rawFile);
|
|
1991
2213
|
}
|
|
1992
2214
|
|
|
1993
|
-
//
|
|
1994
|
-
|
|
2215
|
+
// NEW: no per-session BAD verdict. Seed from ALL sessions; the observed-only
|
|
2216
|
+
// anchor filter in analyzeBadSession decides which produce candidates.
|
|
2217
|
+
const badSessions = analyses;
|
|
1995
2218
|
|
|
1996
2219
|
if (badSessions.length === 0) {
|
|
1997
|
-
console.log('No
|
|
2220
|
+
console.log('No sessions to analyze.');
|
|
1998
2221
|
return 0;
|
|
1999
2222
|
}
|
|
2000
2223
|
|
|
2001
|
-
console.log(`
|
|
2224
|
+
console.log(`Scanning ${badSessions.length} sessions for observed user-reaction signals...\n`);
|
|
2002
2225
|
|
|
2003
2226
|
// Extract antigens
|
|
2004
2227
|
const allCandidates = [];
|
|
@@ -2053,17 +2276,17 @@ function extractMain(sessionsDir) {
|
|
|
2053
2276
|
|
|
2054
2277
|
reviewLines.push('# Friction Antigen Clusters\n\n');
|
|
2055
2278
|
reviewLines.push(`Generated: ${new Date().toISOString()}\n`);
|
|
2056
|
-
reviewLines.push(`
|
|
2279
|
+
reviewLines.push(`Sessions scanned: ${badSessions.length} | Reaction candidates: ${allCandidates.length} | Clusters: ${clusters.length}\n\n`);
|
|
2057
2280
|
|
|
2058
2281
|
// Summary table
|
|
2059
2282
|
reviewLines.push('## Cluster Summary\n\n');
|
|
2060
|
-
reviewLines.push('| # | Signal |
|
|
2061
|
-
reviewLines.push('
|
|
2283
|
+
reviewLines.push('| # | Signal | Artifact/Severity | Sessions | Projects | Score | Median Peak |\n');
|
|
2284
|
+
reviewLines.push('|---|--------|-------------------|----------|----------|-------|-------------|\n');
|
|
2062
2285
|
reviewClusters.forEach((cl, idx) => {
|
|
2063
2286
|
const projs = cl.projects || [];
|
|
2064
2287
|
const projectsShort = projs.slice(0, 3).join(', ') +
|
|
2065
2288
|
(projs.length > 3 ? `, +${projs.length - 3}` : '');
|
|
2066
|
-
reviewLines.push(`| ${idx + 1} | ${cl.anchor_signal} | ${cl.tool_pattern} | ${cl.
|
|
2289
|
+
reviewLines.push(`| ${idx + 1} | ${cl.anchor_signal} | ${cl.tool_pattern} | ${cl.sessions} | ${projectsShort || '-'} | ${cl.score} | ${cl.median_peak} |\n`);
|
|
2067
2290
|
});
|
|
2068
2291
|
reviewLines.push('\n---\n\n');
|
|
2069
2292
|
|
|
@@ -2075,6 +2298,10 @@ function extractMain(sessionsDir) {
|
|
|
2075
2298
|
reviewLines.push(`**Projects:** ${cl.projects.join(', ')}\n\n`);
|
|
2076
2299
|
}
|
|
2077
2300
|
|
|
2301
|
+
if (cl.self_suspect) {
|
|
2302
|
+
reviewLines.push('> ⚠️ **Looks like user self-correction** (e.g. "wrong project") — LLM should confirm target before treating as an antigen.\n\n');
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2078
2305
|
if (cl.contexts.length > 0) {
|
|
2079
2306
|
reviewLines.push('### User Context (what the user said)\n\n');
|
|
2080
2307
|
for (const ctx of cl.contexts.slice(0, 3)) {
|
|
@@ -2083,6 +2310,13 @@ function extractMain(sessionsDir) {
|
|
|
2083
2310
|
}
|
|
2084
2311
|
}
|
|
2085
2312
|
|
|
2313
|
+
if (cl.preceding && (cl.preceding.action !== 'none' || cl.preceding.error)) {
|
|
2314
|
+
reviewLines.push('### Trigger (agent action just before)\n\n');
|
|
2315
|
+
reviewLines.push(`- **Action:** ${cl.preceding.action} → ${cl.preceding.result}\n`);
|
|
2316
|
+
if (cl.preceding.error) reviewLines.push(`- **Error:** \`${cl.preceding.error}\`\n`);
|
|
2317
|
+
reviewLines.push('\n');
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2086
2320
|
if (cl.errors.length > 0) {
|
|
2087
2321
|
reviewLines.push('### Errors\n\n');
|
|
2088
2322
|
reviewLines.push('```\n');
|
|
@@ -2092,14 +2326,6 @@ function extractMain(sessionsDir) {
|
|
|
2092
2326
|
reviewLines.push('```\n\n');
|
|
2093
2327
|
}
|
|
2094
2328
|
|
|
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
2329
|
if (cl.top_keywords.length > 0) {
|
|
2104
2330
|
reviewLines.push(`**Keywords:** ${cl.top_keywords.join(', ')}\n\n`);
|
|
2105
2331
|
}
|
|
@@ -2161,7 +2387,7 @@ Outputs (all in .amp/friction/):
|
|
|
2161
2387
|
|
|
2162
2388
|
// Step 2: Extract antigens
|
|
2163
2389
|
console.log('\n' + '='.repeat(60));
|
|
2164
|
-
console.log('\n[2/2] Extracting antigens from
|
|
2390
|
+
console.log('\n[2/2] Extracting antigens from user-reaction signals...\n');
|
|
2165
2391
|
extractMain(sessionsDir);
|
|
2166
2392
|
|
|
2167
2393
|
// Final summary
|