@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.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { distillTranscript, buildJudgePrompt, parseReflectionReport, ReflectionCorpus } from './reflect';
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
- 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
- };
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('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/);
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
 
@@ -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('<') && userPrompts.length < MAX_PROMPTS) {
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
- async function contextFilesFor(workspacePath: string): Promise<string[]> {
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.access(path.join(workspacePath, name));
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: await contextFilesFor(s.workspacePath),
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
- * Build the LLM-as-a-judge prompt. The judge sees distilled recent sessions and
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