@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+
12
+ - **`reflect` now shows live progress and no longer looks hung.** The judge call
13
+ ran synchronously (`execFileSync`), which blocked the event loop, and a
14
+ 10-workspace run could hit the 180s cap and die with a raw
15
+ `spawnSync pi ETIMEDOUT`. Now: (1) the gather phase prints a **per-workspace
16
+ line** as each session is read (`✓ 1/3 my-workspace 635 turns · 12 prompts ·
17
+ 13 failures`); (2) the judge runs **async** behind a live spinner with elapsed
18
+ seconds; (3) the timeout is raised to 300s, overridable via
19
+ `NEMUS_JUDGE_TIMEOUT_MS`, and a timeout now yields an **actionable** message
20
+ ("try a smaller --limit, a faster agent, or raise the cap"); (4) the judge
21
+ prompt is leaner (fewer prompts/errors per session) so a local model actually
22
+ finishes.
23
+
24
+ ## [0.3.0] - 2026-08-27
25
+
26
+ ### Added
27
+
28
+ - **`nemus reflect` (alias `retro`) — LLM-as-a-judge retrospective.** Analyzes
29
+ your most recent workspaces (default 10) by reading their agent session
30
+ transcripts, distilling the human prompts + tool failures + tools used, and
31
+ asking your own configured agent (claude/pi/opencode — no API key of ours) to
32
+ recommend concrete setup improvements: which **skills** to add and where,
33
+ missing **AGENTS.md/context** rules, missing **connectivity/smoke tests**, and
34
+ **prompt/workflow** habits to change — each with a priority and a concrete
35
+ example snippet. Reads both Claude and pi transcript formats. Flags:
36
+ `--limit <n>`, `--json` (structured report, same `{ok:false,error}` failure
37
+ contract as the other JSON commands), and `--dry-run` (print the assembled
38
+ corpus + judge prompt without calling the agent). Idea courtesy of
39
+ **@lightpriest** — thank you for the great suggestion!
40
+
10
41
  ## [0.2.13] - 2026-08-27
11
42
 
12
43
  ### Added
package/README.md CHANGED
@@ -280,6 +280,23 @@ nemus snapshot save ws # (ss) capture exact branches/commits/dirty state
280
280
  nemus snapshot restore <id> # (sr)
281
281
  ```
282
282
 
283
+ ### Reflect — improve your setup over time
284
+
285
+ ```bash
286
+ nemus reflect # (retro) analyze your last 10 workspaces' sessions
287
+ nemus reflect --limit 5 # narrow the window
288
+ nemus reflect --json # structured report for tooling
289
+ nemus reflect --dry-run # show what the judge sees, without calling the agent
290
+ ```
291
+
292
+ `reflect` reads your recent agent **session transcripts** (Claude + pi), distills
293
+ the prompts you sent, the failures the agent hit, and the tools it used, then asks
294
+ **your own configured agent** (LLM-as-a-judge — no extra API key) to recommend
295
+ concrete improvements: which **skills** to add and where, missing
296
+ **AGENTS.md/context** rules, missing **connectivity/smoke tests**, and
297
+ **prompt/workflow** habits — each with a priority and an example snippet. It's a
298
+ fast retrospective on *how you drive the agent*, so next time works better.
299
+
283
300
  ### AI assistant
284
301
 
285
302
  ```bash
@@ -41,6 +41,7 @@ exports.extractIntent = extractIntent;
41
41
  exports.run = run;
42
42
  exports.main = main;
43
43
  const child_process_1 = require("child_process");
44
+ const agent_judge_1 = require("../utils/agent-judge");
44
45
  const util_1 = require("util");
45
46
  const fs = __importStar(require("fs"));
46
47
  const path = __importStar(require("path"));
@@ -267,35 +268,22 @@ async function extractIntent(prompt) {
267
268
  ];
268
269
  result = runExtraction('pi', [...piLean, ...piCore], piCore);
269
270
  }
270
- // Strip markdown code fences if present (Pi may wrap JSON in ```json...```)
271
- let jsonStr = result.trim();
272
- const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
273
- if (fenceMatch) {
274
- jsonStr = fenceMatch[1].trim();
275
- }
276
- const parsed = JSON.parse(jsonStr);
277
- // Handle different output formats:
278
- // - Claude: { structured_output: {...} } or { result: "..." }
279
- // - Pi: may return the object directly or wrap it
271
+ // Unwrap the agent's reply (code fences + the structured_output / result-string
272
+ // / result-object / bare-object envelopes) with the shared parser, so this and
273
+ // the reflect judge can't drift when a new agent shape is learned. The
274
+ // extraction-specific INVOCATION (lean flags + tailored timeout/auth errors)
275
+ // deliberately stays here — those messages are part of the `nemus --` UX.
280
276
  let intent;
