@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.
- package/CHANGELOG.md +53 -11
- package/dist/cli/ai-prompt.js +4 -0
- package/dist/commands/reflect.js +41 -8
- package/dist/utils/agent-judge.js +89 -12
- package/dist/utils/reflect-analyze.js +136 -0
- package/dist/utils/reflect.js +99 -42
- package/package.json +1 -1
- package/src/cli/ai-prompt.ts +4 -0
- package/src/commands/reflect.ts +52 -10
- 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 +50 -27
- package/src/utils/reflect.ts +111 -59
package/src/utils/reflect.ts
CHANGED
|
@@ -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('<')
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|