@nemus-cli/nemus 0.3.1 → 0.3.3

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,6 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
+ import { CACHE_DIR } from './config';
3
4
  import { listWorkspaces } from './workspace-meta';
4
5
  import { getAgentPaths, getSkillsTargetDirs, getAllKnownContextFileNames, ConcreteAgentType } from './agent-config';
5
6
  import { pathToProjectDirName, getWorkspaceSessions, WorkspaceSession } from './claude-sessions';
@@ -13,18 +14,26 @@ export interface SessionDigest {
13
14
  turns: number;
14
15
  /** Human prompts the user sent (the raw material for judging prompt quality). */
15
16
  userPrompts: string[];
17
+ /** Verbatim user “re-steer” messages (corrections/redirects) — the highest-
18
+ * signal quotes for the judge to coach on. Subset of userPrompts, bounded. */
19
+ reSteerSamples: string[];
16
20
  /** Error/failure snippets from tool results (missing skills/tests show up here). */
17
21
  errors: string[];
18
22
  /** Distinct tool names the agent used. */
19
23
  tools: string[];
20
24
  }
21
25
 
26
+ /** How useful a workspace's context file (AGENTS.md/CLAUDE.md) actually is. */
27
+ export type ContextQuality = 'missing' | 'boilerplate' | 'substantive';
28
+
22
29
  export interface WorkspaceDigest {
23
30
  name: string;
24
31
  repoCount: number;
25
32
  repos: string[];
26
33
  /** Context files present at the workspace root, e.g. ['AGENTS.md']. */
27
34
  contextFiles: string[];
35
+ /** Whether the primary context file is missing / boilerplate / substantive. */
36
+ contextQuality: ContextQuality;
28
37
  session: SessionDigest | null;
29
38
  }
30
39
 
