@nemus-cli/nemus 0.3.1 → 0.3.2
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 +41 -11
- package/dist/cli/ai-prompt.js +4 -0
- package/dist/commands/reflect.js +13 -4
- package/dist/utils/agent-judge.js +89 -12
- package/dist/utils/reflect-analyze.js +136 -0
- package/dist/utils/reflect.js +75 -39
- package/package.json +1 -1
- package/src/cli/ai-prompt.ts +4 -0
- package/src/commands/reflect.ts +14 -6
- package/src/utils/agent-judge.test.ts +37 -2
- package/src/utils/agent-judge.ts +98 -15
- package/src/utils/reflect-analyze.test.ts +126 -0
- package/src/utils/reflect-analyze.ts +246 -0
- package/src/utils/reflect.test.ts +30 -27
- package/src/utils/reflect.ts +83 -58
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { distillTranscript,
|
|
2
|
+
import { distillTranscript, parseReflectionReport, classifyAgentsMd, isCorrectionPrompt } from './reflect';
|
|
3
3
|
|
|
4
4
|
const J = (o: unknown) => JSON.stringify(o);
|
|
5
5
|
|
|
@@ -15,6 +15,8 @@ describe('distillTranscript', () => {
|
|
|
15
15
|
J({ type: 'message', message: { role: 'toolResult', toolName: 'bash', isError: true, content: [{ type: 'text', text: 'command failed: boom' }] } }),
|
|
16
16
|
// claude: user prompt as plain string
|
|
17
17
|
J({ type: 'user', message: { role: 'user', content: 'claude style prompt' } }),
|
|
18
|
+
// a correction/re-steer → captured verbatim in reSteerSamples
|
|
19
|
+
J({ type: 'user', message: { role: 'user', content: 'no, that is wrong — revert that change' } }),
|
|
18
20
|
// claude: assistant tool_use
|
|
19
21
|
J({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Edit' }] } }),
|
|
20
22
|
// claude: tool_result-only user message → an error, NOT a prompt
|
|
@@ -31,13 +33,14 @@ describe('distillTranscript', () => {
|
|
|
31
33
|
|
|
32
34
|
const d = distillTranscript(lines, { sessionId: 's1', agentType: 'pi' });
|
|
33
35
|
expect(d.turns).toBe(2);
|
|
34
|
-
expect(d.userPrompts).toEqual(['do the thing', 'claude style prompt']);
|
|
36
|
+
expect(d.userPrompts).toEqual(['do the thing', 'claude style prompt', 'no, that is wrong — revert that change']);
|
|
35
37
|
expect(d.tools.sort()).toEqual(['Edit', 'bash', 'git', 'grep']);
|
|
36
38
|
expect(d.errors).toContain('command failed: boom'); // explicit isError:true
|
|
37
39
|
expect(d.errors).toContain('file not found'); // explicit is_error:true
|
|
38
40
|
expect(d.errors).toContain('fatal: not a git repository'); // unflagged + strong signal
|
|
39
41
|
expect(d.errors).not.toContain('0 results for error'); // unflagged benign 'error' mention
|
|
40
42
|
expect(d.errors.some((e) => e.includes('flagged success'))).toBe(false); // isError:false trusted
|
|
43
|
+
expect(d.reSteerSamples).toEqual(['no, that is wrong — revert that change']); // captured verbatim
|
|
41
44
|
});
|
|
42
45
|
|
|
43
46
|
it('is bounded and tolerant of empty input', () => {
|
|
@@ -50,32 +53,32 @@ describe('distillTranscript', () => {
|
|
|
50
53
|
});
|
|
51
54
|
});
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
};
|
|
56
|
+
describe('classifyAgentsMd', () => {
|
|
57
|
+
it('distinguishes missing / boilerplate / substantive', () => {
|
|
58
|
+
expect(classifyAgentsMd('')).toBe('missing');
|
|
59
|
+
expect(classifyAgentsMd(null)).toBe('missing');
|
|
60
|
+
// Generated template: headings + markers, little real guidance.
|
|
61
|
+
expect(classifyAgentsMd('# Workspace\n\nThis workspace was created with Workspace Manager.\n<!-- ws-rules:v2 -->\n## Notes\n- \n')).toBe('boilerplate');
|
|
62
|
+
// Real, rule-heavy content.
|
|
63
|
+
const real = Array.from({ length: 12 }, (_, i) => `- Always run the ${i} integration suite before opening a pull request here`).join('\n');
|
|
64
|
+
expect(classifyAgentsMd(`# Rules\n${real}`)).toBe('substantive');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('is newline-independent: a whitespace-collapsed substantive file still classifies substantive', () => {
|
|
68
|
+
// Regression for the collapse bug: same content, newlines squashed to spaces.
|
|
69
|
+
const real = Array.from({ length: 12 }, (_, i) => `- Always run the ${i} integration suite before opening a pull request here`).join('\n');
|
|
70
|
+
const multiline = `# Rules\n${real}`;
|
|
71
|
+
const collapsed = multiline.replace(/\s+/g, ' ');
|
|
72
|
+
expect(classifyAgentsMd(collapsed)).toBe(classifyAgentsMd(multiline));
|
|
73
|
+
expect(classifyAgentsMd(collapsed)).toBe('substantive');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
67
76
|
|
|
68
|
-
describe('
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
expect(
|
|
72
|
-
expect(
|
|
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/);
|
|
77
|
+
describe('isCorrectionPrompt', () => {
|
|
78
|
+
it('flags corrections, not normal instructions', () => {
|
|
79
|
+
expect(isCorrectionPrompt('no, revert that')).toBe(true);
|
|
80
|
+
expect(isCorrectionPrompt('actually use the other repo instead')).toBe(true);
|
|
81
|
+
expect(isCorrectionPrompt('add a health check to the api')).toBe(false);
|
|
79
82
|
});
|
|
80
83
|
});
|
|
81
84
|
|
package/src/utils/reflect.ts
CHANGED
|
@@ -13,18 +13,26 @@ export interface SessionDigest {
|
|
|
13
13
|
turns: number;
|
|
14
14
|
/** Human prompts the user sent (the raw material for judging prompt quality). */
|
|
15
15
|
userPrompts: string[];
|
|
16
|
+
/** Verbatim user “re-steer” messages (corrections/redirects) — the highest-
|
|
17
|
+
* signal quotes for the judge to coach on. Subset of userPrompts, bounded. */
|
|
18
|
+
reSteerSamples: string[];
|
|
16
19
|
/** Error/failure snippets from tool results (missing skills/tests show up here). */
|
|
17
20
|
errors: string[];
|
|
18
21
|
/** Distinct tool names the agent used. */
|
|
19
22
|
tools: string[];
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/** How useful a workspace's context file (AGENTS.md/CLAUDE.md) actually is. */
|
|
26
|
+
export type ContextQuality = 'missing' | 'boilerplate' | 'substantive';
|
|
27
|
+
|
|
22
28
|
export interface WorkspaceDigest {
|
|
23
29
|
name: string;
|
|
24
30
|
repoCount: number;
|
|
25
31
|
repos: string[];
|
|
26
32
|
/** Context files present at the workspace root, e.g. ['AGENTS.md']. */
|
|
27
33
|
contextFiles: string[];
|
|
34
|
+
/** Whether the primary context file is missing / boilerplate / substantive. */
|
|
35
|
+
contextQuality: ContextQuality;
|
|
28
36
|
session: SessionDigest | null;
|
|
29
37
|
}
|
|
30
38
|
|
|
@@ -62,8 +70,21 @@ export interface ReflectionReport {
|
|
|
62
70
|
// fraction of the tokens (~halving the prompt), so the judge actually finishes.
|
|
63
71
|
const MAX_PROMPTS = 12;
|
|
64
72
|
const MAX_ERRORS = 15;
|
|
73
|
+
const MAX_RESTEER = 6;
|
|
65
74
|
const PROMPT_CHARS = 400;
|
|
66
75
|
const ERROR_CHARS = 200;
|
|
76
|
+
const RESTEER_CHARS = 240;
|
|
77
|
+
|
|
78
|
+
// A conservative “the user corrected / redirected the agent” cue. Soft signal:
|
|
79
|
+
// used to count re-steers and to capture the verbatim message for the judge.
|
|
80
|
+
const CORRECTION_RE =
|
|
81
|
+
/\b(no|nope|wrong|incorrect|revert|undo|instead|actually|you (missed|forgot|broke|didn'?t)|that'?s? (wrong|not right|incorrect)|not what|don'?t)\b/i;
|
|
82
|
+
|
|
83
|
+
/** Whether a single user prompt reads like a correction/redirect. Exported +
|
|
84
|
+
* shared with the analyzer so the two never diverge. */
|
|
85
|
+
export function isCorrectionPrompt(prompt: string): boolean {
|
|
86
|
+
return CORRECTION_RE.test(prompt ?? '');
|
|
87
|
+
}
|
|
67
88
|
|
|
68
89
|
/** Flatten a message `content` (string or content-block array) to plain text. */
|
|
69
90
|
function contentToText(content: unknown): string {
|
|
@@ -105,6 +126,7 @@ export function distillTranscript(
|
|
|
105
126
|
meta: { sessionId: string; agentType: string },
|
|
106
127
|
): SessionDigest {
|
|
107
128
|
const userPrompts: string[] = [];
|
|
129
|
+
const reSteerSamples: string[] = [];
|
|
108
130
|
const errors: string[] = [];
|
|
109
131
|
const tools = new Set<string>();
|
|
110
132
|
let turns = 0;
|
|
@@ -160,14 +182,57 @@ export function distillTranscript(
|
|
|
160
182
|
Array.isArray(content) && content.length > 0 && content.every((b: any) => b?.type === 'tool_result');
|
|
161
183
|
if (!isToolResultOnly) {
|
|
162
184
|
const text = contentToText(content).trim();
|
|
163
|
-
if (text && !text.startsWith('<')
|
|
164
|
-
userPrompts.push(text.slice(0, PROMPT_CHARS));
|
|
185
|
+
if (text && !text.startsWith('<')) {
|
|
186
|
+
if (userPrompts.length < MAX_PROMPTS) userPrompts.push(text.slice(0, PROMPT_CHARS));
|
|
187
|
+
// Capture corrections verbatim (bounded) — the sharpest coaching signal.
|
|
188
|
+
if (reSteerSamples.length < MAX_RESTEER && isCorrectionPrompt(text)) {
|
|
189
|
+
reSteerSamples.push(text.slice(0, RESTEER_CHARS));
|
|
190
|
+
}
|
|
165
191
|
}
|
|
166
192
|
}
|
|
167
193
|
}
|
|
168
194
|
}
|
|
169
195
|
|
|
170
|
-
return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, errors, tools: [...tools] };
|
|
196
|
+
return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, reSteerSamples, errors, tools: [...tools] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ------------------------------------------------------- context classification
|
|
200
|
+
|
|
201
|
+
// Lines that are structural/boilerplate rather than real, custom guidance.
|
|
202
|
+
const BOILERPLATE_MARKERS = [
|
|
203
|
+
'ws-rules:',
|
|
204
|
+
'this workspace was created with',
|
|
205
|
+
'workspace manager',
|
|
206
|
+
'saved context',
|
|
207
|
+
'add your own notes here',
|
|
208
|
+
'common workflows',
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Classify an AGENTS.md/CLAUDE.md by how much *real* guidance it carries, so the
|
|
213
|
+
* judge can tell “no context” from “has a file but it's the generated template.”
|
|
214
|
+
* Heuristic + pure.
|
|
215
|
+
*
|
|
216
|
+
* Deliberately **newline-independent**: it measures the volume of non-boilerplate
|
|
217
|
+
* prose (word count) plus heading count via a whitespace-tolerant regex, rather
|
|
218
|
+
* than splitting on lines. A line-anchored version would misclassify any excerpt
|
|
219
|
+
* whose newlines were collapsed to spaces upstream (a real bug class caught in a
|
|
220
|
+
* sibling implementation) — here even a fully single-lined file classifies the
|
|
221
|
+
* same as its multi-line original.
|
|
222
|
+
*/
|
|
223
|
+
export function classifyAgentsMd(content: string | null | undefined): ContextQuality {
|
|
224
|
+
if (!content || !content.trim()) return 'missing';
|
|
225
|
+
let s = content.replace(/\r\n/g, '\n').toLowerCase();
|
|
226
|
+
s = s.replace(/```[\s\S]*?```/g, ' '); // drop fenced code
|
|
227
|
+
s = s.replace(/<!--[\s\S]*?-->/g, ' '); // drop HTML comments
|
|
228
|
+
for (const m of BOILERPLATE_MARKERS) s = s.split(m).join(' '); // drop generated boilerplate (markers are lowercase)
|
|
229
|
+
// Headings: a `#` run at start OR after any whitespace (so a collapsed,
|
|
230
|
+
// single-line excerpt still counts them), followed by a space.
|
|
231
|
+
const headings = (s.match(/(?:^|\s)#{1,6}\s/g) || []).length;
|
|
232
|
+
// Remaining non-boilerplate words (markdown punctuation stripped).
|
|
233
|
+
const words = s.replace(/[#|>*_`~-]/g, ' ').split(/\s+/).filter((w) => w.length > 1).length;
|
|
234
|
+
if (words < 40) return 'boilerplate';
|
|
235
|
+
return headings >= 2 || words >= 60 ? 'substantive' : 'boilerplate';
|
|
171
236
|
}
|
|
172
237
|
|
|
173
238
|
// --------------------------------------------------------- corpus gathering
|
|
@@ -242,17 +307,23 @@ async function listAvailableSkills(): Promise<string[]> {
|
|
|
242
307
|
return [...names].sort();
|
|
243
308
|
}
|
|
244
309
|
|
|
245
|
-
|
|
310
|
+
/** Which context files exist at the workspace root, plus how substantive the
|
|
311
|
+
* primary one is (missing/boilerplate/substantive). One read per present file. */
|
|
312
|
+
async function contextFilesFor(workspacePath: string): Promise<{ files: string[]; quality: ContextQuality }> {
|
|
246
313
|
const present: string[] = [];
|
|
314
|
+
let quality: ContextQuality = 'missing';
|
|
247
315
|
for (const name of getAllKnownContextFileNames()) {
|
|
248
316
|
try {
|
|
249
|
-
await fs.
|
|
317
|
+
const content = await fs.readFile(path.join(workspacePath, name), 'utf-8');
|
|
250
318
|
present.push(name);
|
|
319
|
+
// Classify the first present file, then keep the best classification seen.
|
|
320
|
+
const c = classifyAgentsMd(content);
|
|
321
|
+
if (quality === 'missing' || (quality === 'boilerplate' && c === 'substantive')) quality = c;
|
|
251
322
|
} catch {
|
|
252
|
-
/* not present */
|
|
323
|
+
/* not present / unreadable */
|
|
253
324
|
}
|
|
254
325
|
}
|
|
255
|
-
return present;
|
|
326
|
+
return { files: present, quality };
|
|
256
327
|
}
|
|
257
328
|
|
|
258
329
|
/** Fired as each workspace is read + distilled, so the CLI can show live,
|
|
@@ -286,11 +357,13 @@ export async function gatherReflectionCorpus(
|
|
|
286
357
|
for (let index = 0; index < recent.length; index++) {
|
|
287
358
|
const s = recent[index];
|
|
288
359
|
const meta = metaByName.get(s.workspaceName);
|
|
360
|
+
const context = await contextFilesFor(s.workspacePath);
|
|
289
361
|
const digest: WorkspaceDigest = {
|
|
290
362
|
name: s.workspaceName,
|
|
291
363
|
repoCount: meta?.metadata?.repositories?.length ?? 0,
|
|
292
364
|
repos: (meta?.metadata?.repositories ?? []).map((r) => r.name),
|
|
293
|
-
contextFiles:
|
|
365
|
+
contextFiles: context.files,
|
|
366
|
+
contextQuality: context.quality,
|
|
294
367
|
session: await readDigestForSession(s),
|
|
295
368
|
};
|
|
296
369
|
digests.push(digest);
|
|
@@ -326,56 +399,8 @@ export const REFLECT_SCHEMA = JSON.stringify({
|
|
|
326
399
|
required: ['summary', 'recommendations'],
|
|
327
400
|
});
|
|
328
401
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
* is asked to recommend concrete improvements to the user's SETUP (skills,
|
|
332
|
-
* AGENTS.md/context rules, connectivity/tests, prompt habits, workflow) — not to
|
|
333
|
-
* redo the tasks. Output is strict JSON matching REFLECT_SCHEMA.
|
|
334
|
-
*/
|
|
335
|
-
export function buildJudgePrompt(corpus: ReflectionCorpus): string {
|
|
336
|
-
const lines: string[] = [];
|
|
337
|
-
lines.push(
|
|
338
|
-
'You are an expert reviewer ("LLM as a judge") analyzing an engineer\'s recent AI coding-agent sessions.',
|
|
339
|
-
'Goal: recommend concrete improvements to their SETUP so the agent works better next time —',
|
|
340
|
-
'which skills to add and WHERE, which AGENTS.md/context rules are missing, missing connectivity/',
|
|
341
|
-
'smoke tests, and prompt habits to change. Judge the setup, do NOT redo the tasks.',
|
|
342
|
-
'',
|
|
343
|
-
'Base every recommendation on evidence in the sessions below (repeated failures, retries, vague',
|
|
344
|
-
'prompts, missing context). Prefer a few high-signal, actionable items over many generic ones.',
|
|
345
|
-
'When you suggest a skill or an AGENTS.md rule, include a short concrete `example` snippet.',
|
|
346
|
-
'',
|
|
347
|
-
`Globally installed skills (don't re-suggest these; suggest genuinely missing ones): ${corpus.availableSkills.join(', ') || '(none)'}`,
|
|
348
|
-
'',
|
|
349
|
-
`Recent workspaces (${corpus.workspaces.length}):`,
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
for (const ws of corpus.workspaces) {
|
|
353
|
-
lines.push(`\n## ${ws.name}`);
|
|
354
|
-
lines.push(`repos: ${ws.repos.join(', ') || '(none)'} | context files: ${ws.contextFiles.join(', ') || 'NONE'}`);
|
|
355
|
-
if (!ws.session) {
|
|
356
|
-
lines.push('session: (no recent agent session found)');
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
lines.push(`session: ${ws.session.turns} turns, tools used: ${ws.session.tools.join(', ') || '(none)'}`);
|
|
360
|
-
if (ws.session.userPrompts.length) {
|
|
361
|
-
lines.push('user prompts:');
|
|
362
|
-
for (const p of ws.session.userPrompts) lines.push(` - ${p.replace(/\n/g, ' ')}`);
|
|
363
|
-
}
|
|
364
|
-
if (ws.session.errors.length) {
|
|
365
|
-
lines.push('errors/failures observed:');
|
|
366
|
-
for (const e of ws.session.errors) lines.push(` - ${e.replace(/\n/g, ' ')}`);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
lines.push(
|
|
371
|
-
'',
|
|
372
|
-
'Respond with ONLY a JSON object of this shape (no prose, no markdown fence):',
|
|
373
|
-
'{"summary": string, "recommendations": [{"kind":"skill|context|test|prompt|connectivity|workflow|other",',
|
|
374
|
-
'"title": string, "detail": string, "target": string(optional workspace/repo/path),',
|
|
375
|
-
'"priority":"high|medium|low", "example": string(optional snippet)}]}',
|
|
376
|
-
);
|
|
377
|
-
return lines.join('\n');
|
|
378
|
-
}
|
|
402
|
+
// The judge prompt is now built from pre-computed FACTS (see reflect-analyze.ts
|
|
403
|
+
// `buildAnalysisPrompt`), not raw transcripts, so the LLM call stays small/fast.
|
|
379
404
|
|
|
380
405
|
// ----------------------------------------------------------- response parse
|
|
381
406
|
|