@nemus-cli/nemus 0.2.12 → 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/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-08-27
11
+
12
+ ### Added
13
+
14
+ - **`nemus reflect` (alias `retro`) — LLM-as-a-judge retrospective.** Analyzes
15
+ your most recent workspaces (default 10) by reading their agent session
16
+ transcripts, distilling the human prompts + tool failures + tools used, and
17
+ asking your own configured agent (claude/pi/opencode — no API key of ours) to
18
+ recommend concrete setup improvements: which **skills** to add and where,
19
+ missing **AGENTS.md/context** rules, missing **connectivity/smoke tests**, and
20
+ **prompt/workflow** habits to change — each with a priority and a concrete
21
+ example snippet. Reads both Claude and pi transcript formats. Flags:
22
+ `--limit <n>`, `--json` (structured report, same `{ok:false,error}` failure
23
+ contract as the other JSON commands), and `--dry-run` (print the assembled
24
+ corpus + judge prompt without calling the agent). Idea courtesy of
25
+ **@lightpriest** — thank you for the great suggestion!
26
+
27
+ ## [0.2.13] - 2026-08-27
28
+
29
+ ### Added
30
+
31
+ - **Shell completions**: `nemus completion bash|zsh|fish` prints a completion
32
+ script for that shell. Completes subcommands (names + aliases) and, for a
33
+ workspace-scoped command, live **workspace names** — the script calls back
34
+ into `nemus completion --workspaces`, so completions stay fresh without
35
+ regenerating. Registered for both the `nemus` and `nem` binaries. Install e.g.
36
+ `nemus completion zsh > "${fpath[1]}/_nemus"` (see README).
37
+
10
38
  ## [0.2.12] - 2026-08-27
11
39
 
12
40
  ### Added
package/README.md CHANGED
@@ -243,6 +243,24 @@ The workspace-scoped ones (`status`/`doctor`/`analyze-deps`) need an explicit
243
243
  workspace name with `--json` (they never prompt). On failure, `--json` prints a
244
244
  parseable `{ "ok": false, "error": … }` to stdout and exits non-zero.
245
245
 
246
+ ### Shell completions
247
+
248
+ Tab-complete subcommands and workspace names. `nemus completion <shell>` prints
249
+ a script for `bash`, `zsh`, or `fish` (works for both the `nemus` and `nem`
250
+ binaries):
251
+
252
+ ```bash
253
+ # bash
254
+ nemus completion bash > /etc/bash_completion.d/nemus # or >> ~/.bashrc
255
+ # zsh (a directory on your $fpath)
256
+ nemus completion zsh > "${fpath[1]}/_nemus"
257
+ # fish
258
+ nemus completion fish > ~/.config/fish/completions/nemus.fish
259
+ ```
260
+
261
+ Workspace names are resolved live (the script calls back into the CLI), so they
262
+ stay current without regenerating.
263
+
246
264
  ### Suites (reusable repo collections)
247
265
 
248
266
  ```bash
@@ -262,6 +280,23 @@ nemus snapshot save ws # (ss) capture exact branches/commits/dirty state
262
280
  nemus snapshot restore <id> # (sr)
263
281
  ```
264
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
+
265
300
  ### AI assistant
266
301
 