@@ -62,8 +71,21 @@ export interface ReflectionReport {
62
71
  // fraction of the tokens (~halving the prompt), so the judge actually finishes.
63
72
  const MAX_PROMPTS = 12;
64
73
  const MAX_ERRORS = 15;
74
+ const MAX_RESTEER = 6;
65
75
  const PROMPT_CHARS = 400;
66
76
  const ERROR_CHARS = 200;
77
+ const RESTEER_CHARS = 240;
78
+
79
+ // A conservative “the user corrected / redirected the agent” cue. Soft signal:
80
+ // used to count re-steers and to capture the verbatim message for the judge.
81
+ const CORRECTION_RE =
82
+ /\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;
83
+
84
+ /** Whether a single user prompt reads like a correction/redirect. Exported +
85
+ * shared with the analyzer so the two never diverge. */
86
+ export function isCorrectionPrompt(prompt: string): boolean {
87
+ return CORRECTION_RE.test(prompt ?? '');
88
+ }
67
89
 
68
90
  /** Flatten a message `content` (string or content-block array) to plain text. */
69
91
  function contentToText(content: unknown): string {
@@ -105,6 +127,7 @@ export function distillTranscript(
105
127
  meta: { sessionId: string; agentType: string },
106
128
  ): SessionDigest {
107
129
  const userPrompts: string[] = [];
130
+ const reSteerSamples: string[] = [];
108
131
  const errors: string[] = [];
109
132
  const tools = new Set<string>();
110
133
  let turns = 0;
@@ -160,14 +183,57 @@ export function distillTranscript(
160
183
  Array.isArray(content) && content.length > 0 && content.every((b: any) => b?.type === 'tool_result');
161
184
  if (!isToolResultOnly) {
162
185
  const text = contentToText(content).trim();
163
- if (text && !text.startsWith('<') && userPrompts.length < MAX_PROMPTS) {
164
- userPrompts.push(text.slice(0, PROMPT_CHARS));
186
+ if (text && !text.startsWith('<')) {
187
+ if (userPrompts.length < MAX_PROMPTS) userPrompts.push(text.slice(0, PROMPT_CHARS));
188
+ // Capture corrections verbatim (bounded) — the sharpest coaching signal.
189
+ if (reSteerSamples.length < MAX_RESTEER && isCorrectionPrompt(text)) {
190
+ reSteerSamples.push(text.slice(0, RESTEER_CHARS));
191
+ }
165
192
  }
166
193
  }
167
194
  }
168
195
  }
169
196
 
170
- return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, errors, tools: [...tools] };
197
+ return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, reSteerSamples, errors, tools: [...tools] };
198
+ }
199
+
200
+ // ------------------------------------------------------- context classification
201
+
202
+ // Lines that are structural/boilerplate rather than real, custom guidance.
203
+ const BOILERPLATE_MARKERS = [
204
+ 'ws-rules:',
205
+ 'this workspace was created with',
206
+ 'workspace manager',
207
+ 'saved context',
208
+ 'add your own notes here',
209
+ 'common workflows',
210
+ ];
211
+
212
+ /**
213
+ * Classify an AGENTS.md/CLAUDE.md by how much *real* guidance it carries, so the
214
+ * judge can tell “no context” from “has a file but it's the generated template.”
215
+ * Heuristic + pure.
216
+ *
217
+ * Deliberately **newline-independent**: it measures the volume of non-boilerplate
218
+ * prose (word count) plus heading count via a whitespace-tolerant regex, rather
219
+ * than splitting on lines. A line-anchored version would misclassify any excerpt
220
+ * whose newlines were collapsed to spaces upstream (a real bug class caught in a
221
+ * sibling implementation) — here even a fully single-lined file classifies the
222
+ * same as its multi-line original.
223
+ */
224
+ export function classifyAgentsMd(content: string | null | undefined): ContextQuality {
225
+ if (!content || !content.trim()) return 'missing';
226
+ let s = content.replace(/\r\n/g, '\n').toLowerCase();
227
+ s = s.replace(/```[\s\S]*?```/g, ' '); // drop fenced code
228
+ s = s.replace(/<!--[\s\S]*?-->/g, ' '); // drop HTML comments
229
+ for (const m of BOILERPLATE_MARKERS) s = s.split(m).join(' '); // drop generated boilerplate (markers are lowercase)
230
+ // Headings: a `#` run at start OR after any whitespace (so a collapsed,
231
+ // single-line excerpt still counts them), followed by a space.
232
+ const headings = (s.match(/(?:^|\s)#{1,6}\s/g) || []).length;
233
+ // Remaining non-boilerplate words (markdown punctuation stripped).
234
+ const words = s.replace(/[#|>*_`~-]/g, ' ').split(/\s+/).filter((w) => w.length > 1).length;
235
+ if (words < 40) return 'boilerplate';
236
+ return headings >= 2 || words >= 60 ? 'substantive' : 'boilerplate';
171
237
  }
172
238
 
173
239
  // --------------------------------------------------------- corpus gathering
@@ -242,17 +308,23 @@ async function listAvailableSkills(): Promise<string[]> {
242
308
  return [...names].sort();
243
309
  }
244
310
 
245
- async function contextFilesFor(workspacePath: string): Promise<string[]> {
311
+ /** Which context files exist at the workspace root, plus how substantive the
312
+ * primary one is (missing/boilerplate/substantive). One read per present file. */
313
+ async function contextFilesFor(workspacePath: string): Promise<{ files: string[]; quality: ContextQuality }> {
246
314
  const present: string[] = [];
315
+ let quality: ContextQuality = 'missing';
247
316
  for (const name of getAllKnownContextFileNames()) {
248
317
  try {
249
- await fs.access(path.join(workspacePath, name));
318
+ const content = await fs.readFile(path.join(workspacePath, name), 'utf-8');
250
319
  present.push(name);
320
+ // Classify the first present file, then keep the best classification seen.
321
+ const c = classifyAgentsMd(content);
322
+ if (quality === 'missing' || (quality === 'boilerplate' && c === 'substantive')) quality = c;
251
323
  } catch {
252
- /* not present */
324
+ /* not present / unreadable */
253
325
  }
254
326
  }
255
- return present;
327
+ return { files: present, quality };
256
328
  }
257
329
 
258
330
  /** Fired as each workspace is read + distilled, so the CLI can show live,
@@ -273,6 +345,7 @@ export interface ReflectProgress {
273
345
  export async function gatherReflectionCorpus(
274
346
  limit: number,
275
347
  onProgress?: (p: ReflectProgress) => void,
348
+ opts: { workspace?: string } = {},
276
349
  ): Promise<ReflectionCorpus> {
277
350
  const [sessions, workspaces, availableSkills] = await Promise.all([
278
351
  getWorkspaceSessions(), // already sorted by last-active, one per workspace
@@ -280,17 +353,22 @@ export async function gatherReflectionCorpus(
280
353
  listAvailableSkills(),
281
354
  ]);
282
355
  const metaByName = new Map(workspaces.map((w) => [w.name, w]));
283
- const recent = sessions.slice(0, limit);
356
+ // A single named workspace (ignores limit), else the N most recently active.
357
+ const recent = opts.workspace
358
+ ? sessions.filter((s) => s.workspaceName === opts.workspace)
359
+ : sessions.slice(0, limit);
284
360
 
285
361
  const digests: WorkspaceDigest[] = [];
286
362
  for (let index = 0; index < recent.length; index++) {
287
363
  const s = recent[index];
288
364
  const meta = metaByName.get(s.workspaceName);
365
+ const context = await contextFilesFor(s.workspacePath);
289
366
  const digest: WorkspaceDigest = {
290
367
  name: s.workspaceName,
291
368
  repoCount: meta?.metadata?.repositories?.length ?? 0,
292
369
  repos: (meta?.metadata?.repositories ?? []).map((r) => r.name),
293
- contextFiles: await contextFilesFor(s.workspacePath),
370
+ contextFiles: context.files,
371
+ contextQuality: context.quality,
294
372
  session: await readDigestForSession(s),
295
373
  };
296
374
  digests.push(digest);
@@ -326,56 +404,8 @@ export const REFLECT_SCHEMA = JSON.stringify({
326
404
  required: ['summary', 'recommendations'],
327
405
  });
328
406
 
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
- }
407
+ // The judge prompt is now built from pre-computed FACTS (see reflect-analyze.ts
408
+ // `buildAnalysisPrompt`), not raw transcripts, so the LLM call stays small/fast.
379
409
 
380
410
  // ----------------------------------------------------------- response parse
381
411
 
@@ -403,3 +433,25 @@ export function parseReflectionReport(parsed: unknown): ReflectionReport {
403
433
  .filter((r: Recommendation | null): r is Recommendation => r !== null);
404
434
  return { summary, recommendations };
405
435
  }
436
+
437
+ // -------------------------------------------------------------- report saving
438
+
439
+ /** Where saved reflection reports live: `~/.nemus/reflect/`. */
440
+ export const REFLECT_REPORTS_DIR = path.join(CACHE_DIR, 'reflect');
441
+
442
+ /**
443
+ * Persist a reflection report as timestamped JSON under `~/.nemus/reflect/`, so
444
+ * a run can be revisited or diffed over time. Returns the written path. Pure
445
+ * side-effect (mkdir -p + write); callers treat failure as non-fatal.
446
+ */
447
+ export async function saveReflectionReport(
448
+ report: ReflectionReport,
449
+ meta: { analyzed: number; workspaces: number; workspace?: string },
450
+ ): Promise<string> {
451
+ await fs.mkdir(REFLECT_REPORTS_DIR, { recursive: true });
452
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
453
+ const scope = meta.workspace ? `-${meta.workspace.replace(/[^a-zA-Z0-9_-]+/g, '_')}` : '';
454
+ const file = path.join(REFLECT_REPORTS_DIR, `${stamp}${scope}.json`);
455
+ await fs.writeFile(file, JSON.stringify({ generatedAt: new Date().toISOString(), ...meta, ...report }, null, 2));
456
+ return file;
457
+ }