@nemus-cli/nemus 0.3.0 → 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
 
@@ -56,10 +64,27 @@ export interface ReflectionReport {
56
64
 
57
65
  // --------------------------------------------------------- transcript distill
58
66
 
59
- const MAX_PROMPTS = 25;
60
- const MAX_ERRORS = 25;
61
- const PROMPT_CHARS = 600;
62
- const ERROR_CHARS = 300;
67
+ // Kept deliberately lean: the judge runs on the user's own (often local, slow)
68
+ // agent, and a 10-workspace corpus at full verbosity produced a ~200KB prompt
69
+ // that timed pi out. These bounds capture the pattern of a session at a
70
+ // fraction of the tokens (~halving the prompt), so the judge actually finishes.
71
+ const MAX_PROMPTS = 12;
72
+ const MAX_ERRORS = 15;
73
+ const MAX_RESTEER = 6;
74
+ const PROMPT_CHARS = 400;
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
+ }
63
88
 
64
89
  /** Flatten a message `content` (string or content-block array) to plain text. */
65
90
  function contentToText(content: unknown): string {
@@ -101,6 +126,7 @@ export function distillTranscript(
101
126
  meta: { sessionId: string; agentType: string },
102
127
  ): SessionDigest {
103
128
  const userPrompts: string[] = [];
129
+ const reSteerSamples: string[] = [];
104
130
  const errors: string[] = [];
105
131
  const tools = new Set<string>();
106
132
  let turns = 0;
@@ -156,14 +182,57 @@ export function distillTranscript(
156
182
  Array.isArray(content) && content.length > 0 && content.every((b: any) => b?.type === 'tool_result');
157
183
  if (!isToolResultOnly) {
158
184
  const text = contentToText(content).trim();
159
- if (text && !text.startsWith('<') && userPrompts.length < MAX_PROMPTS) {
160
- 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
+ }
161
191
  }
162
192
  }
163
193
  }
164
194
  }
165
195
 
166
- 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';
167
236
  }
168
237
 
169
238
  // --------------------------------------------------------- corpus gathering
@@ -238,26 +307,44 @@ async function listAvailableSkills(): Promise<string[]> {
238
307
  return [...names].sort();
239
308
  }
240
309
 
241
- 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 }> {
242
313
  const present: string[] = [];
314
+ let quality: ContextQuality = 'missing';
243
315
  for (const name of getAllKnownContextFileNames()) {
244
316
  try {
245
- await fs.access(path.join(workspacePath, name));
317
+ const content = await fs.readFile(path.join(workspacePath, name), 'utf-8');
246
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;
247
322
  } catch {
248
- /* not present */
323
+ /* not present / unreadable */
249
324
  }
250
325
  }
251
- return present;
326
+ return { files: present, quality };
327
+ }
328
+
329
+ /** Fired as each workspace is read + distilled, so the CLI can show live,
330
+ * per-workspace progress during the (I/O-bound) gather phase. */
331
+ export interface ReflectProgress {
332
+ index: number;
333
+ total: number;
334
+ digest: WorkspaceDigest;
252
335
  }
253
336
 
254
337
  /**
255
338
  * Build the corpus the judge reasons over: the `limit` most **recently active**
256
339
  * workspaces (by their latest agent session, not creation date — a retrospective
257
340
  * is about recent *work*), each with its repos, context files, and distilled
258
- * session, plus the globally-available skills.
341
+ * session, plus the globally-available skills. `onProgress` (optional) fires
342
+ * once per workspace as it finishes, for a live progress display.
259
343
  */