267
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,143 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMPLETION_BINS = void 0;
4
+ exports.generateCompletion = generateCompletion;
5
+ exports.specsFromProgram = specsFromProgram;
6
+ exports.registerCompletionCommand = registerCompletionCommand;
7
+ const workspace_meta_1 = require("../utils/workspace-meta");
8
+ const logger_1 = require("../utils/logger");
9
+ /** Binaries that get completion registered (the CLI's bins). */
10
+ exports.COMPLETION_BINS = ['nemus', 'nem'];
11
+ /** Every token (name + aliases) that should complete as a subcommand. */
12
+ function allTokens(cmds) {
13
+ return cmds.flatMap((c) => [c.name, ...c.aliases]);
14
+ }
15
+ /** Tokens (names + aliases) of the commands that take a workspace argument. */
16
+ function workspaceTokens(cmds) {
17
+ return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
18
+ }
19
+ /** Escape a description for a fish single-quoted string. */
20
+ function fishDesc(s) {
21
+ return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
22
+ }
23
+ /**
24
+ * Generate a shell completion script. Pure (no I/O) so it's unit-tested. The
25
+ * generated script completes subcommands at position 1, and for a subcommand
26
+ * that takes a workspace it completes workspace names by calling back into the
27
+ * CLI: `<bin> completion --workspaces`. Dynamic values stay fresh without
28
+ * regenerating the script.
29
+ */
30
+ function generateCompletion(shell, cmds, bins = exports.COMPLETION_BINS) {
31
+ const commands = allTokens(cmds).join(' ');
32
+ const wsCommands = workspaceTokens(cmds).join(' ');
33
+ if (shell === 'bash') {
34
+ return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
35
+ # (or: nemus completion bash >> ~/.bashrc)
36
+ _nemus_complete() {
37
+ local cur bin sub
38
+ # bash does not clear COMPREPLY between completions; reset so a stale result
39
+ # from a previous TAB can't leak when we return without setting it.
40
+ COMPREPLY=()
41
+ cur="\${COMP_WORDS[COMP_CWORD]}"
42
+ bin="\${COMP_WORDS[0]}"
43
+ local commands="${commands}"
44
+ local ws_commands="${wsCommands}"
45
+ if [ "\$COMP_CWORD" -eq 1 ]; then
46
+ COMPREPLY=( \$(compgen -W "\$commands" -- "\$cur") )
47
+ return 0
48
+ fi
49
+ if [ "\$COMP_CWORD" -eq 2 ]; then
50
+ sub="\${COMP_WORDS[1]}"
51
+ if [[ " \$ws_commands " == *" \$sub "* ]]; then
52
+ local names
53
+ names="\$("\$bin" completion --workspaces 2>/dev/null)"
54
+ COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
55
+ return 0
56
+ fi
57
+ fi
58
+ return 0
59
+ }
60
+ ${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
61
+ `;
62
+ }
63
+ if (shell === 'zsh') {
64
+ // Autoloaded form: save as a file named `_nemus` on your $fpath.
65
+ return `#compdef ${bins.join(' ')}
66
+ # nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
67
+ local -a _nemus_commands
68
+ _nemus_commands=(${allTokens(cmds).map((t) => `'${t}'`).join(' ')})
69
+ local _nemus_ws_commands="${wsCommands}"
70
+ if (( CURRENT == 2 )); then
71
+ compadd -- $_nemus_commands
72
+ return
73
+ fi
74
+ if (( CURRENT == 3 )); then
75
+ local sub=\${words[2]}
76
+ if [[ " $_nemus_ws_commands " == *" $sub "* ]]; then
77
+ local -a _nemus_names
78
+ _nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
79
+ compadd -- $_nemus_names
80
+ fi
81
+ fi
82
+ `;
83
+ }
84
+ // fish
85
+ const lines = ['# nemus fish completion. Install: nemus completion fish > ~/.config/fish/completions/nemus.fish'];
86
+ for (const bin of bins) {
87
+ lines.push(`complete -c ${bin} -f`);
88
+ for (const c of cmds) {
89
+ for (const tok of [c.name, ...c.aliases]) {
90
+ lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
91
+ }
92
+ }
93
+ const wsToks = workspaceTokens(cmds).join(' ');
94
+ if (wsToks) {
95
+ lines.push(`complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`);
96
+ }
97
+ }
98
+ return lines.join('\n') + '\n';
99
+ }
100
+ /** Distill the program's top-level commands into CommandSpecs. */
101
+ function specsFromProgram(program) {
102
+ return program.commands
103
+ .map((c) => {
104
+ const args = c.registeredArguments ?? [];
105
+ const firstArg = args[0]?.name?.();
106
+ return {
107
+ name: c.name(),
108
+ aliases: c.aliases(),
109
+ takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
110
+ description: c.description() ?? '',
111
+ };
112
+ })
113
+ // The completion command itself and any hidden helper needn't clutter, but
114
+ // keeping them is harmless; only drop entries with no name.
115
+ .filter((s) => s.name);
116
+ }
117
+ function registerCompletionCommand(program) {
118
+ program
119
+ .command('completion [shell]')
120
+ .description('Output a shell completion script (bash|zsh|fish)')
121
+ .option('--workspaces', 'Print workspace names (used internally by completion scripts)')
122
+ .action(async (shell, opts) => {
123
+ // Data helper the generated scripts call back into.
124
+ if (opts.workspaces) {
125
+ try {
126
+ const workspaces = await (0, workspace_meta_1.listWorkspaces)(false);
127
+ for (const ws of workspaces)
128
+ process.stdout.write(ws.name + '\n');
129
+ }
130
+ catch {
131
+ // Silent: completion must never error out the user's shell.
132
+ }
133
+ return;
134
+ }
135
+ const shells = ['bash', 'zsh', 'fish'];
136
+ if (!shell || !shells.includes(shell)) {
137
+ (0, logger_1.logError)(`completion: specify a shell — one of ${shells.join(', ')}`);
138
+ (0, logger_1.logError)('e.g. nemus completion bash');
139
+ process.exit(1);
140
+ }
141
+ process.stdout.write(generateCompletion(shell, specsFromProgram(program)));
142
+ });
143
+ }
@@ -0,0 +1,111 @@
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
+ if (!opts.json && !opts.dryRun) {
25
+ (0, logger_1.logStep)(`Analyzing your ${(0, colors_1.colorize)(String(limit), 'cyan')} most recent workspaces…`);
26
+ (0, logger_1.logInfo)('Reading sessions and distilling prompts + failures…');
27
+ }
28
+ const corpus = await (0, reflect_1.gatherReflectionCorpus)(limit);
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
+ if (!opts.json)
49
+ (0, logger_1.logInfo)(`Judging ${withSessions} session(s) with your configured agent…`);
50
+ const parsed = (0, agent_judge_1.runAgentJson)(prompt, { schema: reflect_1.REFLECT_SCHEMA });
51
+ const report = (0, reflect_1.parseReflectionReport)(parsed);
52
+ if (opts.json) {
53
+ (0, output_1.outputJson)({ analyzed: withSessions, workspaces: corpus.workspaces.length, ...report });
54
+ return;
55
+ }
56
+ printReport(report, corpus.workspaces.length, withSessions);
57
+ }
58
+ catch (error) {
59
+ const msg = error instanceof Error ? error.message : 'reflect failed';
60
+ if (opts.json)
61
+ (0, output_1.outputJsonError)(msg);
62
+ else {
63
+ (0, logger_1.logError)('Failed to analyze sessions');
64
+ (0, logger_1.logError)(msg);
65
+ }
66
+ process.exit(1);
67
+ }
68
+ }
69
+ const KIND_LABEL = {
70
+ skill: 'Skill',
71
+ context: 'Context/AGENTS.md',
72
+ test: 'Test',
73
+ prompt: 'Prompt',
74
+ connectivity: 'Connectivity',
75
+ workflow: 'Workflow',
76
+ other: 'Other',
77
+ };
78
+ function priorityBadge(p) {
79
+ if (p === 'high')
80
+ return (0, colors_1.colorize)('● high', 'red');
81
+ if (p === 'medium')
82
+ return (0, colors_1.colorize)('● med', 'yellow');
83
+ return (0, colors_1.colorize)('● low', 'gray');
84
+ }
85
+ function printReport(report, workspaces, analyzed) {
86
+ console.log('');
87
+ console.log((0, colors_1.colorize)(' Reflection', 'bright') + (0, colors_1.colorize)(` (${analyzed} sessions across ${workspaces} workspaces)`, 'dim'));
88
+ console.log((0, colors_1.colorize)(' ' + '─'.repeat(56), 'dim'));
89
+ if (report.summary) {
90
+ console.log('\n ' + report.summary.replace(/\n/g, '\n '));
91
+ }
92
+ if (report.recommendations.length === 0) {
93
+ console.log('\n ' + (0, colors_1.colorize)('No specific recommendations — looks solid.', 'green') + '\n');
94
+ return;
95
+ }
96
+ // High priority first.
97
+ const order = { high: 0, medium: 1, low: 2 };
98
+ const recs = [...report.recommendations].sort((a, b) => order[a.priority] - order[b.priority]);
99
+ console.log('');
100
+ for (const r of recs) {
101
+ const target = r.target ? (0, colors_1.colorize)(` [${r.target}]`, 'cyan') : '';
102
+ console.log(` ${priorityBadge(r.priority)} ${(0, colors_1.colorize)(KIND_LABEL[r.kind], 'bright')} ${r.title}${target}`);
103
+ if (r.detail)
104
+ console.log(` ${r.detail.replace(/\n/g, '\n ')}`);
105
+ if (r.example) {
106
+ console.log((0, colors_1.colorize)(' example:', 'dim'));
107
+ console.log((0, colors_1.colorize)(r.example.replace(/^/gm, ' '), 'dim'));
108
+ }
109
+ console.log('');
110
+ }
111
+ }
package/dist/program.js CHANGED
@@ -88,6 +88,8 @@ const ghq_status_1 = require("./commands/ghq-status");
88
88
  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
+ const completion_1 = require("./commands/completion");
92
+ const reflect_1 = require("./commands/reflect");
91
93
  (0, create_1.registerCreateCommand)(exports.program);
92
94
  (0, list_1.registerListCommand)(exports.program);
93
95
  (0, update_1.registerUpdateCommand)(exports.program);
@@ -111,6 +113,8 @@ const report_bug_1 = require("./commands/report-bug");
111
113
  (0, save_context_1.registerSaveContextCommand)(exports.program);
112
114
  (0, migrate_1.registerMigrateCommand)(exports.program);
113
115
  (0, report_bug_1.registerReportBugCommand)(exports.program);
116
+ (0, completion_1.registerCompletionCommand)(exports.program);
117
+ (0, reflect_1.registerReflectCommand)(exports.program);
114
118
  // Register TUI (delegates to existing Ink/React implementation)
115
119
  exports.program
116
120
  .command('tui')
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runAgentRaw = runAgentRaw;
4
+ exports.runAgentJson = runAgentJson;
5
+ exports.parseAgentJson = parseAgentJson;
6
+ const child_process_1 = require("child_process");
7
+ const agent_config_1 = require("./agent-config");
8
+ const DEFAULT_TIMEOUT_MS = 180000; // judging N transcripts is heavier than extraction
9
+ const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
10
+ /**
11
+ * Invoke the agent with `prompt` and return the raw stdout. Throws a clear error
12
+ * on timeout / non-zero exit. Kept separate from parsing so callers can inspect
13
+ * raw output (e.g. `--dry-run`, debugging).
14
+ */
15
+ function runAgentRaw(prompt, opts = {}) {
16
+ const agentType = opts.agentType ?? (0, agent_config_1.getPrimaryAgent)().type;
17
+ const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
18
+ const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
19
+ const exec = opts.exec ??
20
+ ((cmd, args, o) => (0, child_process_1.execFileSync)(cmd, args, { encoding: 'utf-8', timeout: o.timeout, maxBuffer: o.maxBuffer }));
21
+ const attempt = (cmd, args) => exec(cmd, args, { timeout, maxBuffer });
22
+ try {
23
+ if (agentType === 'claude') {
24
+ const preferred = ['-p', prompt, '--output-format', 'json', '--bare', '--strict-mcp-config', '--disable-slash-commands'];
25
+ if (opts.schema)
26
+ preferred.push('--json-schema', opts.schema);
27
+ try {
28
+ return attempt('claude', preferred);
29
+ }
30
+ catch {
31
+ // Older claude may reject the newer flags — fall back to the plainest form.
32
+ return attempt('claude', ['-p', prompt]);
33
+ }
34
+ }
35
+ if (agentType === 'opencode') {
36
+ return attempt('opencode', ['run', prompt]);
37
+ }
38
+ // pi (and any other): run as lean as possible so a bloated env can't hang it.
39
+ const piLean = ['--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files', '--no-tools', '--no-session'];
40
+ try {
41
+ return attempt('pi', [...piLean, '-p', prompt]);
42
+ }
43
+ catch {
44
+ return attempt('pi', ['-p', prompt]);
45
+ }
46
+ }
47
+ catch (err) {
48
+ const detail = (err?.stderr || err?.stdout || err?.message || 'unknown error').toString().trim().slice(0, 500);
49
+ throw new Error(`agent judge failed (${agentType}): ${detail}`);
50
+ }
51
+ }
52
+ /**
53
+ * Run the agent and parse its reply as JSON, tolerating the shapes different
54
+ * agents emit: `{ structured_output }`, `{ result: "<json>" }`, a ```json fence,
55
+ * or a bare object. Returns `unknown`; callers validate/normalize their shape.
56
+ */
57
+ function runAgentJson(prompt, opts = {}) {
58
+ const raw = runAgentRaw(prompt, opts);
59
+ return parseAgentJson(raw);
60
+ }
61
+ /** Extract a JSON object from an agent's raw stdout. Exported for tests. */
62
+ function parseAgentJson(raw) {
63
+ let text = raw.trim();
64
+ const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
65
+ if (fence)
66
+ text = fence[1].trim();
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(text);
70
+ }
71
+ catch {
72
+ // Last resort: grab the outermost {...} span.
73
+ const span = text.match(/\{[\s\S]*\}/);
74
+ if (!span)
75
+ throw new Error('agent did not return JSON');
76
+ parsed = JSON.parse(span[0]);
77
+ }
78
+ // Unwrap the common agent envelopes.
79
+ if (parsed && typeof parsed === 'object') {
80
+ if (parsed.structured_output && typeof parsed.structured_output === 'object')
81
+ return parsed.structured_output;
82
+ if (typeof parsed.result === 'string') {
83
+ try {
84
+ return JSON.parse(parsed.result);
85
+ }
86
+ catch {
87
+ /* fall through — result was plain text, return the envelope */
88
+ }
89
+ }
90
+ if (parsed.result && typeof parsed.result === 'object')
91
+ return parsed.result;
92
+ }
93
+ return parsed;
94
+ }