@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/CHANGELOG.md +17 -0
- package/README.md +17 -0
- package/dist/cli/ai-prompt.js +13 -25
- package/dist/commands/reflect.js +111 -0
- package/dist/program.js +2 -0
- package/dist/utils/agent-judge.js +94 -0
- package/dist/utils/reflect.js +345 -0
- package/package.json +1 -1
- package/src/cli/ai-prompt.ts +12 -22
- package/src/commands/reflect.ts +120 -0
- package/src/program.ts +2 -0
- package/src/utils/agent-judge.test.ts +54 -0
- package/src/utils/agent-judge.ts +109 -0
- package/src/utils/reflect.test.ts +103 -0
- package/src/utils/reflect.ts +386 -0
package/package.json
CHANGED
package/src/cli/ai-prompt.ts
CHANGED
|
@@ -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
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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
|
+
});
|