260
- export async function gatherReflectionCorpus(limit: number): Promise<ReflectionCorpus> {
344
+ export async function gatherReflectionCorpus(
345
+ limit: number,
346
+ onProgress?: (p: ReflectProgress) => void,
347
+ ): Promise<ReflectionCorpus> {
261
348
  const [sessions, workspaces, availableSkills] = await Promise.all([
262
349
  getWorkspaceSessions(), // already sorted by last-active, one per workspace
263
350
  listWorkspaces(false),
@@ -267,15 +354,20 @@ export async function gatherReflectionCorpus(limit: number): Promise<ReflectionC
267
354
  const recent = sessions.slice(0, limit);
268
355
 
269
356
  const digests: WorkspaceDigest[] = [];
270
- for (const s of recent) {
357
+ for (let index = 0; index < recent.length; index++) {
358
+ const s = recent[index];
271
359
  const meta = metaByName.get(s.workspaceName);
272
- digests.push({
360
+ const context = await contextFilesFor(s.workspacePath);
361
+ const digest: WorkspaceDigest = {
273
362
  name: s.workspaceName,
274
363
  repoCount: meta?.metadata?.repositories?.length ?? 0,
275
364
  repos: (meta?.metadata?.repositories ?? []).map((r) => r.name),
276
- contextFiles: await contextFilesFor(s.workspacePath),
365
+ contextFiles: context.files,
366
+ contextQuality: context.quality,
277
367
  session: await readDigestForSession(s),
278
- });
368
+ };
369
+ digests.push(digest);
370
+ onProgress?.({ index, total: recent.length, digest });
279
371
  }
280
372
 
281
373
  return { generatedAt: new Date().toISOString(), availableSkills, workspaces: digests };
@@ -307,56 +399,8 @@ export const REFLECT_SCHEMA = JSON.stringify({
307
399
  required: ['summary', 'recommendations'],
308
400
  });
309
401
 
310
- /**
311
- * Build the LLM-as-a-judge prompt. The judge sees distilled recent sessions and
312
- * is asked to recommend concrete improvements to the user's SETUP (skills,
313
- * AGENTS.md/context rules, connectivity/tests, prompt habits, workflow) — not to
314
- * redo the tasks. Output is strict JSON matching REFLECT_SCHEMA.
315
- */
316
- export function buildJudgePrompt(corpus: ReflectionCorpus): string {
317
- const lines: string[] = [];
318
- lines.push(
319
- 'You are an expert reviewer ("LLM as a judge") analyzing an engineer\'s recent AI coding-agent sessions.',
320
- 'Goal: recommend concrete improvements to their SETUP so the agent works better next time —',
321
- 'which skills to add and WHERE, which AGENTS.md/context rules are missing, missing connectivity/',
322
- 'smoke tests, and prompt habits to change. Judge the setup, do NOT redo the tasks.',
323
- '',
324
- 'Base every recommendation on evidence in the sessions below (repeated failures, retries, vague',
325
- 'prompts, missing context). Prefer a few high-signal, actionable items over many generic ones.',
326
- 'When you suggest a skill or an AGENTS.md rule, include a short concrete `example` snippet.',
327
- '',
328
- `Globally installed skills (don't re-suggest these; suggest genuinely missing ones): ${corpus.availableSkills.join(', ') || '(none)'}`,
329
- '',
330
- `Recent workspaces (${corpus.workspaces.length}):`,
331
- );
332
-
333
- for (const ws of corpus.workspaces) {
334
- lines.push(`\n## ${ws.name}`);
335
- lines.push(`repos: ${ws.repos.join(', ') || '(none)'} | context files: ${ws.contextFiles.join(', ') || 'NONE'}`);
336
- if (!ws.session) {
337
- lines.push('session: (no recent agent session found)');
338
- continue;
339
- }
340
- lines.push(`session: ${ws.session.turns} turns, tools used: ${ws.session.tools.join(', ') || '(none)'}`);
341
- if (ws.session.userPrompts.length) {
342
- lines.push('user prompts:');
343
- for (const p of ws.session.userPrompts) lines.push(` - ${p.replace(/\n/g, ' ')}`);
344
- }
345
- if (ws.session.errors.length) {
346
- lines.push('errors/failures observed:');
347
- for (const e of ws.session.errors) lines.push(` - ${e.replace(/\n/g, ' ')}`);
348
- }
349
- }
350
-
351
- lines.push(
352
- '',
353
- 'Respond with ONLY a JSON object of this shape (no prose, no markdown fence):',
354
- '{"summary": string, "recommendations": [{"kind":"skill|context|test|prompt|connectivity|workflow|other",',
355
- '"title": string, "detail": string, "target": string(optional workspace/repo/path),',
356
- '"priority":"high|medium|low", "example": string(optional snippet)}]}',
357
- );
358
- return lines.join('\n');
359
- }
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.
360
404
 
361
405
  // ----------------------------------------------------------- response parse
362
406