281
- if (parsed.structured_output) {
282
- intent = parsed.structured_output;
283
- }
284
- else if (typeof parsed.result === 'string' && parsed.result) {
285
- try {
286
- intent = JSON.parse(parsed.result);
287
- }
288
- catch { /* ignore */ }
289
- }
290
- else if (typeof parsed.result === 'object' && parsed.result !== null) {
291
- intent = parsed.result;
277
+ try {
278
+ const parsed = (0, agent_judge_1.parseAgentJson)(result);
279
+ if (parsed && typeof parsed === 'object')
280
+ intent = parsed;
292
281
  }
293
- else if (parsed.workspaceName || parsed.repos || parsed.remainingIntent !== undefined) {
294
- // Pi may return the extracted object directly
295
- intent = parsed;
282
+ catch {
283
+ throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
296
284
  }
297
285
  if (!intent) {
298
- throw new Error(`Could not extract intent from agent response. Parsed: ${JSON.stringify(parsed).slice(0, 200)}`);
286
+ throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
299
287
  }
300
288
  // Coerce/validate field types so malformed model output can't crash the
301
289
  // downstream .trim()/sanitize path (a stringly-typed workspaceName or a
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerReflectCommand = registerReflectCommand;
4
+ const logger_1 = require("../utils/logger");
5
+ const output_1 = require("../utils/output");
6
+ const colors_1 = require("../utils/colors");
7
+ const reflect_1 = require("../utils/reflect");
8
+ const agent_judge_1 = require("../utils/agent-judge");
9
+ function registerReflectCommand(parent) {
10
+ parent
11
+ .command('reflect')
12
+ .alias('retro')
13
+ .description('Analyze your recent workspace sessions and suggest skill/prompt/context improvements (LLM-as-a-judge)')
14
+ .option('-n, --limit <n>', 'How many recent workspaces to analyze', '10')
15
+ .option('--json', 'Output the report as JSON')
16
+ .option('--dry-run', 'Print the assembled corpus + judge prompt without calling the agent')
17
+ .action(async (opts) => {
18
+ await handleReflect(opts);
19
+ });
20
+ }
21
+ async function handleReflect(opts) {
22
+ const limit = Math.max(1, Number.parseInt(opts.limit ?? '10', 10) || 10);
23
+ try {
24
+ const showProgress = !opts.json && !opts.dryRun;
25
+ if (showProgress) {
26
+ (0, logger_1.logStep)(`Analyzing your ${(0, colors_1.colorize)(String(limit), 'cyan')} most recent workspaces…`);
27
+ }
28
+ const corpus = await (0, reflect_1.gatherReflectionCorpus)(limit, showProgress ? printProgress : undefined);
29
+ const withSessions = corpus.workspaces.filter((w) => w.session).length;
30
+ const prompt = (0, reflect_1.buildJudgePrompt)(corpus);
31
+ if (opts.dryRun) {
32
+ // No LLM call — surface exactly what the judge would see.
33
+ if (opts.json)
34
+ (0, output_1.outputJson)({ corpus, prompt });
35
+ else {
36
+ process.stdout.write(prompt + '\n');
37
+ }
38
+ return;
39
+ }
40
+ if (withSessions === 0) {
41
+ const msg = 'No recent agent sessions found to analyze (need Claude/pi session transcripts).';
42
+ if (opts.json)
43
+ (0, output_1.outputJsonError)(msg);
44
+ else
45
+ (0, logger_1.logError)(msg);
46
+ process.exit(1);
47
+ }
48
+ // The judge shells the user's own agent and can take minutes; run it async
49
+ // (non-blocking) so a live spinner shows it's alive, not hung. Timeout is
50
+ // overridable for slow local models.
51
+ const timeoutMs = Number.parseInt(process.env.NEMUS_JUDGE_TIMEOUT_MS ?? '', 10) || undefined;
52
+ const stopSpinner = opts.json
53
+ ? () => { }
54
+ : startSpinner(`Judging ${withSessions} session(s) with your configured agent (this can take a minute)…`);
55
+ let parsed;
56
+ try {
57
+ parsed = await (0, agent_judge_1.runAgentJsonAsync)(prompt, { schema: reflect_1.REFLECT_SCHEMA, timeoutMs });
58
+ }
59
+ finally {
60
+ stopSpinner();
61
+ }
62
+ const report = (0, reflect_1.parseReflectionReport)(parsed);
63
+ if (opts.json) {
64
+ (0, output_1.outputJson)({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report });
65
+ return;
66
+ }
67
+ printReport(report, corpus.workspaces.length, withSessions);
68
+ }
69
+ catch (error) {
70
+ const msg = error instanceof Error ? error.message : 'reflect failed';
71
+ if (opts.json)
72
+ (0, output_1.outputJsonError)(msg);
73
+ else {
74
+ (0, logger_1.logError)('Failed to analyze sessions');
75
+ (0, logger_1.logError)(msg);
76
+ }
77
+ process.exit(1);
78
+ }
79
+ }
80
+ const KIND_LABEL = {
81
+ skill: 'Skill',
82
+ context: 'Context/AGENTS.md',
83
+ test: 'Test',
84
+ prompt: 'Prompt',
85
+ connectivity: 'Connectivity',
86
+ workflow: 'Workflow',
87
+ other: 'Other',
88
+ };
89
+ /**
90
+ * A minimal stderr spinner with elapsed seconds. Returns a stop() that clears
91
+ * the line. No-op (single log line) when stderr isn't a TTY (piped/CI), so it
92
+ * never pollutes captured output. Kept local + tiny — no new dependency.
93
+ */
94
+ function startSpinner(text) {
95
+ if (!process.stderr.isTTY) {
96
+ (0, logger_1.logInfo)(text);
97
+ return () => { };
98
+ }
99
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
100
+ const start = Date.now();
101
+ let i = 0;
102
+ const render = () => {
103
+ const secs = Math.floor((Date.now() - start) / 1000);
104
+ process.stderr.write(`\r${(0, colors_1.colorize)(frames[(i = (i + 1) % frames.length)], 'cyan')} ${text} ${(0, colors_1.colorize)(`(${secs}s)`, 'dim')}`);
105
+ };
106
+ render();
107
+ const timer = setInterval(render, 100);
108
+ timer.unref?.(); // never keep the process alive on our account
109
+ return () => {
110
+ clearInterval(timer);
111
+ process.stderr.write('\r' + ' '.repeat(text.length + 24) + '\r');
112
+ };
113
+ }
114
+ /** Live per-workspace line during the gather phase (to stderr — stdout stays
115
+ * reserved for the report / JSON). */
116
+ function printProgress(p) {
117
+ const n = (0, colors_1.colorize)(`${p.index + 1}/${p.total}`, 'dim');
118
+ const d = p.digest.session;
119
+ if (!d) {
120
+ process.stderr.write(` ${(0, colors_1.colorize)('·', 'dim')} ${n} ${p.digest.name} ${(0, colors_1.colorize)('— no session', 'dim')}\n`);
121
+ return;
122
+ }
123
+ const failures = `${d.errors.length} ${d.errors.length === 1 ? 'failure' : 'failures'}`;
124
+ const stats = (0, colors_1.colorize)(`${d.turns} turns · ${d.userPrompts.length} prompts · ${failures}`, 'dim');
125
+ process.stderr.write(` ${(0, colors_1.colorize)('✓', 'green')} ${n} ${p.digest.name} ${stats}\n`);
126
+ }
127
+ function priorityBadge(p) {
128
+ if (p === 'high')
129
+ return (0, colors_1.colorize)('● high', 'red');
130
+ if (p === 'medium')
131
+ return (0, colors_1.colorize)('● med', 'yellow');
132
+ return (0, colors_1.colorize)('● low', 'gray');
133
+ }
134
+ function printReport(report, workspaces, analyzed) {
135
+ console.log('');
136
+ console.log((0, colors_1.colorize)(' Reflection', 'bright') + (0, colors_1.colorize)(` (${analyzed} sessions across ${workspaces} workspaces)`, 'dim'));
137
+ console.log((0, colors_1.colorize)(' ' + '─'.repeat(56), 'dim'));
138
+ if (report.summary) {
139
+ console.log('\n ' + report.summary.replace(/\n/g, '\n '));
140
+ }
141
+ if (report.recommendations.length === 0) {
142
+ console.log('\n ' + (0, colors_1.colorize)('No specific recommendations — looks solid.', 'green') + '\n');
143
+ return;
144
+ }
145
+ // High priority first.
146
+ const order = { high: 0, medium: 1, low: 2 };
147
+ const recs = [...report.recommendations].sort((a, b) => order[a.priority] - order[b.priority]);
148
+ console.log('');
149
+ for (const r of recs) {
150
+ const target = r.target ? (0, colors_1.colorize)(` [${r.target}]`, 'cyan') : '';
151
+ console.log(` ${priorityBadge(r.priority)} ${(0, colors_1.colorize)(KIND_LABEL[r.kind], 'bright')} ${r.title}${target}`);
152
+ if (r.detail)
153
+ console.log(` ${r.detail.replace(/\n/g, '\n ')}`);
154
+ if (r.example) {
155
+ console.log((0, colors_1.colorize)(' example:', 'dim'));
156
+ console.log((0, colors_1.colorize)(r.example.replace(/^/gm, ' '), 'dim'));
157
+ }
158
+ console.log('');
159
+ }
160
+ }
package/dist/program.js CHANGED
@@ -89,6 +89,7 @@ const save_context_1 = require("./commands/save-context");
89
89
  const migrate_1 = require("./commands/migrate");
90
90
  const report_bug_1 = require("./commands/report-bug");
91
91
  const completion_1 = require("./commands/completion");
92
+ const reflect_1 = require("./commands/reflect");
92
93
  (0, create_1.registerCreateCommand)(exports.program);
93
94
  (0, list_1.registerListCommand)(exports.program);
94
95
  (0, update_1.registerUpdateCommand)(exports.program);
@@ -113,6 +114,7 @@ const completion_1 = require("./commands/completion");
113
114
  (0, migrate_1.registerMigrateCommand)(exports.program);
114
115
  (0, report_bug_1.registerReportBugCommand)(exports.program);
115
116
  (0, completion_1.registerCompletionCommand)(exports.program);
117
+ (0, reflect_1.registerReflectCommand)(exports.program);
116
118
  // Register TUI (delegates to existing Ink/React implementation)
117
119
  exports.program
118
120
  .command('tui')
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.agentAttempts = agentAttempts;
4
+ exports.runAgentRaw = runAgentRaw;
5
+ exports.runAgentRawAsync = runAgentRawAsync;
6
+ exports.runAgentJson = runAgentJson;
7
+ exports.runAgentJsonAsync = runAgentJsonAsync;
8
+ exports.parseAgentJson = parseAgentJson;
9
+ const child_process_1 = require("child_process");
10
+ const util_1 = require("util");
11
+ const agent_config_1 = require("./agent-config");
12
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
13
+ /**
14
+ * The ordered invocation attempts for an agent (preferred → fallback), as pure
15
+ * data so both the sync and async runners share ONE flag ladder (and it's
16
+ * unit-testable without spawning anything).
17
+ */
18
+ function agentAttempts(agentType, prompt, schema) {
19
+ if (agentType === 'claude') {
20
+ const preferred = ['-p', prompt, '--output-format', 'json', '--bare', '--strict-mcp-config', '--disable-slash-commands'];
21
+ if (schema)
22
+ preferred.push('--json-schema', schema);
23
+ // Older claude may reject the newer flags — fall back to the plainest form.
24
+ return [{ cmd: 'claude', args: preferred }, { cmd: 'claude', args: ['-p', prompt] }];
25
+ }
26
+ if (agentType === 'opencode') {
27
+ return [{ cmd: 'opencode', args: ['run', prompt] }];
28
+ }
29
+ // pi (and any other): run as lean as possible so a bloated env can't hang it.
30
+ const piLean = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files', '--no-tools', '--no-session'];
31
+ return [{ cmd: 'pi', args: [...piLean, '-p', prompt] }, { cmd: 'pi', args: ['-p', prompt] }];
32
+ }
33
+ function wrapJudgeError(err, agentType) {
34
+ // A timeout is the common failure (big prompt + slow local model), so make it
35
+ // actionable instead of surfacing a raw `spawn … ETIMEDOUT`.
36
+ if (err?.killed || err?.code === 'ETIMEDOUT' || err?.signal === 'SIGTERM' || /ETIMEDOUT/.test(String(err?.message ?? ''))) {
37
+ return new Error(`agent judge (${agentType}) timed out. Try a smaller --limit, a faster agent, or raise the cap with NEMUS_JUDGE_TIMEOUT_MS.`);
38
+ }
39
+ const detail = (err?.stderr || err?.stdout || err?.message || 'unknown error').toString().trim().slice(0, 500);
40
+ return new Error(`agent judge failed (${agentType}): ${detail}`);
41
+ }
42
+ const DEFAULT_TIMEOUT_MS = 300000; // judging N transcripts is heavier than extraction; a big prompt + slow model can run minutes
43
+ const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
44
+ /**
45
+ * Invoke the agent with `prompt` and return the raw stdout. Throws a clear error
46
+ * on timeout / non-zero exit. Kept separate from parsing so callers can inspect
47
+ * raw output (e.g. `--dry-run`, debugging).
48
+ */
49
+ function runAgentRaw(prompt, opts = {}) {
50
+ const agentType = opts.agentType ?? (0, agent_config_1.getPrimaryAgent)().type;
51
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
52
+ const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
53
+ const exec = opts.exec ??
54
+ ((cmd, args, o) => (0, child_process_1.execFileSync)(cmd, args, { encoding: 'utf-8', timeout: o.timeout, maxBuffer: o.maxBuffer }));
55
+ const attempts = agentAttempts(agentType, prompt, opts.schema);
56
+ let lastErr;
57
+ for (const a of attempts) {
58
+ try {
59
+ return exec(a.cmd, a.args, { timeout, maxBuffer });
60
+ }
61
+ catch (err) {
62
+ lastErr = err; // try the next (fallback) form
63
+ }
64
+ }
65
+ throw wrapJudgeError(lastErr, agentType);
66
+ }
67
+ /**
68
+ * Non-blocking twin of {@link runAgentRaw}. Uses `execFile` (async) so the
69
+ * caller's event loop stays free — letting a spinner/progress UI animate while
70
+ * the judge (which can take minutes) runs. Prefer this in interactive commands.
71
+ */
72
+ async function runAgentRawAsync(prompt, opts = {}) {
73
+ const agentType = opts.agentType ?? (0, agent_config_1.getPrimaryAgent)().type;
74
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
75
+ const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
76
+ const exec = opts.execAsync ??
77
+ (async (cmd, args, o) => (await execFileAsync(cmd, args, { encoding: 'utf-8', timeout: o.timeout, maxBuffer: o.maxBuffer })).stdout.toString());
78
+ const attempts = agentAttempts(agentType, prompt, opts.schema);
79
+ let lastErr;
80
+ for (const a of attempts) {
81
+ try {
82
+ return await exec(a.cmd, a.args, { timeout, maxBuffer });
83
+ }
84
+ catch (err) {
85
+ lastErr = err; // try the next (fallback) form
86
+ }
87
+ }
88
+ throw wrapJudgeError(lastErr, agentType);
89
+ }
90
+ /**
91
+ * Run the agent and parse its reply as JSON, tolerating the shapes different
92
+ * agents emit: `{ structured_output }`, `{ result: "<json>" }`, a ```json fence,
93
+ * or a bare object. Returns `unknown`; callers validate/normalize their shape.
94
+ */
95
+ function runAgentJson(prompt, opts = {}) {
96
+ const raw = runAgentRaw(prompt, opts);
97
+ return parseAgentJson(raw);
98
+ }
99
+ /** Non-blocking twin of {@link runAgentJson}. */
100
+ async function runAgentJsonAsync(prompt, opts = {}) {
101
+ const raw = await runAgentRawAsync(prompt, opts);
102
+ return parseAgentJson(raw);
103
+ }
104
+ /** Extract a JSON object from an agent's raw stdout. Exported for tests. */
105
+ function parseAgentJson(raw) {
106
+ let text = raw.trim();
107
+ const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
108
+ if (fence)
109
+ text = fence[1].trim();
110
+ let parsed;
111
+ try {
112
+ parsed = JSON.parse(text);
113
+ }
114
+ catch {
115
+ // Last resort: grab the outermost {...} span.
116
+ const span = text.match(/\{[\s\S]*\}/);
117
+ if (!span)
118
+ throw new Error('agent did not return JSON');
119
+ parsed = JSON.parse(span[0]);
120
+ }
121
+ // Unwrap the common agent envelopes.
122
+ if (parsed && typeof parsed === 'object') {
123
+ if (parsed.structured_output && typeof parsed.structured_output === 'object')
124
+ return parsed.structured_output;
125
+ if (typeof parsed.result === 'string') {
126
+ try {
127
+ return JSON.parse(parsed.result);
128
+ }
129
+ catch {
130
+ /* fall through — result was plain text, return the envelope */
131
+ }
132
+ }
133
+ if (parsed.result && typeof parsed.result === 'object')
134
+ return parsed.result;
135
+ }
136
+ return parsed;
137
+ }