@nemus-cli/nemus 0.2.13 → 0.3.1

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.
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseAgentJson, runAgentRaw, runAgentRawAsync, agentAttempts } from './agent-judge';
3
+
4
+ describe('parseAgentJson', () => {
5
+ it('unwraps the common agent envelopes and shapes', () => {
6
+ expect(parseAgentJson('{"a":1}')).toEqual({ a: 1 }); // bare object
7
+ expect(parseAgentJson(J({ structured_output: { a: 2 } }))).toEqual({ a: 2 }); // claude structured
8
+ expect(parseAgentJson(J({ result: J({ a: 3 }) }))).toEqual({ a: 3 }); // result-as-json-string
9
+ expect(parseAgentJson(J({ result: { a: 4 } }))).toEqual({ a: 4 }); // result-as-object
10
+ expect(parseAgentJson('```json\n{"a":5}\n```')).toEqual({ a: 5 }); // fenced
11
+ expect(parseAgentJson('noise before {"a":6} after')).toEqual({ a: 6 }); // outermost span fallback
12
+ });
13
+
14
+ it('throws when there is no JSON at all', () => {
15
+ expect(() => parseAgentJson('totally not json')).toThrow(/did not return JSON/);
16
+ });
17
+ });
18
+
19
+ describe('agentAttempts', () => {
20
+ it('claude: preferred (schema + lean flags) then a plain fallback', () => {
21
+ const a = agentAttempts('claude', 'P', '{"type":"object"}');
22
+ expect(a[0]).toEqual({ cmd: 'claude', args: expect.arrayContaining(['-p', 'P', '--output-format', 'json', '--json-schema', '{"type":"object"}']) });
23
+ expect(a[1]).toEqual({ cmd: 'claude', args: ['-p', 'P'] });
24
+ });
25
+ it('pi: lean then plain; opencode: single run', () => {
26
+ const pi = agentAttempts('pi', 'P');
27
+ expect(pi[0].args).toEqual(expect.arrayContaining(['--no-tools', '--no-skills', '-p', 'P']));
28
+ expect(pi[1]).toEqual({ cmd: 'pi', args: ['-p', 'P'] });
29
+ expect(agentAttempts('opencode', 'P')).toEqual([{ cmd: 'opencode', args: ['run', 'P'] }]);
30
+ });
31
+ });
32
+
33
+ describe('runAgentRawAsync', () => {
34
+ it('runs the preferred attempt and returns stdout', async () => {
35
+ const calls: string[][] = [];
36
+ const execAsync = async (cmd: string, args: string[]) => {
37
+ calls.push([cmd, ...args]);
38
+ return '{"ok":true}';
39
+ };
40
+ const out = await runAgentRawAsync('P', { agentType: 'pi', execAsync });
41
+ expect(out).toBe('{"ok":true}');
42
+ expect(calls).toHaveLength(1); // first attempt succeeded, no fallback
43
+ });
44
+
45
+ it('turns a timeout into an actionable error', async () => {
46
+ const execAsync = async () => {
47
+ throw Object.assign(new Error('spawn pi ETIMEDOUT'), { code: 'ETIMEDOUT', killed: true });
48
+ };
49
+ await expect(runAgentRawAsync('P', { agentType: 'pi', execAsync })).rejects.toThrow(/timed out.*NEMUS_JUDGE_TIMEOUT_MS/s);
50
+ });
51
+ });
52
+
53
+ describe('runAgentRaw', () => {
54
+ it('claude: passes the schema + lean flags, falls back on a rejected flag', () => {
55
+ const calls: string[][] = [];
56
+ const exec = (cmd: string, args: string[]) => {
57
+ calls.push([cmd, ...args]);
58
+ if (calls.length === 1) throw new Error('unknown flag --json-schema'); // old claude
59
+ return '{"ok":true}';
60
+ };
61
+ const out = runAgentRaw('PROMPT', { agentType: 'claude', schema: '{"type":"object"}', exec });
62
+ expect(out).toBe('{"ok":true}');
63
+ // first (preferred) attempt carries the schema + structured flags…
64
+ expect(calls[0]).toEqual(expect.arrayContaining(['claude', '-p', 'PROMPT', '--output-format', 'json', '--json-schema', '{"type":"object"}']));
65
+ // …the fallback is the plainest form
66
+ expect(calls[1]).toEqual(['claude', '-p', 'PROMPT']);
67
+ });
68
+
69
+ it('pi: runs with lean flags', () => {
70
+ let seen: string[] = [];
71
+ const exec = (cmd: string, args: string[]) => {
72
+ seen = [cmd, ...args];
73
+ return 'ok';
74
+ };
75
+ runAgentRaw('P', { agentType: 'pi', exec });
76
+ expect(seen).toEqual(expect.arrayContaining(['pi', '--no-extensions', '--no-skills', '--no-tools', '-p', 'P']));
77
+ });
78
+
79
+ it('wraps a hard failure in a clear error', () => {
80
+ const exec = () => { throw Object.assign(new Error('boom'), { stderr: 'agent exploded' }); };
81
+ // pi retries once (lean → plain), then throws
82
+ expect(() => runAgentRaw('P', { agentType: 'pi', exec })).toThrow(/agent judge failed \(pi\): agent exploded/);
83
+ });
84
+ });
85
+
86
+ function J(o: unknown) {
87
+ return JSON.stringify(o);
88
+ }
@@ -0,0 +1,168 @@
1
+ import { execFile, execFileSync } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { getPrimaryAgent } from './agent-config';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ /**
8
+ * Run the user's configured coding agent headlessly as an "LLM-as-a-judge":
9
+ * feed it a prompt, get back a parsed JSON object. This reuses whatever agent
10
+ * the user already has authenticated (claude / pi / opencode) — no API keys of
11
+ * our own. It mirrors the lean, structured invocation used for intent
12
+ * extraction, but generalized (any prompt + optional JSON schema).
13
+ *
14
+ * The call is bounded by a timeout and a large maxBuffer; a transcript-analysis
15
+ * judge returns more than intent extraction, so the buffer is generous.
16
+ */
17
+ export interface JudgeOptions {
18
+ /** JSON schema string passed to `claude --json-schema` (ignored by others). */
19
+ schema?: string;
20
+ timeoutMs?: number;
21
+ maxBuffer?: number;
22
+ /** Injected for tests. Defaults to the real (blocking) child_process runner. */
23
+ exec?: (cmd: string, args: string[], opts: { timeout: number; maxBuffer: number }) => string;
24
+ /** Injected for tests. Async runner used by the non-blocking variants. */
25
+ execAsync?: (cmd: string, args: string[], opts: { timeout: number; maxBuffer: number }) => Promise<string>;
26
+ /** Injected for tests. Defaults to the configured primary agent. */
27
+ agentType?: 'claude' | 'pi' | 'opencode' | 'codex' | 'gemini';
28
+ }
29
+
30
+ export type JudgeAgentType = NonNullable<JudgeOptions['agentType']>;
31
+
32
+ export interface AgentAttempt {
33
+ cmd: string;
34
+ args: string[];
35
+ }
36
+
37
+ /**
38
+ * The ordered invocation attempts for an agent (preferred → fallback), as pure
39
+ * data so both the sync and async runners share ONE flag ladder (and it's
40
+ * unit-testable without spawning anything).
41
+ */
42
+ export function agentAttempts(agentType: JudgeAgentType, prompt: string, schema?: string): AgentAttempt[] {
43
+ if (agentType === 'claude') {
44
+ const preferred = ['-p', prompt, '--output-format', 'json', '--bare', '--strict-mcp-config', '--disable-slash-commands'];
45
+ if (schema) preferred.push('--json-schema', schema);
46
+ // Older claude may reject the newer flags — fall back to the plainest form.
47
+ return [{ cmd: 'claude', args: preferred }, { cmd: 'claude', args: ['-p', prompt] }];
48
+ }
49
+ if (agentType === 'opencode') {
50
+ return [{ cmd: 'opencode', args: ['run', prompt] }];
51
+ }
52
+ // pi (and any other): run as lean as possible so a bloated env can't hang it.
53
+ const piLean = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files', '--no-tools', '--no-session'];
54
+ return [{ cmd: 'pi', args: [...piLean, '-p', prompt] }, { cmd: 'pi', args: ['-p', prompt] }];
55
+ }
56
+
57
+ function wrapJudgeError(err: any, agentType: string): Error {
58
+ // A timeout is the common failure (big prompt + slow local model), so make it
59
+ // actionable instead of surfacing a raw `spawn … ETIMEDOUT`.
60
+ if (err?.killed || err?.code === 'ETIMEDOUT' || err?.signal === 'SIGTERM' || /ETIMEDOUT/.test(String(err?.message ?? ''))) {
61
+ return new Error(
62
+ `agent judge (${agentType}) timed out. Try a smaller --limit, a faster agent, or raise the cap with NEMUS_JUDGE_TIMEOUT_MS.`,
63
+ );
64
+ }
65
+ const detail = (err?.stderr || err?.stdout || err?.message || 'unknown error').toString().trim().slice(0, 500);
66
+ return new Error(`agent judge failed (${agentType}): ${detail}`);
67
+ }
68
+
69
+ const DEFAULT_TIMEOUT_MS = 300_000; // judging N transcripts is heavier than extraction; a big prompt + slow model can run minutes
70
+ const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
71
+
72
+ /**
73
+ * Invoke the agent with `prompt` and return the raw stdout. Throws a clear error
74
+ * on timeout / non-zero exit. Kept separate from parsing so callers can inspect
75
+ * raw output (e.g. `--dry-run`, debugging).
76
+ */
77
+ export function runAgentRaw(prompt: string, opts: JudgeOptions = {}): string {
78
+ const agentType = opts.agentType ?? getPrimaryAgent().type;
79
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
80
+ const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
81
+ const exec =
82
+ opts.exec ??
83
+ ((cmd, args, o) => execFileSync(cmd, args, { encoding: 'utf-8', timeout: o.timeout, maxBuffer: o.maxBuffer }));
84
+
85
+ const attempts = agentAttempts(agentType, prompt, opts.schema);
86
+ let lastErr: any;
87
+ for (const a of attempts) {
88
+ try {
89
+ return exec(a.cmd, a.args, { timeout, maxBuffer });
90
+ } catch (err) {
91
+ lastErr = err; // try the next (fallback) form
92
+ }
93
+ }
94
+ throw wrapJudgeError(lastErr, agentType);
95
+ }
96
+
97
+ /**
98
+ * Non-blocking twin of {@link runAgentRaw}. Uses `execFile` (async) so the
99
+ * caller's event loop stays free — letting a spinner/progress UI animate while
100
+ * the judge (which can take minutes) runs. Prefer this in interactive commands.
101
+ */
102
+ export async function runAgentRawAsync(prompt: string, opts: JudgeOptions = {}): Promise<string> {
103
+ const agentType = opts.agentType ?? getPrimaryAgent().type;
104
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
105
+ 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());
110
+
111
+ const attempts = agentAttempts(agentType, prompt, opts.schema);
112
+ let lastErr: any;
113
+ for (const a of attempts) {
114
+ try {
115
+ return await exec(a.cmd, a.args, { timeout, maxBuffer });
116
+ } catch (err) {
117
+ lastErr = err; // try the next (fallback) form
118
+ }
119
+ }
120
+ throw wrapJudgeError(lastErr, agentType);
121
+ }
122
+
123
+ /**
124
+ * Run the agent and parse its reply as JSON, tolerating the shapes different
125
+ * agents emit: `{ structured_output }`, `{ result: "<json>" }`, a ```json fence,
126
+ * or a bare object. Returns `unknown`; callers validate/normalize their shape.
127
+ */
128
+ export function runAgentJson(prompt: string, opts: JudgeOptions = {}): unknown {
129
+ const raw = runAgentRaw(prompt, opts);
130
+ return parseAgentJson(raw);
131
+ }
132
+
133
+ /** Non-blocking twin of {@link runAgentJson}. */
134
+ export async function runAgentJsonAsync(prompt: string, opts: JudgeOptions = {}): Promise<unknown> {
135
+ const raw = await runAgentRawAsync(prompt, opts);
136
+ return parseAgentJson(raw);
137
+ }
138
+
139
+ /** Extract a JSON object from an agent's raw stdout. Exported for tests. */
140
+ export function parseAgentJson(raw: string): unknown {
141
+ let text = raw.trim();
142
+ const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
143
+ if (fence) text = fence[1].trim();
144
+
145
+ let parsed: any;
146
+ try {
147
+ parsed = JSON.parse(text);
148
+ } catch {
149
+ // Last resort: grab the outermost {...} span.
150
+ const span = text.match(/\{[\s\S]*\}/);
151
+ if (!span) throw new Error('agent did not return JSON');
152
+ parsed = JSON.parse(span[0]);
153
+ }
154
+
155
+ // Unwrap the common agent envelopes.
156
+ if (parsed && typeof parsed === 'object') {
157
+ if (parsed.structured_output && typeof parsed.structured_output === 'object') return parsed.structured_output;
158
+ if (typeof parsed.result === 'string') {
159
+ try {
160
+ return JSON.parse(parsed.result);
161
+ } catch {
162
+ /* fall through — result was plain text, return the envelope */
163
+ }
164
+ }
165
+ if (parsed.result && typeof parsed.result === 'object') return parsed.result;
166
+ }
167
+ return parsed;
168
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { distillTranscript, buildJudgePrompt, parseReflectionReport, ReflectionCorpus } from './reflect';
3
+
4
+ const J = (o: unknown) => JSON.stringify(o);
5
+
6
+ describe('distillTranscript', () => {
7
+ it('extracts prompts, tools, errors, and turns across pi + claude shapes', () => {
8
+ const lines = [
9
+ 'not json — skipped',
10
+ // pi: user prompt
11
+ J({ type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'do the thing' }] } }),
12
+ // pi: assistant with a toolCall
13
+ J({ type: 'message', message: { role: 'assistant', content: [{ type: 'toolCall', name: 'bash' }] } }),
14
+ // pi: tool result flagged as error
15
+ J({ type: 'message', message: { role: 'toolResult', toolName: 'bash', isError: true, content: [{ type: 'text', text: 'command failed: boom' }] } }),
16
+ // claude: user prompt as plain string
17
+ J({ type: 'user', message: { role: 'user', content: 'claude style prompt' } }),
18
+ // claude: assistant tool_use
19
+ J({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Edit' }] } }),
20
+ // claude: tool_result-only user message → an error, NOT a prompt
21
+ J({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', is_error: true, content: 'file not found' }] } }),
22
+ // a system-reminder-style user text → ignored as a prompt
23
+ J({ type: 'message', message: { role: 'user', content: '<system-reminder>ignore me</system-reminder>' } }),
24
+ // UNFLAGGED benign result whose text merely mentions "error" → NOT a failure
25
+ J({ type: 'message', message: { role: 'toolResult', toolName: 'grep', content: [{ type: 'text', text: '0 results for error' }] } }),
26
+ // UNFLAGGED result with a strong failure signal → a failure (regex fallback)
27
+ J({ type: 'message', message: { role: 'toolResult', toolName: 'git', content: [{ type: 'text', text: 'fatal: not a git repository' }] } }),
28
+ // EXPLICITLY not-an-error, despite failing-looking text → trust the flag
29
+ J({ type: 'message', message: { role: 'toolResult', isError: false, content: [{ type: 'text', text: 'fatal: boom (but flagged success)' }] } }),
30
+ ].join('\n');
31
+
32
+ const d = distillTranscript(lines, { sessionId: 's1', agentType: 'pi' });
33
+ expect(d.turns).toBe(2);
34
+ expect(d.userPrompts).toEqual(['do the thing', 'claude style prompt']);
35
+ expect(d.tools.sort()).toEqual(['Edit', 'bash', 'git', 'grep']);
36
+ expect(d.errors).toContain('command failed: boom'); // explicit isError:true
37
+ expect(d.errors).toContain('file not found'); // explicit is_error:true
38
+ expect(d.errors).toContain('fatal: not a git repository'); // unflagged + strong signal
39
+ expect(d.errors).not.toContain('0 results for error'); // unflagged benign 'error' mention
40
+ expect(d.errors.some((e) => e.includes('flagged success'))).toBe(false); // isError:false trusted
41
+ });
42
+
43
+ it('is bounded and tolerant of empty input', () => {
44
+ expect(distillTranscript('', { sessionId: 's', agentType: 'pi' })).toMatchObject({
45
+ turns: 0,
46
+ userPrompts: [],
47
+ errors: [],
48
+ tools: [],
49
+ });
50
+ });
51
+ });
52
+
53
+ const corpus: ReflectionCorpus = {
54
+ generatedAt: '2026-01-01T00:00:00Z',
55
+ availableSkills: ['redash', 'datadog'],
56
+ workspaces: [
57
+ {
58
+ name: 'pay-app',
59
+ repoCount: 1,
60
+ repos: ['api'],
61
+ contextFiles: ['AGENTS.md'],
62
+ session: { sessionId: 's', agentType: 'pi', turns: 12, userPrompts: ['fix the sync bug'], errors: ['gh_pr_create: not a git repository'], tools: ['bash', 'edit'] },
63
+ },
64
+ { name: 'empty-ws', repoCount: 0, repos: [], contextFiles: [], session: null },
65
+ ],
66
+ };
67
+
68
+ describe('buildJudgePrompt', () => {
69
+ const p = buildJudgePrompt(corpus);
70
+ it('frames the judge task and includes the evidence + guardrails', () => {
71
+ expect(p).toContain('LLM as a judge');
72
+ expect(p).toContain('redash, datadog'); // available skills (do not re-suggest)
73
+ expect(p).toContain('## pay-app');
74
+ expect(p).toContain('fix the sync bug');
75
+ expect(p).toContain('gh_pr_create: not a git repository');
76
+ expect(p).toContain('no recent agent session found'); // empty-ws
77
+ expect(p).toContain('context files: NONE'); // empty-ws has none
78
+ expect(p).toMatch(/ONLY a JSON object/);
79
+ });
80
+ });
81
+
82
+ describe('parseReflectionReport', () => {
83
+ it('normalizes, clamps enums, and drops empty recs', () => {
84
+ const report = parseReflectionReport({
85
+ summary: 'ok',
86
+ recommendations: [
87
+ { kind: 'skill', title: 'Add X', detail: 'because Y', priority: 'high', target: ' api ', example: 'stub' },
88
+ { kind: 'bogus', title: 'clamp me', detail: 'd', priority: 'urgent' }, // kind→other, priority→medium
89
+ { title: '', detail: '' }, // dropped
90
+ 'garbage', // dropped
91
+ ],
92
+ });
93
+ expect(report.summary).toBe('ok');
94
+ expect(report.recommendations).toHaveLength(2);
95
+ expect(report.recommendations[0]).toEqual({ kind: 'skill', title: 'Add X', detail: 'because Y', priority: 'high', target: 'api', example: 'stub' });
96
+ expect(report.recommendations[1]).toMatchObject({ kind: 'other', priority: 'medium', title: 'clamp me' });
97
+ });
98
+
99
+ it('tolerates a malformed top-level object', () => {
100
+ expect(parseReflectionReport(null)).toEqual({ summary: '', recommendations: [] });
101
+ expect(parseReflectionReport({ recommendations: 'nope' })).toEqual({ summary: '', recommendations: [] });
102
+ });
103
+ });