@nemus-cli/nemus 0.2.13 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.2.13",
3
+ "version": "0.3.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -1,4 +1,5 @@
1
1
  import { spawn, execFile, execFileSync } from 'child_process';
2
+ import { parseAgentJson } from '../utils/agent-judge';
2
3
  import { promisify } from 'util';
3
4
  import * as fs from 'fs';
4
5
  import * as path from 'path';
@@ -259,32 +260,21 @@ export async function extractIntent(prompt: string): Promise<ExtractedIntent> {
259
260
  result = runExtraction('pi', [...piLean, ...piCore], piCore);
260
261
  }
261
262
 
262
- // Strip markdown code fences if present (Pi may wrap JSON in ```json...```)
263
- let jsonStr = result.trim();
264
- const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
265
- if (fenceMatch) {
266
- jsonStr = fenceMatch[1].trim();
267
- }
268
-
269
- const parsed = JSON.parse(jsonStr);
270
-
271
- // Handle different output formats:
272
- // - Claude: { structured_output: {...} } or { result: "..." }
273
- // - Pi: may return the object directly or wrap it
263
+ // Unwrap the agent's reply (code fences + the structured_output / result-string
264
+ // / result-object / bare-object envelopes) with the shared parser, so this and
265
+ // the reflect judge can't drift when a new agent shape is learned. The
266
+ // extraction-specific INVOCATION (lean flags + tailored timeout/auth errors)
267
+ // deliberately stays here — those messages are part of the `nemus --` UX.
274
268
  let intent: ExtractedIntent | undefined;
