@nemus-cli/nemus 0.3.1 → 0.3.2
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 +41 -11
- package/dist/cli/ai-prompt.js +4 -0
- package/dist/commands/reflect.js +13 -4
- package/dist/utils/agent-judge.js +89 -12
- package/dist/utils/reflect-analyze.js +136 -0
- package/dist/utils/reflect.js +75 -39
- package/package.json +1 -1
- package/src/cli/ai-prompt.ts +4 -0
- package/src/commands/reflect.ts +14 -6
- package/src/utils/agent-judge.test.ts +37 -2
- package/src/utils/agent-judge.ts +98 -15
- package/src/utils/reflect-analyze.test.ts +126 -0
- package/src/utils/reflect-analyze.ts +246 -0
- package/src/utils/reflect.test.ts +30 -27
- package/src/utils/reflect.ts +83 -58
package/src/commands/reflect.ts
CHANGED
|
@@ -4,13 +4,13 @@ import { outputJson, outputJsonError } from '../utils/output';
|
|
|
4
4
|
import { colorize } from '../utils/colors';
|
|
5
5
|
import {
|
|
6
6
|
gatherReflectionCorpus,
|
|
7
|
-
buildJudgePrompt,
|
|
8
7
|
parseReflectionReport,
|
|
9
8
|
REFLECT_SCHEMA,
|
|
10
9
|
ReflectionReport,
|
|
11
10
|
ReflectProgress,
|
|
12
11
|
Recommendation,
|
|
13
12
|
} from '../utils/reflect';
|
|
13
|
+
import { analyzeCorpus, buildAnalysisPrompt } from '../utils/reflect-analyze';
|
|
14
14
|
import { runAgentJsonAsync } from '../utils/agent-judge';
|
|
15
15
|
|
|
16
16
|
export function registerReflectCommand(parent: Command) {
|
|
@@ -19,6 +19,8 @@ export function registerReflectCommand(parent: Command) {
|
|
|
19
19
|
.alias('retro')
|
|
20
20
|
.description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
|
|
21
21
|
.option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
|
|
22
|
+
.option('--model <model>', 'Judge model override (agent-native pattern/id)')
|
|
23
|
+
.option('--thinking <level>', 'Judge thinking level for pi: off|minimal|low|medium|high|xhigh|max')
|
|
22
24
|
.option('--json', 'Output the report as JSON')
|
|
23
25
|
.option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
|
|
24
26
|
.action(async (opts) => {
|
|
@@ -26,7 +28,7 @@ export function registerReflectCommand(parent: Command) {
|
|
|
26
28
|
});
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
async function handleReflect(opts: { limit?: string; json?: boolean; dryRun?: boolean }) {
|
|
31
|
+
async function handleReflect(opts: { limit?: string; model?: string; thinking?: string; json?: boolean; dryRun?: boolean }) {
|
|
30
32
|
const limit = Math.max(1, Number.parseInt(opts.limit ?? '10', 10) || 10);
|
|
31
33
|
try {
|
|
32
34
|
const showProgress = !opts.json && !opts.dryRun;
|
|
@@ -36,11 +38,15 @@ async function handleReflect(opts: { limit?: string; json?: boolean; dryRun?: bo
|
|
|
36
38
|
|
|
37
39
|
const corpus = await gatherReflectionCorpus(limit, showProgress ? printProgress : undefined);
|
|
38
40
|
const withSessions = corpus.workspaces.filter((w) => w.session).length;
|
|
39
|
-
|
|
41
|
+
// A script does the heavy analysis (clustering failures, counting tools,
|
|
42
|
+
// spotting correction loops); the LLM only ever sees these compact facts,
|
|
43
|
+
// so the judge call stays small + fast regardless of workspace count.
|
|
44
|
+
const analysis = analyzeCorpus(corpus);
|
|
45
|
+
const prompt = buildAnalysisPrompt(analysis);
|
|
40
46
|
|
|
41
47
|
if (opts.dryRun) {
|
|
42
|
-
// No LLM call — surface exactly what the judge
|
|
43
|
-
if (opts.json) outputJson({
|
|
48
|
+
// No LLM call — surface the computed facts + exactly what the judge sees.
|
|
49
|
+
if (opts.json) outputJson({ analysis, prompt });
|
|
44
50
|
else {
|
|
45
51
|
process.stdout.write(prompt + '\n');
|
|
46
52
|
}
|
|
@@ -58,12 +64,14 @@ async function handleReflect(opts: { limit?: string; json?: boolean; dryRun?: bo
|
|
|
58
64
|
// (non-blocking) so a live spinner shows it's alive, not hung. Timeout is
|
|
59
65
|
// overridable for slow local models.
|
|
60
66
|
const timeoutMs = Number.parseInt(process.env.NEMUS_JUDGE_TIMEOUT_MS ?? '', 10) || undefined;
|
|
67
|
+
const model = opts.model ?? process.env.NEMUS_JUDGE_MODEL ?? undefined;
|
|
68
|
+
const thinking = opts.thinking ?? process.env.NEMUS_JUDGE_THINKING ?? undefined;
|
|
61
69
|
const stopSpinner = opts.json
|
|
62
70
|
? () => {}
|
|
63
71
|
: startSpinner(`Judging ${withSessions} session(s) with your configured agent (this can take a minute)…`);
|
|
64
72
|
let parsed: unknown;
|
|
65
73
|
try {
|
|
66
|
-
parsed = await runAgentJsonAsync(prompt, { schema: REFLECT_SCHEMA, timeoutMs });
|
|
74
|
+
parsed = await runAgentJsonAsync(prompt, { schema: REFLECT_SCHEMA, timeoutMs, model, thinking });
|
|
67
75
|
} finally {
|
|
68
76
|
stopSpinner();
|
|
69
77
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseAgentJson, runAgentRaw, runAgentRawAsync, agentAttempts } from './agent-judge';
|
|
2
|
+
import { parseAgentJson, runAgentRaw, runAgentRawAsync, agentAttempts, spawnCollect } from './agent-judge';
|
|
3
3
|
|
|
4
4
|
describe('parseAgentJson', () => {
|
|
5
5
|
it('unwraps the common agent envelopes and shapes', () => {
|
|
@@ -16,9 +16,24 @@ describe('parseAgentJson', () => {
|
|
|
16
16
|
});
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
describe('spawnCollect (stdin-hang regression)', () => {
|
|
20
|
+
it('gives the child stdin EOF so a stdin-reading process does NOT hang', async () => {
|
|
21
|
+
// `cat` with no args reads stdin to EOF. If stdin were an open pipe (the old
|
|
22
|
+
// execFile default) this would block until the timeout and reject; with
|
|
23
|
+
// stdin ignored it gets immediate EOF and exits 0 fast. 2s timeout << any hang.
|
|
24
|
+
const out = await spawnCollect('cat', [], { timeout: 2000, maxBuffer: 1 << 20 });
|
|
25
|
+
expect(out).toBe('');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('captures stdout and rejects on non-zero exit', async () => {
|
|
29
|
+
await expect(spawnCollect('node', ['-e', 'process.stdout.write("hi")'], { timeout: 5000, maxBuffer: 1 << 20 })).resolves.toBe('hi');
|
|
30
|
+
await expect(spawnCollect('node', ['-e', 'process.exit(3)'], { timeout: 5000, maxBuffer: 1 << 20 })).rejects.toThrow(/exit 3/);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
19
34
|
describe('agentAttempts', () => {
|
|
20
35
|
it('claude: preferred (schema + lean flags) then a plain fallback', () => {
|
|
21
|
-
const a = agentAttempts('claude', 'P', '{"type":"object"}');
|
|
36
|
+
const a = agentAttempts('claude', 'P', { schema: '{"type":"object"}' });
|
|
22
37
|
expect(a[0]).toEqual({ cmd: 'claude', args: expect.arrayContaining(['-p', 'P', '--output-format', 'json', '--json-schema', '{"type":"object"}']) });
|
|
23
38
|
expect(a[1]).toEqual({ cmd: 'claude', args: ['-p', 'P'] });
|
|
24
39
|
});
|
|
@@ -28,6 +43,26 @@ describe('agentAttempts', () => {
|
|
|
28
43
|
expect(pi[1]).toEqual({ cmd: 'pi', args: ['-p', 'P'] });
|
|
29
44
|
expect(agentAttempts('opencode', 'P')).toEqual([{ cmd: 'opencode', args: ['run', 'P'] }]);
|
|
30
45
|
});
|
|
46
|
+
it('threads --model (all) + --thinking (pi only); fallback keeps model, drops --thinking', () => {
|
|
47
|
+
const pi = agentAttempts('pi', 'P', { model: 'haiku', thinking: 'low' });
|
|
48
|
+
expect(pi[0].args).toEqual(expect.arrayContaining(['--model', 'haiku', '--thinking', 'low', '-p', 'P']));
|
|
49
|
+
// Safety net: model stays (stable flag), --thinking is dropped so an old pi
|
|
50
|
+
// that rejects --thinking still has a working fallback.
|
|
51
|
+
expect(pi[1].args).toEqual(['--model', 'haiku', '-p', 'P']);
|
|
52
|
+
expect(pi[1].args).not.toContain('--thinking');
|
|
53
|
+
const cl = agentAttempts('claude', 'P', { model: 'sonnet' });
|
|
54
|
+
expect(cl[0].args).toEqual(expect.arrayContaining(['--model', 'sonnet']));
|
|
55
|
+
expect(cl[0].args).not.toContain('--thinking'); // thinking is pi-only
|
|
56
|
+
expect(agentAttempts('opencode', 'P', { model: 'gpt' })[0].args).toEqual(['run', 'P', '--model', 'gpt']);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('runAgentRaw thinking default', () => {
|
|
61
|
+
it('applies the low-thinking default for pi', () => {
|
|
62
|
+
let seen: string[] = [];
|
|
63
|
+
runAgentRaw('P', { agentType: 'pi', exec: (cmd, args) => ((seen = [cmd, ...args]), 'ok') });
|
|
64
|
+
expect(seen).toEqual(expect.arrayContaining(['--thinking', 'low']));
|
|
65
|
+
});
|
|
31
66
|
});
|
|
32
67
|
|
|
33
68
|
describe('runAgentRawAsync', () => {
|
package/src/utils/agent-judge.ts
CHANGED
|
@@ -1,8 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { promisify } from 'util';
|
|
1
|
+
import { execFileSync, spawn } from 'child_process';
|
|
3
2
|
import { getPrimaryAgent } from './agent-config';
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Run a child to completion, capturing stdout, with stdin set to /dev/null.
|
|
6
|
+
*
|
|
7
|
+
* The stdin part is load-bearing: agents like `pi` block waiting on stdin if
|
|
8
|
+
* it's an open pipe (the default for execFile), which made the judge hang until
|
|
9
|
+
* the timeout regardless of prompt size or model speed. `stdio: ['ignore', …]`
|
|
10
|
+
* gives the child an immediate EOF, exactly like a non-interactive shell.
|
|
11
|
+
*/
|
|
12
|
+
export function spawnCollect(
|
|
13
|
+
cmd: string,
|
|
14
|
+
args: string[],
|
|
15
|
+
opts: { timeout: number; maxBuffer: number },
|
|
16
|
+
): Promise<string> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = spawn(cmd, args, {
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
timeout: opts.timeout,
|
|
21
|
+
killSignal: 'SIGKILL',
|
|
22
|
+
});
|
|
23
|
+
// Decode as UTF-8 at the stream boundary so a multi-byte char split across
|
|
24
|
+
// two chunks isn't corrupted (which would break JSON.parse downstream).
|
|
25
|
+
child.stdout.setEncoding('utf8');
|
|
26
|
+
child.stderr.setEncoding('utf8');
|
|
27
|
+
let stdout = '';
|
|
28
|
+
let stderr = '';
|
|
29
|
+
let overflow = false;
|
|
30
|
+
child.stdout.on('data', (d) => {
|
|
31
|
+
stdout += d;
|
|
32
|
+
if (stdout.length > opts.maxBuffer) {
|
|
33
|
+
overflow = true;
|
|
34
|
+
child.kill('SIGKILL');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
child.stderr.on('data', (d) => {
|
|
38
|
+
stderr += d;
|
|
39
|
+
});
|
|
40
|
+
child.on('error', (e) => reject(e));
|
|
41
|
+
child.on('close', (code, signal) => {
|
|
42
|
+
if (overflow) return reject(Object.assign(new Error('maxBuffer exceeded'), { stdout, stderr }));
|
|
43
|
+
if (signal) return reject(Object.assign(new Error(`killed by ${signal}`), { killed: true, signal, stdout, stderr }));
|
|
44
|
+
if (code !== 0) return reject(Object.assign(new Error(`exit ${code}`), { code, stdout, stderr }));
|
|
45
|
+
resolve(stdout);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
6
49
|
|
|
7
50
|
/**
|
|
8
51
|
* Run the user's configured coding agent headlessly as an "LLM-as-a-judge":
|
|
@@ -17,6 +60,10 @@ const execFileAsync = promisify(execFile);
|
|
|
17
60
|
export interface JudgeOptions {
|
|
18
61
|
/** JSON schema string passed to `claude --json-schema` (ignored by others). */
|
|
19
62
|
schema?: string;
|
|
63
|
+
/** Model override (`--model`), agent-native pattern/id. */
|
|
64
|
+
model?: string;
|
|
65
|
+
/** Thinking level (`--thinking`, pi only): off|minimal|low|medium|high|xhigh|max. */
|
|
66
|
+
thinking?: string;
|
|
20
67
|
timeoutMs?: number;
|
|
21
68
|
maxBuffer?: number;
|
|
22
69
|
/** Injected for tests. Defaults to the real (blocking) child_process runner. */
|
|
@@ -34,24 +81,61 @@ export interface AgentAttempt {
|
|
|
34
81
|
args: string[];
|
|
35
82
|
}
|
|
36
83
|
|
|
84
|
+
export interface AttemptOptions {
|
|
85
|
+
schema?: string;
|
|
86
|
+
model?: string;
|
|
87
|
+
thinking?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Default thinking level for the judge. The judge is a mechanical transform
|
|
92
|
+
* (facts → recommendations), not deep reasoning, so a heavy default like Opus
|
|
93
|
+
* @ medium thinking just makes it slow. `low` keeps pi fast; override per-run.
|
|
94
|
+
*/
|
|
95
|
+
export const DEFAULT_JUDGE_THINKING = 'low';
|
|
96
|
+
|
|
37
97
|
/**
|
|
38
98
|
* The ordered invocation attempts for an agent (preferred → fallback), as pure
|
|
39
99
|
* data so both the sync and async runners share ONE flag ladder (and it's
|
|
40
|
-
* unit-testable without spawning anything).
|
|
100
|
+
* unit-testable without spawning anything). `--model` applies to all; pi also
|
|
101
|
+
* takes `--thinking` (the speed lever); both are ignored where unsupported.
|
|
41
102
|
*/
|
|
42
|
-
export function agentAttempts(agentType: JudgeAgentType, prompt: string,
|
|
103
|
+
export function agentAttempts(agentType: JudgeAgentType, prompt: string, opts: AttemptOptions = {}): AgentAttempt[] {
|
|
104
|
+
const { schema, model, thinking } = opts;
|
|
43
105
|
if (agentType === 'claude') {
|
|
44
106
|
const preferred = ['-p', prompt, '--output-format', 'json', '--bare', '--strict-mcp-config', '--disable-slash-commands'];
|
|
107
|
+
if (model) preferred.push('--model', model);
|
|
45
108
|
if (schema) preferred.push('--json-schema', schema);
|
|
46
109
|
// Older claude may reject the newer flags — fall back to the plainest form.
|
|
47
|
-
|
|
110
|
+
const plain = ['-p', prompt];
|
|
111
|
+
if (model) plain.push('--model', model);
|
|
112
|
+
return [{ cmd: 'claude', args: preferred }, { cmd: 'claude', args: plain }];
|
|
48
113
|
}
|
|
49
114
|
if (agentType === 'opencode') {
|
|
50
|
-
|
|
115
|
+
const args = ['run', prompt];
|
|
116
|
+
if (model) args.push('--model', model);
|
|
117
|
+
return [{ cmd: 'opencode', args }];
|
|
51
118
|
}
|
|
52
119
|
// pi (and any other): run as lean as possible so a bloated env can't hang it.
|
|
53
120
|
const piLean = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files', '--no-tools', '--no-session'];
|
|
54
|
-
|
|
121
|
+
const modelArgs = model ? ['--model', model] : [];
|
|
122
|
+
const tune = thinking ? [...modelArgs, '--thinking', thinking] : modelArgs;
|
|
123
|
+
// Fallback keeps the stable --model but DROPS --thinking: --thinking is the
|
|
124
|
+
// newest flag and the most likely reason an older pi rejects the first
|
|
125
|
+
// attempt, so the safety net must not carry it (else both attempts fail).
|
|
126
|
+
return [
|
|
127
|
+
{ cmd: 'pi', args: [...piLean, ...tune, '-p', prompt] },
|
|
128
|
+
{ cmd: 'pi', args: [...modelArgs, '-p', prompt] },
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Resolve the attempt options for a run, applying the pi thinking default. */
|
|
133
|
+
function attemptOptions(agentType: JudgeAgentType, opts: JudgeOptions): AttemptOptions {
|
|
134
|
+
return {
|
|
135
|
+
schema: opts.schema,
|
|
136
|
+
model: opts.model,
|
|
137
|
+
thinking: opts.thinking ?? (agentType === 'pi' ? DEFAULT_JUDGE_THINKING : undefined),
|
|
138
|
+
};
|
|
55
139
|
}
|
|
56
140
|
|
|
57
141
|
function wrapJudgeError(err: any, agentType: string): Error {
|
|
@@ -80,9 +164,11 @@ export function runAgentRaw(prompt: string, opts: JudgeOptions = {}): string {
|
|
|
80
164
|
const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
81
165
|
const exec =
|
|
82
166
|
opts.exec ??
|
|
83
|
-
|
|
167
|
+
// `input: ''` closes the child's stdin (EOF) so a stdin-reading agent (pi)
|
|
168
|
+
// can't hang the synchronous call — the sync twin of spawnCollect's fix.
|
|
169
|
+
((cmd, args, o) => execFileSync(cmd, args, { encoding: 'utf-8', input: '', timeout: o.timeout, maxBuffer: o.maxBuffer }));
|
|
84
170
|
|
|
85
|
-
const attempts = agentAttempts(agentType, prompt, opts
|
|
171
|
+
const attempts = agentAttempts(agentType, prompt, attemptOptions(agentType, opts));
|
|
86
172
|
let lastErr: any;
|
|
87
173
|
for (const a of attempts) {
|
|
88
174
|
try {
|
|
@@ -103,12 +189,9 @@ export async function runAgentRawAsync(prompt: string, opts: JudgeOptions = {}):
|
|
|
103
189
|
const agentType = opts.agentType ?? getPrimaryAgent().type;
|
|
104
190
|
const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
105
191
|
const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
106
|
-
const exec =
|
|
107
|
-
opts.execAsync ??
|
|
108
|
-
(async (cmd, args, o) =>
|
|
109
|
-
(await execFileAsync(cmd, args, { encoding: 'utf-8', timeout: o.timeout, maxBuffer: o.maxBuffer })).stdout.toString());
|
|
192
|
+
const exec = opts.execAsync ?? ((cmd, args, o) => spawnCollect(cmd, args, o));
|
|
110
193
|
|
|
111
|
-
const attempts = agentAttempts(agentType, prompt, opts
|
|
194
|
+
const attempts = agentAttempts(agentType, prompt, attemptOptions(agentType, opts));
|
|
112
195
|
let lastErr: any;
|
|
113
196
|
for (const a of attempts) {
|
|
114
197
|
try {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { normalizeErrorSignature, analyzeCorpus, buildAnalysisPrompt } from './reflect-analyze';
|
|
3
|
+
import { ReflectionCorpus, isCorrectionPrompt } from './reflect';
|
|
4
|
+
|
|
5
|
+
describe('normalizeErrorSignature', () => {
|
|
6
|
+
it('collapses paths, numbers, and hashes so variants cluster', () => {
|
|
7
|
+
const a = normalizeErrorSignature('fatal: not a git repository (or any parent up to /Users/alice/work/repo)');
|
|
8
|
+
const b = normalizeErrorSignature('fatal: not a git repository (or any parent up to /home/bob/src/thing)');
|
|
9
|
+
expect(a).toBe(b);
|
|
10
|
+
expect(a).toContain('<path>');
|
|
11
|
+
|
|
12
|
+
expect(normalizeErrorSignature('exit code 137 after 4200ms')).toBe(
|
|
13
|
+
normalizeErrorSignature('exit code 2 after 9ms'),
|
|
14
|
+
);
|
|
15
|
+
expect(normalizeErrorSignature('object abc1234def not found')).toBe(
|
|
16
|
+
normalizeErrorSignature('object 0f9e8d7c6b not found'),
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('is bounded and safe on empty input', () => {
|
|
21
|
+
expect(normalizeErrorSignature('')).toBe('');
|
|
22
|
+
expect(normalizeErrorSignature('x'.repeat(500)).length).toBeLessThanOrEqual(100);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('isCorrectionPrompt', () => {
|
|
27
|
+
it('flags corrections, not normal instructions', () => {
|
|
28
|
+
expect(isCorrectionPrompt('no, that is wrong — revert it')).toBe(true);
|
|
29
|
+
expect(isCorrectionPrompt('actually use the other repo instead')).toBe(true);
|
|
30
|
+
expect(isCorrectionPrompt('you forgot to run the tests')).toBe(true);
|
|
31
|
+
expect(isCorrectionPrompt('add a health check to the api')).toBe(false);
|
|
32
|
+
expect(isCorrectionPrompt('create a workspace for payments')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const corpus: ReflectionCorpus = {
|
|
37
|
+
generatedAt: 'now',
|
|
38
|
+
availableSkills: ['redash'],
|
|
39
|
+
workspaces: [
|
|
40
|
+
{
|
|
41
|
+
name: 'pay-app',
|
|
42
|
+
repoCount: 1,
|
|
43
|
+
repos: ['api'],
|
|
44
|
+
contextFiles: ['AGENTS.md'],
|
|
45
|
+
contextQuality: 'substantive',
|
|
46
|
+
session: {
|
|
47
|
+
sessionId: 's1',
|
|
48
|
+
agentType: 'pi',
|
|
49
|
+
turns: 40,
|
|
50
|
+
userPrompts: ['fix the sync bug', 'no that is wrong, revert'],
|
|
51
|
+
reSteerSamples: ['no that is wrong, revert'],
|
|
52
|
+
errors: [
|
|
53
|
+
'fatal: not a git repository (at /Users/a/pay-app/api)',
|
|
54
|
+
'fatal: not a git repository (at /Users/a/pay-app/web)',
|
|
55
|
+
'gh: Not Found (HTTP 404)',
|
|
56
|
+
],
|
|
57
|
+
tools: ['bash', 'edit'],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'ledger',
|
|
62
|
+
repoCount: 0,
|
|
63
|
+
repos: [],
|
|
64
|
+
contextFiles: [], // missing context
|
|
65
|
+
contextQuality: 'missing',
|
|
66
|
+
session: {
|
|
67
|
+
sessionId: 's2',
|
|
68
|
+
agentType: 'pi',
|
|
69
|
+
turns: 12,
|
|
70
|
+
userPrompts: ['add tests'],
|
|
71
|
+
reSteerSamples: [],
|
|
72
|
+
errors: ['fatal: not a git repository (at /home/b/ledger)'],
|
|
73
|
+
tools: ['bash'],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{ name: 'idle-ws', repoCount: 0, repos: [], contextFiles: ['CLAUDE.md'], contextQuality: 'boilerplate', session: null },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
describe('analyzeCorpus', () => {
|
|
81
|
+
const a = analyzeCorpus(corpus);
|
|
82
|
+
|
|
83
|
+
it('aggregates totals and correction signals', () => {
|
|
84
|
+
expect(a.totalWorkspaces).toBe(3);
|
|
85
|
+
expect(a.sessionsAnalyzed).toBe(2); // idle-ws has no session
|
|
86
|
+
expect(a.totalTurns).toBe(52);
|
|
87
|
+
expect(a.correctionSignals).toBe(1); // "no that is wrong, revert"
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('clusters the recurring failure across workspaces, most frequent first', () => {
|
|
91
|
+
const top = a.topErrors[0];
|
|
92
|
+
expect(top.signature).toContain('not a git repository');
|
|
93
|
+
expect(top.count).toBe(3); // 2 in pay-app + 1 in ledger
|
|
94
|
+
expect(top.workspaces.sort()).toEqual(['ledger', 'pay-app']);
|
|
95
|
+
expect(top.example).toContain('fatal: not a git repository');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('reports tools, context quality, re-steers, and per-workspace facts', () => {
|
|
99
|
+
expect(a.topTools[0]).toEqual({ tool: 'bash', sessions: 2 });
|
|
100
|
+
expect(a.workspacesMissingContext).toEqual(['ledger']);
|
|
101
|
+
expect(a.workspacesBoilerplateContext).toEqual(['idle-ws']);
|
|
102
|
+
expect(a.reSteerSamples).toEqual(['no that is wrong, revert']);
|
|
103
|
+
const pay = a.workspaces.find((w) => w.name === 'pay-app')!;
|
|
104
|
+
expect(pay).toMatchObject({ turns: 40, failures: 3, contextQuality: 'substantive' });
|
|
105
|
+
expect(pay.topFailure).toContain('not a git repository');
|
|
106
|
+
expect(a.workspaces.find((w) => w.name === 'idle-ws')).toMatchObject({ turns: 0, failures: 0, contextQuality: 'boilerplate' });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('buildAnalysisPrompt', () => {
|
|
111
|
+
it('is compact and fact-based (no raw transcripts)', () => {
|
|
112
|
+
const p = buildAnalysisPrompt(analyzeCorpus(corpus));
|
|
113
|
+
expect(p).toContain('A script has already analyzed');
|
|
114
|
+
expect(p).toContain('Sessions analyzed: 2 across 3 workspaces (52 total turns).');
|
|
115
|
+
expect(p).toContain('Top recurring failures');
|
|
116
|
+
expect(p).toMatch(/\[3\u00d7 in 2 ws\]/); // the clustered failure
|
|
117
|
+
expect(p).toContain('NO context file (AGENTS.md/CLAUDE.md): ledger');
|
|
118
|
+
expect(p).toContain('boilerplate/template: idle-ws');
|
|
119
|
+
expect(p).toContain('Verbatim user corrections');
|
|
120
|
+
expect(p).toContain('no that is wrong, revert');
|
|
121
|
+
expect(p).toContain('bash(2)');
|
|
122
|
+
expect(p).toMatch(/ONLY a JSON object/);
|
|
123
|
+
// Compact: even this corpus stays well under a few KB.
|
|
124
|
+
expect(p.length).toBeLessThan(3000);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { ReflectionCorpus, ContextQuality, isCorrectionPrompt } from './reflect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic analysis layer for `reflect`.
|
|
5
|
+
*
|
|
6
|
+
* The LLM used to read raw transcripts (every prompt + every error) for every
|
|
7
|
+
* workspace, which made the judge prompt large and slow (timeouts on local
|
|
8
|
+
* models). Instead, a *script* does the heavy lifting here — clustering repeated
|
|
9
|
+
* failures, counting tools, spotting correction/retry loops — and the LLM only
|
|
10
|
+
* ever sees a small, pre-computed set of FACTS. That keeps the judge call fast
|
|
11
|
+
* and bounded no matter how many workspaces are analyzed, and grounds every
|
|
12
|
+
* recommendation in real counts rather than a wall of text.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure (corpus in → report out) so it's exhaustively
|
|
15
|
+
* unit-tested without spawning an agent.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ------------------------------------------------------------------ types
|
|
19
|
+
|
|
20
|
+
export interface ErrorCluster {
|
|
21
|
+
/** Normalized signature (paths/numbers/hashes stripped) used to group. */
|
|
22
|
+
signature: string;
|
|
23
|
+
/** Total occurrences across all analyzed sessions. */
|
|
24
|
+
count: number;
|
|
25
|
+
/** Distinct workspaces the signature appeared in (recurrence = strong signal). */
|
|
26
|
+
workspaces: string[];
|
|
27
|
+
/** One representative raw example (bounded). */
|
|
28
|
+
example: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToolStat {
|
|
32
|
+
tool: string;
|
|
33
|
+
/** Number of sessions that used the tool (not raw invocation count — the
|
|
34
|
+
* digest only records distinct tools per session). */
|
|
35
|
+
sessions: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WorkspaceFact {
|
|
39
|
+
name: string;
|
|
40
|
+
turns: number;
|
|
41
|
+
failures: number;
|
|
42
|
+
/** Whether the primary context file is missing / boilerplate / substantive. */
|
|
43
|
+
contextQuality: ContextQuality;
|
|
44
|
+
/** The workspace's single most frequent failure signature, if any. */
|
|
45
|
+
topFailure?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AnalysisReport {
|
|
49
|
+
totalWorkspaces: number;
|
|
50
|
+
sessionsAnalyzed: number;
|
|
51
|
+
totalTurns: number;
|
|
52
|
+
/** Recurring failures, most frequent first. */
|
|
53
|
+
topErrors: ErrorCluster[];
|
|
54
|
+
/** Most-used tools, most sessions first. */
|
|
55
|
+
topTools: ToolStat[];
|
|
56
|
+
/** How many user prompts looked like corrections/retries (a friction signal). */
|
|
57
|
+
correctionSignals: number;
|
|
58
|
+
/** Verbatim user re-steer messages across sessions (the sharpest coaching
|
|
59
|
+
* signal), most-recent-workspace first, bounded. */
|
|
60
|
+
reSteerSamples: string[];
|
|
61
|
+
/** Workspaces with NO context file at all. */
|
|
62
|
+
workspacesMissingContext: string[];
|
|
63
|
+
/** Workspaces whose context file exists but is just boilerplate/template. */
|
|
64
|
+
workspacesBoilerplateContext: string[];
|
|
65
|
+
/** Skills already installed (so the judge suggests genuine gaps). */
|
|
66
|
+
availableSkills: string[];
|
|
67
|
+
/** One compact line of facts per workspace, for grounding. */
|
|
68
|
+
workspaces: WorkspaceFact[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --------------------------------------------------------------- normalizing
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reduce a raw error string to a stable signature so near-identical failures
|
|
75
|
+
* cluster together: lowercase, drop quotes, replace filesystem paths, hashes,
|
|
76
|
+
* hex ids and bare numbers with placeholders, collapse whitespace, and cap the
|
|
77
|
+
* length. `"fatal: not a git repository (or any of the parent up to /Users/x)"`
|
|
78
|
+
* and the same from another path collapse to one signature.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeErrorSignature(raw: string): string {
|
|
81
|
+
let s = (raw ?? '').toLowerCase();
|
|
82
|
+
s = s.replace(/[`'"]/g, ' ');
|
|
83
|
+
// Windows paths first (contain ':' and '\'), then unix paths.
|
|
84
|
+
s = s.replace(/[a-z]:\\[^\s]+/g, '<path>');
|
|
85
|
+
s = s.replace(/\/[^\s:)'"]+/g, '<path>');
|
|
86
|
+
// Long hex / uuids / sha-like tokens before bare numbers.
|
|
87
|
+
s = s.replace(/\b[0-9a-f]{7,}\b/g, '<hash>');
|
|
88
|
+
// No trailing \b, so a number glued to a unit ('4200ms', '9ms') still clusters.
|
|
89
|
+
s = s.replace(/\b\d[\d.,:]*/g, '<n>');
|
|
90
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
91
|
+
return s.slice(0, 100);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ------------------------------------------------------------------ analyze
|
|
95
|
+
|
|
96
|
+
export interface AnalyzeOptions {
|
|
97
|
+
/** Max error clusters to surface (default 8). */
|
|
98
|
+
topErrors?: number;
|
|
99
|
+
/** Max tools to surface (default 10). */
|
|
100
|
+
topTools?: number;
|
|
101
|
+
/** Max chars of a raw error kept as the example (default 160). */
|
|
102
|
+
exampleChars?: number;
|
|
103
|
+
/** Max verbatim re-steer samples to surface to the judge (default 8). */
|
|
104
|
+
maxReSteer?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Turn a distilled corpus into aggregated facts. This is the "script processes
|
|
109
|
+
* the data" step — the LLM never sees the raw corpus, only the returned report.
|
|
110
|
+
*/
|
|
111
|
+
export function analyzeCorpus(corpus: ReflectionCorpus, opts: AnalyzeOptions = {}): AnalysisReport {
|
|
112
|
+
const topErrorsK = opts.topErrors ?? 8;
|
|
113
|
+
const topToolsK = opts.topTools ?? 10;
|
|
114
|
+
const exampleChars = opts.exampleChars ?? 160;
|
|
115
|
+
const maxReSteer = opts.maxReSteer ?? 8;
|
|
116
|
+
|
|
117
|
+
const errorMap = new Map<string, { count: number; ws: Set<string>; example: string }>();
|
|
118
|
+
const toolMap = new Map<string, number>();
|
|
119
|
+
const workspacesMissingContext: string[] = [];
|
|
120
|
+
const workspacesBoilerplateContext: string[] = [];
|
|
121
|
+
const reSteerSamples: string[] = [];
|
|
122
|
+
const workspaces: WorkspaceFact[] = [];
|
|
123
|
+
let sessionsAnalyzed = 0;
|
|
124
|
+
let totalTurns = 0;
|
|
125
|
+
let correctionSignals = 0;
|
|
126
|
+
|
|
127
|
+
for (const ws of corpus.workspaces) {
|
|
128
|
+
if (ws.contextQuality === 'missing') workspacesMissingContext.push(ws.name);
|
|
129
|
+
else if (ws.contextQuality === 'boilerplate') workspacesBoilerplateContext.push(ws.name);
|
|
130
|
+
|
|
131
|
+
const s = ws.session;
|
|
132
|
+
if (!s) {
|
|
133
|
+
workspaces.push({ name: ws.name, turns: 0, failures: 0, contextQuality: ws.contextQuality });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
sessionsAnalyzed++;
|
|
138
|
+
totalTurns += s.turns;
|
|
139
|
+
for (const t of s.tools) toolMap.set(t, (toolMap.get(t) ?? 0) + 1);
|
|
140
|
+
correctionSignals += s.userPrompts.filter(isCorrectionPrompt).length;
|
|
141
|
+
for (const q of s.reSteerSamples) {
|
|
142
|
+
if (reSteerSamples.length < maxReSteer) reSteerSamples.push(q);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Per-workspace signature tally (drives the global map + this ws's topFailure).
|
|
146
|
+
const localSig = new Map<string, number>();
|
|
147
|
+
for (const e of s.errors) {
|
|
148
|
+
const sig = normalizeErrorSignature(e);
|
|
149
|
+
if (!sig) continue;
|
|
150
|
+
localSig.set(sig, (localSig.get(sig) ?? 0) + 1);
|
|
151
|
+
let entry = errorMap.get(sig);
|
|
152
|
+
if (!entry) {
|
|
153
|
+
entry = { count: 0, ws: new Set(), example: e.slice(0, exampleChars) };
|
|
154
|
+
errorMap.set(sig, entry);
|
|
155
|
+
}
|
|
156
|
+
entry.count++;
|
|
157
|
+
entry.ws.add(ws.name);
|
|
158
|
+
}
|
|
159
|
+
const topFailure = [...localSig.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
160
|
+
|
|
161
|
+
workspaces.push({ name: ws.name, turns: s.turns, failures: s.errors.length, contextQuality: ws.contextQuality, topFailure });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const topErrors: ErrorCluster[] = [...errorMap.entries()]
|
|
165
|
+
.map(([signature, v]) => ({ signature, count: v.count, workspaces: [...v.ws], example: v.example }))
|
|
166
|
+
// Most frequent first; break ties by cross-workspace spread (recurrence).
|
|
167
|
+
.sort((a, b) => b.count - a.count || b.workspaces.length - a.workspaces.length)
|
|
168
|
+
.slice(0, topErrorsK);
|
|
169
|
+
|
|
170
|
+
const topTools: ToolStat[] = [...toolMap.entries()]
|
|
171
|
+
.map(([tool, sessions]) => ({ tool, sessions }))
|
|
172
|
+
.sort((a, b) => b.sessions - a.sessions || a.tool.localeCompare(b.tool))
|
|
173
|
+
.slice(0, topToolsK);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
totalWorkspaces: corpus.workspaces.length,
|
|
177
|
+
sessionsAnalyzed,
|
|
178
|
+
totalTurns,
|
|
179
|
+
topErrors,
|
|
180
|
+
topTools,
|
|
181
|
+
correctionSignals,
|
|
182
|
+
reSteerSamples,
|
|
183
|
+
workspacesMissingContext,
|
|
184
|
+
workspacesBoilerplateContext,
|
|
185
|
+
availableSkills: corpus.availableSkills,
|
|
186
|
+
workspaces,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --------------------------------------------------------------- judge prompt
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build the judge prompt from the pre-computed facts. Compact by construction
|
|
194
|
+
* (a handful of clusters + counts), so the LLM call stays small and fast no
|
|
195
|
+
* matter how many workspaces were analyzed. Output contract is unchanged
|
|
196
|
+
* (REFLECT_SCHEMA), so parsing/rendering are shared with the old path.
|
|
197
|
+
*/
|
|
198
|
+
export function buildAnalysisPrompt(a: AnalysisReport): string {
|
|
199
|
+
const L: string[] = [];
|
|
200
|
+
L.push(
|
|
201
|
+
'You are an expert reviewer ("LLM as a judge"). A script has already analyzed an engineer\'s',
|
|
202
|
+
'recent AI coding-agent sessions and distilled them into the FACTS below. Do not ask for the',
|
|
203
|
+
'raw transcripts — reason only from these facts.',
|
|
204
|
+
'',
|
|
205
|
+
'Goal: recommend concrete improvements to their SETUP so the agent works better next time —',
|
|
206
|
+
'which skills to add and WHERE, which AGENTS.md/context rules are missing, missing connectivity/',
|
|
207
|
+
'smoke tests, and prompt/workflow habits. Ground every recommendation in a fact below',
|
|
208
|
+
'(a recurring failure, a correction loop, a missing context file). Prefer a few high-signal',
|
|
209
|
+
'items over many generic ones. Include a concrete `example` snippet for skills/context rules.',
|
|
210
|
+
'',
|
|
211
|
+
`Sessions analyzed: ${a.sessionsAnalyzed} across ${a.totalWorkspaces} workspaces (${a.totalTurns} total turns).`,
|
|
212
|
+
`Installed skills (don't re-suggest; find genuine gaps): ${a.availableSkills.join(', ') || '(none)'}`,
|
|
213
|
+
`User correction/retry signals: ${a.correctionSignals} prompt(s) looked like corrections.`,
|
|
214
|
+
`Workspaces with NO context file (AGENTS.md/CLAUDE.md): ${a.workspacesMissingContext.join(', ') || '(none)'}`,
|
|
215
|
+
`Workspaces whose context file is just boilerplate/template: ${a.workspacesBoilerplateContext.join(', ') || '(none)'}`,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (a.reSteerSamples.length) {
|
|
219
|
+
L.push('', 'Verbatim user corrections/re-steers (the sharpest signal — quote/act on these):');
|
|
220
|
+
for (const q of a.reSteerSamples) L.push(` - “${q.replace(/\n/g, ' ')}”`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
L.push('', 'Top recurring failures (count × workspaces — example):');
|
|
224
|
+
if (a.topErrors.length === 0) L.push(' (none captured)');
|
|
225
|
+
for (const e of a.topErrors) {
|
|
226
|
+
L.push(` - [${e.count}× in ${e.workspaces.length} ws] ${e.signature}`);
|
|
227
|
+
L.push(` e.g. ${e.example.replace(/\n/g, ' ')}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
L.push('', `Most-used tools: ${a.topTools.map((t) => `${t.tool}(${t.sessions})`).join(', ') || '(none)'}`);
|
|
231
|
+
|
|
232
|
+
L.push('', 'Per-workspace:');
|
|
233
|
+
for (const w of a.workspaces) {
|
|
234
|
+
const top = w.topFailure ? ` · top failure: ${w.topFailure}` : '';
|
|
235
|
+
L.push(` - ${w.name}: ${w.turns} turns, ${w.failures} failures, context:${w.contextQuality}${top}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
L.push(
|
|
239
|
+
'',
|
|
240
|
+
'Respond with ONLY a JSON object of this shape (no prose, no markdown fence):',
|
|
241
|
+
'{"summary": string, "recommendations": [{"kind":"skill|context|test|prompt|connectivity|workflow|other",',
|
|
242
|
+
'"title": string, "detail": string, "target": string(optional workspace/repo/path),',
|
|
243
|
+
'"priority":"high|medium|low", "example": string(optional snippet)}]}',
|
|
244
|
+
);
|
|
245
|
+
return L.join('\n');
|
|
246
|
+
}
|