275
- if (parsed.structured_output) {
276
- intent = parsed.structured_output;
277
- } else if (typeof parsed.result === 'string' && parsed.result) {
278
- try { intent = JSON.parse(parsed.result); } catch { /* ignore */ }
279
- } else if (typeof parsed.result === 'object' && parsed.result !== null) {
280
- intent = parsed.result;
281
- } else if (parsed.workspaceName || parsed.repos || parsed.remainingIntent !== undefined) {
282
- // Pi may return the extracted object directly
283
- intent = parsed;
269
+ try {
270
+ const parsed = parseAgentJson(result) as any;
271
+ if (parsed && typeof parsed === 'object') intent = parsed;
272
+ } catch {
273
+ throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
284
274
  }
285
275
 
286
276
  if (!intent) {
287
- throw new Error(`Could not extract intent from agent response. Parsed: ${JSON.stringify(parsed).slice(0, 200)}`);
277
+ throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
288
278
  }
289
279
 
290
280
  // Coerce/validate field types so malformed model output can't crash the
@@ -0,0 +1,120 @@
1
+ import { Command } from 'commander';
2
+ import { logError, logInfo, logStep } from '../utils/logger';
3
+ import { outputJson, outputJsonError } from '../utils/output';
4
+ import { colorize } from '../utils/colors';
5
+ import {
6
+ gatherReflectionCorpus,
7
+ buildJudgePrompt,
8
+ parseReflectionReport,
9
+ REFLECT_SCHEMA,
10
+ ReflectionReport,
11
+ Recommendation,
12
+ } from '../utils/reflect';
13
+ import { runAgentJson } from '../utils/agent-judge';
14
+
15
+ export function registerReflectCommand(parent: Command) {
16
+ parent
17
+ .command('reflect')
18
+ .alias('retro')
19
+ .description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
20
+ .option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
21
+ .option('--json', 'Output the report as JSON')
22
+ .option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
23
+ .action(async (opts) => {
24
+ await handleReflect(opts);
25
+ });
26
+ }
27
+
28
+ async function handleReflect(opts: { limit?: string; json?: boolean; dryRun?: boolean }) {
29
+ const limit = Math.max(1, Number.parseInt(opts.limit ?? '10', 10) || 10);
30
+ try {
31
+ if (!opts.json && !opts.dryRun) {
32
+ logStep(`Analyzing your ${colorize(String(limit), 'cyan')} most recent workspaces…`);
33
+ logInfo('Reading sessions and distilling prompts + failures…');
34
+ }
35
+
36
+ const corpus = await gatherReflectionCorpus(limit);
37
+ const withSessions = corpus.workspaces.filter((w) => w.session).length;
38
+ const prompt = buildJudgePrompt(corpus);
39
+
40
+ if (opts.dryRun) {
41
+ // No LLM call — surface exactly what the judge would see.
42
+ if (opts.json) outputJson({ corpus, prompt });
43
+ else {
44
+ process.stdout.write(prompt + '\n');
45
+ }
46
+ return;
47
+ }
48
+
49
+ if (withSessions === 0) {
50
+ const msg = 'No recent agent sessions found to analyze (need Claude/pi session transcripts).';
51
+ if (opts.json) outputJsonError(msg);
52
+ else logError(msg);
53
+ process.exit(1);
54
+ }
55
+
56
+ if (!opts.json) logInfo(`Judging ${withSessions} session(s) with your configured agent…`);
57
+ const parsed = runAgentJson(prompt, { schema: REFLECT_SCHEMA });
58
+ const report = parseReflectionReport(parsed);
59
+
60
+ if (opts.json) {
61
+ outputJson({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report });
62
+ return;
63
+ }
64
+ printReport(report, corpus.workspaces.length, withSessions);
65
+ } catch (error) {
66
+ const msg = error instanceof Error ? error.message : 'reflect failed';
67
+ if (opts.json) outputJsonError(msg);
68
+ else {
69
+ logError('Failed to analyze sessions');
70
+ logError(msg);
71
+ }
72
+ process.exit(1);
73
+ }
74
+ }
75
+
76
+ const KIND_LABEL: Record<Recommendation['kind'], string> = {
77
+ skill: 'Skill',
78
+ context: 'Context/AGENTS.md',
79
+ test: 'Test',
80
+ prompt: 'Prompt',
81
+ connectivity: 'Connectivity',
82
+ workflow: 'Workflow',
83
+ other: 'Other',
84
+ };
85
+
86
+ function priorityBadge(p: Recommendation['priority']): string {
87
+ if (p === 'high') return colorize('● high', 'red');
88
+ if (p === 'medium') return colorize('● med', 'yellow');
89
+ return colorize('● low', 'gray');
90
+ }
91
+
92
+ function printReport(report: ReflectionReport, workspaces: number, analyzed: number) {
93
+ console.log('');
94
+ console.log(colorize(' Reflection', 'bright') + colorize(` (${analyzed} sessions across ${workspaces} workspaces)`, 'dim'));
95
+ console.log(colorize(' ' + '─'.repeat(56), 'dim'));
96
+ if (report.summary) {
97
+ console.log('\n ' + report.summary.replace(/\n/g, '\n '));
98
+ }
99
+
100
+ if (report.recommendations.length === 0) {
101
+ console.log('\n ' + colorize('No specific recommendations — looks solid.', 'green') + '\n');
102
+ return;
103
+ }
104
+
105
+ // High priority first.
106
+ const order = { high: 0, medium: 1, low: 2 };
107
+ const recs = [...report.recommendations].sort((a, b) => order[a.priority] - order[b.priority]);
108
+
109
+ console.log('');
110
+ for (const r of recs) {
111
+ const target = r.target ? colorize(` [${r.target}]`, 'cyan') : '';
112
+ console.log(` ${priorityBadge(r.priority)} ${colorize(KIND_LABEL[r.kind], 'bright')} ${r.title}${target}`);
113
+ if (r.detail) console.log(` ${r.detail.replace(/\n/g, '\n ')}`);
114
+ if (r.example) {
115
+ console.log(colorize(' example:', 'dim'));
116
+ console.log(colorize(r.example.replace(/^/gm, ' '), 'dim'));
117
+ }
118
+ console.log('');
119
+ }
120
+ }
package/src/program.ts CHANGED
@@ -59,6 +59,7 @@ import { registerSaveContextCommand } from './commands/save-context';
59
59
  import { registerMigrateCommand } from './commands/migrate';
60
60
  import { registerReportBugCommand } from './commands/report-bug';
61
61
  import { registerCompletionCommand } from './commands/completion';
62
+ import { registerReflectCommand } from './commands/reflect';
62
63
 
63
64
  registerCreateCommand(program);
64
65
  registerListCommand(program);
@@ -84,6 +85,7 @@ registerSaveContextCommand(program);
84
85
  registerMigrateCommand(program);
85
86
  registerReportBugCommand(program);
86
87
  registerCompletionCommand(program);
88
+ registerReflectCommand(program);
87
89
 
88
90
  // Register TUI (delegates to existing Ink/React implementation)
89
91
  program
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseAgentJson, runAgentRaw } 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('runAgentRaw', () => {
20
+ it('claude: passes the schema + lean flags, falls back on a rejected flag', () => {
21
+ const calls: string[][] = [];
22
+ const exec = (cmd: string, args: string[]) => {
23
+ calls.push([cmd, ...args]);
24
+ if (calls.length === 1) throw new Error('unknown flag --json-schema'); // old claude
25
+ return '{"ok":true}';
26
+ };
27
+ const out = runAgentRaw('PROMPT', { agentType: 'claude', schema: '{"type":"object"}', exec });
28
+ expect(out).toBe('{"ok":true}');
29
+ // first (preferred) attempt carries the schema + structured flags…
30
+ expect(calls[0]).toEqual(expect.arrayContaining(['claude', '-p', 'PROMPT', '--output-format', 'json', '--json-schema', '{"type":"object"}']));
31
+ // …the fallback is the plainest form
32
+ expect(calls[1]).toEqual(['claude', '-p', 'PROMPT']);
33
+ });
34
+
35
+ it('pi: runs with lean flags', () => {
36
+ let seen: string[] = [];
37
+ const exec = (cmd: string, args: string[]) => {
38
+ seen = [cmd, ...args];
39
+ return 'ok';
40
+ };
41
+ runAgentRaw('P', { agentType: 'pi', exec });
42
+ expect(seen).toEqual(expect.arrayContaining(['pi', '--no-extensions', '--no-skills', '--no-tools', '-p', 'P']));
43
+ });
44
+
45
+ it('wraps a hard failure in a clear error', () => {
46
+ const exec = () => { throw Object.assign(new Error('boom'), { stderr: 'agent exploded' }); };
47
+ // pi retries once (lean → plain), then throws
48
+ expect(() => runAgentRaw('P', { agentType: 'pi', exec })).toThrow(/agent judge failed \(pi\): agent exploded/);
49
+ });
50
+ });
51
+
52
+ function J(o: unknown) {
53
+ return JSON.stringify(o);
54
+ }
@@ -0,0 +1,109 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { getPrimaryAgent } from './agent-config';
3
+
4
+ /**
5
+ * Run the user's configured coding agent headlessly as an "LLM-as-a-judge":
6
+ * feed it a prompt, get back a parsed JSON object. This reuses whatever agent
7
+ * the user already has authenticated (claude / pi / opencode) — no API keys of
8
+ * our own. It mirrors the lean, structured invocation used for intent
9
+ * extraction, but generalized (any prompt + optional JSON schema).
10
+ *
11
+ * The call is bounded by a timeout and a large maxBuffer; a transcript-analysis
12
+ * judge returns more than intent extraction, so the buffer is generous.
13
+ */
14
+ export interface JudgeOptions {
15
+ /** JSON schema string passed to `claude --json-schema` (ignored by others). */
16
+ schema?: string;
17
+ timeoutMs?: number;
18
+ maxBuffer?: number;
19
+ /** Injected for tests. Defaults to the real child_process runner. */
20
+ exec?: (cmd: string, args: string[], opts: { timeout: number; maxBuffer: number }) => string;
21
+ /** Injected for tests. Defaults to the configured primary agent. */
22
+ agentType?: 'claude' | 'pi' | 'opencode' | 'codex' | 'gemini';
23
+ }
24
+
25
+ const DEFAULT_TIMEOUT_MS = 180_000; // judging N transcripts is heavier than extraction
26
+ const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
27
+
28
+ /**
29
+ * Invoke the agent with `prompt` and return the raw stdout. Throws a clear error
30
+ * on timeout / non-zero exit. Kept separate from parsing so callers can inspect
31
+ * raw output (e.g. `--dry-run`, debugging).
32
+ */
33
+ export function runAgentRaw(prompt: string, opts: JudgeOptions = {}): string {
34
+ const agentType = opts.agentType ?? getPrimaryAgent().type;
35
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
36
+ const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
37
+ const exec =
38
+ opts.exec ??
39
+ ((cmd, args, o) => execFileSync(cmd, args, { encoding: 'utf-8', timeout: o.timeout, maxBuffer: o.maxBuffer }));
40
+
41
+ const attempt = (cmd: string, args: string[]) => exec(cmd, args, { timeout, maxBuffer });
42
+
43
+ try {
44
+ if (agentType === 'claude') {
45
+ const preferred = ['-p', prompt, '--output-format', 'json', '--bare', '--strict-mcp-config', '--disable-slash-commands'];
46
+ if (opts.schema) preferred.push('--json-schema', opts.schema);
47
+ try {
48
+ return attempt('claude', preferred);
49
+ } catch {
50
+ // Older claude may reject the newer flags — fall back to the plainest form.
51
+ return attempt('claude', ['-p', prompt]);
52
+ }
53
+ }
54
+ if (agentType === 'opencode') {
55
+ return attempt('opencode', ['run', prompt]);
56
+ }
57
+ // pi (and any other): run as lean as possible so a bloated env can't hang it.
58
+ const piLean = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files', '--no-tools', '--no-session'];
59
+ try {
60
+ return attempt('pi', [...piLean, '-p', prompt]);
61
+ } catch {
62
+ return attempt('pi', ['-p', prompt]);
63
+ }
64
+ } catch (err: any) {
65
+ const detail = (err?.stderr || err?.stdout || err?.message || 'unknown error').toString().trim().slice(0, 500);
66
+ throw new Error(`agent judge failed (${agentType}): ${detail}`);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Run the agent and parse its reply as JSON, tolerating the shapes different
72
+ * agents emit: `{ structured_output }`, `{ result: "<json>" }`, a ```json fence,
73
+ * or a bare object. Returns `unknown`; callers validate/normalize their shape.
74
+ */
75
+ export function runAgentJson(prompt: string, opts: JudgeOptions = {}): unknown {
76
+ const raw = runAgentRaw(prompt, opts);
77
+ return parseAgentJson(raw);
78
+ }
79
+
80
+ /** Extract a JSON object from an agent's raw stdout. Exported for tests. */
81
+ export function parseAgentJson(raw: string): unknown {
82
+ let text = raw.trim();
83
+ const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
84
+ if (fence) text = fence[1].trim();
85
+
86
+ let parsed: any;
87
+ try {
88
+ parsed = JSON.parse(text);
89
+ } catch {
90
+ // Last resort: grab the outermost {...} span.
91
+ const span = text.match(/\{[\s\S]*\}/);
92
+ if (!span) throw new Error('agent did not return JSON');
93
+ parsed = JSON.parse(span[0]);
94
+ }
95
+
96
+ // Unwrap the common agent envelopes.
97
+ if (parsed && typeof parsed === 'object') {
98
+ if (parsed.structured_output && typeof parsed.structured_output === 'object') return parsed.structured_output;
99
+ if (typeof parsed.result === 'string') {
100
+ try {
101
+ return JSON.parse(parsed.result);
102
+ } catch {
103
+ /* fall through — result was plain text, return the envelope */
104
+ }
105
+ }
106
+ if (parsed.result && typeof parsed.result === 'object') return parsed.result;
107
+ }
108
+ return parsed;
109
+ }
@@ -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
+ });