@nemus-cli/nemus 0.2.12 → 0.3.0
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 +28 -0
- package/README.md +35 -0
- package/dist/cli/ai-prompt.js +13 -25
- package/dist/commands/completion.js +143 -0
- package/dist/commands/reflect.js +111 -0
- package/dist/program.js +4 -0
- package/dist/utils/agent-judge.js +94 -0
- package/dist/utils/reflect.js +345 -0
- package/package.json +1 -1
- package/src/cli/ai-prompt.ts +12 -22
- package/src/commands/completion.test.ts +60 -0
- package/src/commands/completion.ts +160 -0
- package/src/commands/reflect.ts +120 -0
- package/src/program.ts +4 -0
- package/src/utils/agent-judge.test.ts +54 -0
- package/src/utils/agent-judge.ts +109 -0
- package/src/utils/reflect.test.ts +103 -0
- package/src/utils/reflect.ts +386 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.REFLECT_SCHEMA = void 0;
|
|
37
|
+
exports.distillTranscript = distillTranscript;
|
|
38
|
+
exports.findLatestTranscriptFile = findLatestTranscriptFile;
|
|
39
|
+
exports.gatherReflectionCorpus = gatherReflectionCorpus;
|
|
40
|
+
exports.buildJudgePrompt = buildJudgePrompt;
|
|
41
|
+
exports.parseReflectionReport = parseReflectionReport;
|
|
42
|
+
const fs = __importStar(require("fs/promises"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const workspace_meta_1 = require("./workspace-meta");
|
|
45
|
+
const agent_config_1 = require("./agent-config");
|
|
46
|
+
const claude_sessions_1 = require("./claude-sessions");
|
|
47
|
+
// --------------------------------------------------------- transcript distill
|
|
48
|
+
const MAX_PROMPTS = 25;
|
|
49
|
+
const MAX_ERRORS = 25;
|
|
50
|
+
const PROMPT_CHARS = 600;
|
|
51
|
+
const ERROR_CHARS = 300;
|
|
52
|
+
/** Flatten a message `content` (string or content-block array) to plain text. */
|
|
53
|
+
function contentToText(content) {
|
|
54
|
+
if (typeof content === 'string')
|
|
55
|
+
return content;
|
|
56
|
+
if (Array.isArray(content)) {
|
|
57
|
+
return content
|
|
58
|
+
.map((b) => (b && b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join('\n');
|
|
61
|
+
}
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
// Only used as a FALLBACK when a tool result carries no explicit error flag.
|
|
65
|
+
// Kept to strong failure signals so a successful grep/log line for the word
|
|
66
|
+
// "error", or a passing test named “…error…”, isn't mistaken for a failure.
|
|
67
|
+
const ERROR_RE = /\b(fatal|failed|failure|exception|traceback|denied|not a git repository|timed out|exit code\s+[1-9])\b/i;
|
|
68
|
+
/**
|
|
69
|
+
* Decide whether a tool result is a failure: trust the explicit `isError` flag
|
|
70
|
+
* when present (true => failure, false => success), and only guess from the
|
|
71
|
+
* text when there's no flag at all. This keeps the judge's “evidence” to real
|
|
72
|
+
* failures instead of any output that happens to contain the word “error”.
|
|
73
|
+
*/
|
|
74
|
+
function isToolFailure(flag, text) {
|
|
75
|
+
if (flag === true)
|
|
76
|
+
return true;
|
|
77
|
+
if (flag === false)
|
|
78
|
+
return false;
|
|
79
|
+
return ERROR_RE.test(text);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Distill a raw `.jsonl` transcript into the signals a judge needs: the human
|
|
83
|
+
* prompts, tool failures, and which tools ran. Pure (operates on file content),
|
|
84
|
+
* defensive about the several line shapes Claude/pi emit, and bounded so a huge
|
|
85
|
+
* transcript can't blow the prompt budget.
|
|
86
|
+
*/
|
|
87
|
+
function distillTranscript(raw, meta) {
|
|
88
|
+
const userPrompts = [];
|
|
89
|
+
const errors = [];
|
|
90
|
+
const tools = new Set();
|
|
91
|
+
let turns = 0;
|
|
92
|
+
for (const line of raw.split('\n')) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed)
|
|
95
|
+
continue;
|
|
96
|
+
let obj;
|
|
97
|
+
try {
|
|
98
|
+
obj = JSON.parse(trimmed);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const msg = obj.message ?? obj;
|
|
104
|
+
const role = msg?.role ?? obj?.type;
|
|
105
|
+
const content = msg?.content;
|
|
106
|
+
// Assistant turn + tool uses. Claude uses `tool_use` blocks; pi uses `toolCall`.
|
|
107
|
+
if (role === 'assistant') {
|
|
108
|
+
turns++;
|
|
109
|
+
if (Array.isArray(content)) {
|
|
110
|
+
for (const b of content) {
|
|
111
|
+
if (b && (b.type === 'tool_use' || b.type === 'toolCall') && typeof b.name === 'string')
|
|
112
|
+
tools.add(b.name);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Tool results, two shapes:
|
|
117
|
+
// - pi: a top-level message with role 'toolResult' (+ toolName, content).
|
|
118
|
+
// - Claude: a `tool_result` block inside a user message's content array.
|
|
119
|
+
if (role === 'toolResult') {
|
|
120
|
+
if (typeof msg.toolName === 'string')
|
|
121
|
+
tools.add(msg.toolName);
|
|
122
|
+
const text = contentToText(content);
|
|
123
|
+
if (isToolFailure(msg.isError ?? msg.is_error, text) && text.trim() && errors.length < MAX_ERRORS) {
|
|
124
|
+
errors.push(text.trim().slice(0, ERROR_CHARS));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (Array.isArray(content)) {
|
|
128
|
+
for (const b of content) {
|
|
129
|
+
if (b && b.type === 'tool_result') {
|
|
130
|
+
const text = contentToText(b.content);
|
|
131
|
+
if (isToolFailure(b.is_error, text) && text.trim() && errors.length < MAX_ERRORS) {
|
|
132
|
+
errors.push(text.trim().slice(0, ERROR_CHARS));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Human prompts: a user message that carries actual text (not a tool_result echo).
|
|
138
|
+
if (role === 'user') {
|
|
139
|
+
const isToolResultOnly = Array.isArray(content) && content.length > 0 && content.every((b) => b?.type === 'tool_result');
|
|
140
|
+
if (!isToolResultOnly) {
|
|
141
|
+
const text = contentToText(content).trim();
|
|
142
|
+
if (text && !text.startsWith('<') && userPrompts.length < MAX_PROMPTS) {
|
|
143
|
+
userPrompts.push(text.slice(0, PROMPT_CHARS));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { sessionId: meta.sessionId, agentType: meta.agentType, turns, userPrompts, errors, tools: [...tools] };
|
|
149
|
+
}
|
|
150
|
+
// --------------------------------------------------------- corpus gathering
|
|
151
|
+
/** Locate the most recent `.jsonl` transcript for a workspace under an agent. */
|
|
152
|
+
async function findLatestTranscriptFile(sessionProjectsDir, workspacePath, agentType) {
|
|
153
|
+
if (agentType !== 'claude' && agentType !== 'pi')
|
|
154
|
+
return null;
|
|
155
|
+
const projDir = path.join(sessionProjectsDir, (0, claude_sessions_1.pathToProjectDirName)(workspacePath, agentType));
|
|
156
|
+
let entries;
|
|
157
|
+
try {
|
|
158
|
+
entries = await fs.readdir(projDir);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const jsonl = entries.filter((f) => f.endsWith('.jsonl'));
|
|
164
|
+
if (jsonl.length === 0)
|
|
165
|
+
return null;
|
|
166
|
+
const stats = await Promise.all(jsonl.map(async (f) => {
|
|
167
|
+
try {
|
|
168
|
+
return { f, mtime: (await fs.stat(path.join(projDir, f))).mtime.getTime() };
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}));
|
|
174
|
+
const best = stats.filter((s) => !!s).sort((a, b) => b.mtime - a.mtime)[0];
|
|
175
|
+
return best ? path.join(projDir, best.f) : null;
|
|
176
|
+
}
|
|
177
|
+
const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024;
|
|
178
|
+
/** Read + distill the transcript for a specific discovered session. Prefers the
|
|
179
|
+
* exact `<sessionId>.jsonl`; falls back to the latest transcript in that
|
|
180
|
+
* project dir if the exact file has been rotated away. */
|
|
181
|
+
async function readDigestForSession(s) {
|
|
182
|
+
if (s.agentType !== 'claude' && s.agentType !== 'pi')
|
|
183
|
+
return null;
|
|
184
|
+
const projectsDir = (0, agent_config_1.getAgentPaths)(s.agentType).sessionProjectsDir;
|
|
185
|
+
const exact = path.join(projectsDir, (0, claude_sessions_1.pathToProjectDirName)(s.workspacePath, s.agentType), `${s.sessionId}.jsonl`);
|
|
186
|
+
let file = exact;
|
|
187
|
+
try {
|
|
188
|
+
await fs.access(exact);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
file = await findLatestTranscriptFile(projectsDir, s.workspacePath, s.agentType);
|
|
192
|
+
}
|
|
193
|
+
if (!file)
|
|
194
|
+
return null;
|
|
195
|
+
let raw;
|
|
196
|
+
try {
|
|
197
|
+
raw = await fs.readFile(file, 'utf-8');
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
if (raw.length > MAX_TRANSCRIPT_BYTES)
|
|
203
|
+
raw = raw.slice(raw.length - MAX_TRANSCRIPT_BYTES); // keep the tail (most recent)
|
|
204
|
+
return distillTranscript(raw, { sessionId: s.sessionId, agentType: s.agentType });
|
|
205
|
+
}
|
|
206
|
+
async function listAvailableSkills() {
|
|
207
|
+
const names = new Set();
|
|
208
|
+
for (const dir of (0, agent_config_1.getSkillsTargetDirs)()) {
|
|
209
|
+
try {
|
|
210
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
211
|
+
if (entry.isDirectory())
|
|
212
|
+
names.add(entry.name);
|
|
213
|
+
else if (entry.name.endsWith('.md'))
|
|
214
|
+
names.add(entry.name.replace(/\.md$/, ''));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
/* dir may not exist */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return [...names].sort();
|
|
222
|
+
}
|
|
223
|
+
async function contextFilesFor(workspacePath) {
|
|
224
|
+
const present = [];
|
|
225
|
+
for (const name of (0, agent_config_1.getAllKnownContextFileNames)()) {
|
|
226
|
+
try {
|
|
227
|
+
await fs.access(path.join(workspacePath, name));
|
|
228
|
+
present.push(name);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
/* not present */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return present;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Build the corpus the judge reasons over: the `limit` most **recently active**
|
|
238
|
+
* workspaces (by their latest agent session, not creation date — a retrospective
|
|
239
|
+
* is about recent *work*), each with its repos, context files, and distilled
|
|
240
|
+
* session, plus the globally-available skills.
|
|
241
|
+
*/
|
|
242
|
+
async function gatherReflectionCorpus(limit) {
|
|
243
|
+
const [sessions, workspaces, availableSkills] = await Promise.all([
|
|
244
|
+
(0, claude_sessions_1.getWorkspaceSessions)(), // already sorted by last-active, one per workspace
|
|
245
|
+
(0, workspace_meta_1.listWorkspaces)(false),
|
|
246
|
+
listAvailableSkills(),
|
|
247
|
+
]);
|
|
248
|
+
const metaByName = new Map(workspaces.map((w) => [w.name, w]));
|
|
249
|
+
const recent = sessions.slice(0, limit);
|
|
250
|
+
const digests = [];
|
|
251
|
+
for (const s of recent) {
|
|
252
|
+
const meta = metaByName.get(s.workspaceName);
|
|
253
|
+
digests.push({
|
|
254
|
+
name: s.workspaceName,
|
|
255
|
+
repoCount: meta?.metadata?.repositories?.length ?? 0,
|
|
256
|
+
repos: (meta?.metadata?.repositories ?? []).map((r) => r.name),
|
|
257
|
+
contextFiles: await contextFilesFor(s.workspacePath),
|
|
258
|
+
session: await readDigestForSession(s),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return { generatedAt: new Date().toISOString(), availableSkills, workspaces: digests };
|
|
262
|
+
}
|
|
263
|
+
// ------------------------------------------------------------- judge prompt
|
|
264
|
+
/** JSON schema for `claude --json-schema` (best-effort; other agents ignore it). */
|
|
265
|
+
exports.REFLECT_SCHEMA = JSON.stringify({
|
|
266
|
+
type: 'object',
|
|
267
|
+
properties: {
|
|
268
|
+
summary: { type: 'string' },
|
|
269
|
+
recommendations: {
|
|
270
|
+
type: 'array',
|
|
271
|
+
items: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
properties: {
|
|
274
|
+
kind: { type: 'string', enum: ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'] },
|
|
275
|
+
title: { type: 'string' },
|
|
276
|
+
detail: { type: 'string' },
|
|
277
|
+
target: { type: 'string' },
|
|
278
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'] },
|
|
279
|
+
example: { type: 'string' },
|
|
280
|
+
},
|
|
281
|
+
required: ['kind', 'title', 'detail', 'priority'],
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
required: ['summary', 'recommendations'],
|
|
286
|
+
});
|
|
287
|
+
/**
|
|
288
|
+
* Build the LLM-as-a-judge prompt. The judge sees distilled recent sessions and
|
|
289
|
+
* is asked to recommend concrete improvements to the user's SETUP (skills,
|
|
290
|
+
* AGENTS.md/context rules, connectivity/tests, prompt habits, workflow) — not to
|
|
291
|
+
* redo the tasks. Output is strict JSON matching REFLECT_SCHEMA.
|
|
292
|
+
*/
|
|
293
|
+
function buildJudgePrompt(corpus) {
|
|
294
|
+
const lines = [];
|
|
295
|
+
lines.push('You are an expert reviewer ("LLM as a judge") analyzing an engineer\'s recent AI coding-agent sessions.', 'Goal: recommend concrete improvements to their SETUP so the agent works better next time —', 'which skills to add and WHERE, which AGENTS.md/context rules are missing, missing connectivity/', 'smoke tests, and prompt habits to change. Judge the setup, do NOT redo the tasks.', '', 'Base every recommendation on evidence in the sessions below (repeated failures, retries, vague', 'prompts, missing context). Prefer a few high-signal, actionable items over many generic ones.', 'When you suggest a skill or an AGENTS.md rule, include a short concrete `example` snippet.', '', `Globally installed skills (don't re-suggest these; suggest genuinely missing ones): ${corpus.availableSkills.join(', ') || '(none)'}`, '', `Recent workspaces (${corpus.workspaces.length}):`);
|
|
296
|
+
for (const ws of corpus.workspaces) {
|
|
297
|
+
lines.push(`\n## ${ws.name}`);
|
|
298
|
+
lines.push(`repos: ${ws.repos.join(', ') || '(none)'} | context files: ${ws.contextFiles.join(', ') || 'NONE'}`);
|
|
299
|
+
if (!ws.session) {
|
|
300
|
+
lines.push('session: (no recent agent session found)');
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
lines.push(`session: ${ws.session.turns} turns, tools used: ${ws.session.tools.join(', ') || '(none)'}`);
|
|
304
|
+
if (ws.session.userPrompts.length) {
|
|
305
|
+
lines.push('user prompts:');
|
|
306
|
+
for (const p of ws.session.userPrompts)
|
|
307
|
+
lines.push(` - ${p.replace(/\n/g, ' ')}`);
|
|
308
|
+
}
|
|
309
|
+
if (ws.session.errors.length) {
|
|
310
|
+
lines.push('errors/failures observed:');
|
|
311
|
+
for (const e of ws.session.errors)
|
|
312
|
+
lines.push(` - ${e.replace(/\n/g, ' ')}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
lines.push('', 'Respond with ONLY a JSON object of this shape (no prose, no markdown fence):', '{"summary": string, "recommendations": [{"kind":"skill|context|test|prompt|connectivity|workflow|other",', '"title": string, "detail": string, "target": string(optional workspace/repo/path),', '"priority":"high|medium|low", "example": string(optional snippet)}]}');
|
|
316
|
+
return lines.join('\n');
|
|
317
|
+
}
|
|
318
|
+
// ----------------------------------------------------------- response parse
|
|
319
|
+
const KINDS = ['skill', 'context', 'test', 'prompt', 'connectivity', 'workflow', 'other'];
|
|
320
|
+
const PRIORITIES = ['high', 'medium', 'low'];
|
|
321
|
+
/** Validate + normalize the judge's parsed JSON into a ReflectionReport. */
|
|
322
|
+
function parseReflectionReport(parsed) {
|
|
323
|
+
const obj = (parsed ?? {});
|
|
324
|
+
const summary = typeof obj.summary === 'string' ? obj.summary : '';
|
|
325
|
+
const rawRecs = Array.isArray(obj.recommendations) ? obj.recommendations : [];
|
|
326
|
+
const recommendations = rawRecs
|
|
327
|
+
.map((r) => {
|
|
328
|
+
if (!r || typeof r !== 'object')
|
|
329
|
+
return null;
|
|
330
|
+
const title = typeof r.title === 'string' ? r.title : '';
|
|
331
|
+
const detail = typeof r.detail === 'string' ? r.detail : '';
|
|
332
|
+
if (!title && !detail)
|
|
333
|
+
return null;
|
|
334
|
+
const kind = KINDS.includes(r.kind) ? r.kind : 'other';
|
|
335
|
+
const priority = PRIORITIES.includes(r.priority) ? r.priority : 'medium';
|
|
336
|
+
const rec = { kind, title, detail, priority };
|
|
337
|
+
if (typeof r.target === 'string' && r.target.trim())
|
|
338
|
+
rec.target = r.target.trim();
|
|
339
|
+
if (typeof r.example === 'string' && r.example.trim())
|
|
340
|
+
rec.example = r.example.trim();
|
|
341
|
+
return rec;
|
|
342
|
+
})
|
|
343
|
+
.filter((r) => r !== null);
|
|
344
|
+
return { summary, recommendations };
|
|
345
|
+
}
|
package/package.json
CHANGED
package/src/cli/ai-prompt.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, execFile, execFileSync } from 'child_process';
|
|
2
|
+
import { parseAgentJson } from '../utils/agent-judge';
|
|
2
3
|
import { promisify } from 'util';
|
|
3
4
|
import * as fs from 'fs';
|
|
4
5
|
import * as path from 'path';
|
|
@@ -259,32 +260,21 @@ export async function extractIntent(prompt: string): Promise<ExtractedIntent> {
|
|
|
259
260
|
result = runExtraction('pi', [...piLean, ...piCore], piCore);
|
|
260
261
|
}
|
|
261
262
|
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const parsed = JSON.parse(jsonStr);
|
|
270
|
-
|
|
271
|
-
// Handle different output formats:
|
|
272
|
-
// - Claude: { structured_output: {...} } or { result: "..." }
|
|
273
|
-
// - Pi: may return the object directly or wrap it
|
|
263
|
+
// Unwrap the agent's reply (code fences + the structured_output / result-string
|
|
264
|
+
// / result-object / bare-object envelopes) with the shared parser, so this and
|
|
265
|
+
// the reflect judge can't drift when a new agent shape is learned. The
|
|
266
|
+
// extraction-specific INVOCATION (lean flags + tailored timeout/auth errors)
|
|
267
|
+
// deliberately stays here — those messages are part of the `nemus --` UX.
|
|
274
268
|
let intent: ExtractedIntent | undefined;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
intent = parsed.result;
|
|
281
|
-
} else if (parsed.workspaceName || parsed.repos || parsed.remainingIntent !== undefined) {
|
|
282
|
-
// Pi may return the extracted object directly
|
|
283
|
-
intent = parsed;
|
|
269
|
+
try {
|
|
270
|
+
const parsed = parseAgentJson(result) as any;
|
|
271
|
+
if (parsed && typeof parsed === 'object') intent = parsed;
|
|
272
|
+
} catch {
|
|
273
|
+
throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
|
|
284
274
|
}
|
|
285
275
|
|
|
286
276
|
if (!intent) {
|
|
287
|
-
throw new Error(`Could not extract intent from agent response
|
|
277
|
+
throw new Error(`Could not extract intent from agent response: ${result.slice(0, 200)}`);
|
|
288
278
|
}
|
|
289
279
|
|
|
290
280
|
// Coerce/validate field types so malformed model output can't crash the
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { generateCompletion, specsFromProgram, CommandSpec } from './completion';
|
|
4
|
+
|
|
5
|
+
const specs: CommandSpec[] = [
|
|
6
|
+
{ name: 'list', aliases: ['l'], takesWorkspace: false, description: 'List workspaces' },
|
|
7
|
+
{ name: 'status', aliases: ['st'], takesWorkspace: true, description: "Show a repo's git status" },
|
|
8
|
+
{ name: 'doctor', aliases: ['doc'], takesWorkspace: true, description: 'Health checks' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe('generateCompletion — bash', () => {
|
|
12
|
+
const s = generateCompletion('bash', specs);
|
|
13
|
+
it('resets COMPREPLY, lists tokens, and registers both bins', () => {
|
|
14
|
+
expect(s).toContain('COMPREPLY=()'); // guards the stale-completion leak
|
|
15
|
+
expect(s).toContain('local commands="list l status st doctor doc"');
|
|
16
|
+
expect(s).toContain('local ws_commands="status st doctor doc"');
|
|
17
|
+
expect(s).toContain('complete -F _nemus_complete nemus');
|
|
18
|
+
expect(s).toContain('complete -F _nemus_complete nem');
|
|
19
|
+
});
|
|
20
|
+
it('calls back into the invoked bin for workspace names', () => {
|
|
21
|
+
expect(s).toContain('"$bin" completion --workspaces');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('generateCompletion — zsh', () => {
|
|
26
|
+
const s = generateCompletion('zsh', specs);
|
|
27
|
+
it('is an autoloadable #compdef script with the tokens + callback', () => {
|
|
28
|
+
expect(s.startsWith('#compdef nemus nem')).toBe(true);
|
|
29
|
+
expect(s).toContain("_nemus_commands=('list' 'l' 'status' 'st' 'doctor' 'doc')");
|
|
30
|
+
expect(s).toContain('_nemus_ws_commands="status st doctor doc"');
|
|
31
|
+
expect(s).toContain('completion --workspaces');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('generateCompletion — fish', () => {
|
|
36
|
+
const s = generateCompletion('fish', specs);
|
|
37
|
+
it('emits subcommand + workspace completions for both bins with escaped descriptions', () => {
|
|
38
|
+
expect(s).toContain("complete -c nemus -n __fish_use_subcommand -a 'list' -d 'List workspaces'");
|
|
39
|
+
expect(s).toContain("complete -c nem -n __fish_use_subcommand -a 'status'");
|
|
40
|
+
// apostrophe in the description is escaped for fish's single-quoted string
|
|
41
|
+
expect(s).toContain("Show a repo'\\''s git status");
|
|
42
|
+
expect(s).toContain("-n '__fish_seen_subcommand_from status st doctor doc' -a '(nemus completion --workspaces)'");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('specsFromProgram', () => {
|
|
47
|
+
it('detects workspace args + aliases from a commander program', () => {
|
|
48
|
+
const program = new Command();
|
|
49
|
+
program.command('list').alias('l').description('list');
|
|
50
|
+
program.command('status [workspace]').alias('st').description('status');
|
|
51
|
+
program.command('create').description('create');
|
|
52
|
+
|
|
53
|
+
const out = specsFromProgram(program);
|
|
54
|
+
const byName = Object.fromEntries(out.map((s) => [s.name, s]));
|
|
55
|
+
expect(byName.status.takesWorkspace).toBe(true);
|
|
56
|
+
expect(byName.status.aliases).toEqual(['st']);
|
|
57
|
+
expect(byName.list.takesWorkspace).toBe(false);
|
|
58
|
+
expect(byName.create.takesWorkspace).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { listWorkspaces } from '../utils/workspace-meta';
|
|
3
|
+
import { logError } from '../utils/logger';
|
|
4
|
+
|
|
5
|
+
/** Binaries that get completion registered (the CLI's bins). */
|
|
6
|
+
export const COMPLETION_BINS = ['nemus', 'nem'];
|
|
7
|
+
|
|
8
|
+
export type Shell = 'bash' | 'zsh' | 'fish';
|
|
9
|
+
|
|
10
|
+
/** One top-level command, distilled to what a completion script needs. */
|
|
11
|
+
export interface CommandSpec {
|
|
12
|
+
name: string;
|
|
13
|
+
aliases: string[];
|
|
14
|
+
/** True if its first positional argument is a workspace name. */
|
|
15
|
+
takesWorkspace: boolean;
|
|
16
|
+
description: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Every token (name + aliases) that should complete as a subcommand. */
|
|
20
|
+
function allTokens(cmds: CommandSpec[]): string[] {
|
|
21
|
+
return cmds.flatMap((c) => [c.name, ...c.aliases]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Tokens (names + aliases) of the commands that take a workspace argument. */
|
|
25
|
+
function workspaceTokens(cmds: CommandSpec[]): string[] {
|
|
26
|
+
return cmds.filter((c) => c.takesWorkspace).flatMap((c) => [c.name, ...c.aliases]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Escape a description for a fish single-quoted string. */
|
|
30
|
+
function fishDesc(s: string): string {
|
|
31
|
+
return s.replace(/\n/g, ' ').replace(/'/g, "'\\''");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate a shell completion script. Pure (no I/O) so it's unit-tested. The
|
|
36
|
+
* generated script completes subcommands at position 1, and for a subcommand
|
|
37
|
+
* that takes a workspace it completes workspace names by calling back into the
|
|
38
|
+
* CLI: `<bin> completion --workspaces`. Dynamic values stay fresh without
|
|
39
|
+
* regenerating the script.
|
|
40
|
+
*/
|
|
41
|
+
export function generateCompletion(shell: Shell, cmds: CommandSpec[], bins: string[] = COMPLETION_BINS): string {
|
|
42
|
+
const commands = allTokens(cmds).join(' ');
|
|
43
|
+
const wsCommands = workspaceTokens(cmds).join(' ');
|
|
44
|
+
|
|
45
|
+
if (shell === 'bash') {
|
|
46
|
+
return `# nemus bash completion. Install: nemus completion bash > /etc/bash_completion.d/nemus
|
|
47
|
+
# (or: nemus completion bash >> ~/.bashrc)
|
|
48
|
+
_nemus_complete() {
|
|
49
|
+
local cur bin sub
|
|
50
|
+
# bash does not clear COMPREPLY between completions; reset so a stale result
|
|
51
|
+
# from a previous TAB can't leak when we return without setting it.
|
|
52
|
+
COMPREPLY=()
|
|
53
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
54
|
+
bin="\${COMP_WORDS[0]}"
|
|
55
|
+
local commands="${commands}"
|
|
56
|
+
local ws_commands="${wsCommands}"
|
|
57
|
+
if [ "\$COMP_CWORD" -eq 1 ]; then
|
|
58
|
+
COMPREPLY=( \$(compgen -W "\$commands" -- "\$cur") )
|
|
59
|
+
return 0
|
|
60
|
+
fi
|
|
61
|
+
if [ "\$COMP_CWORD" -eq 2 ]; then
|
|
62
|
+
sub="\${COMP_WORDS[1]}"
|
|
63
|
+
if [[ " \$ws_commands " == *" \$sub "* ]]; then
|
|
64
|
+
local names
|
|
65
|
+
names="\$("\$bin" completion --workspaces 2>/dev/null)"
|
|
66
|
+
COMPREPLY=( \$(compgen -W "\$names" -- "\$cur") )
|
|
67
|
+
return 0
|
|
68
|
+
fi
|
|
69
|
+
fi
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
${bins.map((b) => `complete -F _nemus_complete ${b}`).join('\n')}
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (shell === 'zsh') {
|
|
77
|
+
// Autoloaded form: save as a file named `_nemus` on your $fpath.
|
|
78
|
+
return `#compdef ${bins.join(' ')}
|
|
79
|
+
# nemus zsh completion. Install: nemus completion zsh > "\${fpath[1]}/_nemus"
|
|
80
|
+
local -a _nemus_commands
|
|
81
|
+
_nemus_commands=(${allTokens(cmds).map((t) => `'${t}'`).join(' ')})
|
|
82
|
+
local _nemus_ws_commands="${wsCommands}"
|
|
83
|
+
if (( CURRENT == 2 )); then
|
|
84
|
+
compadd -- $_nemus_commands
|
|
85
|
+
return
|
|
86
|
+
fi
|
|
87
|
+
if (( CURRENT == 3 )); then
|
|
88
|
+
local sub=\${words[2]}
|
|
89
|
+
if [[ " $_nemus_ws_commands " == *" $sub "* ]]; then
|
|
90
|
+
local -a _nemus_names
|
|
91
|
+
_nemus_names=(\${(f)"$(\${words[1]} completion --workspaces 2>/dev/null)"})
|
|
92
|
+
compadd -- $_nemus_names
|
|
93
|
+
fi
|
|
94
|
+
fi
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// fish
|
|
99
|
+
const lines: string[] = ['# nemus fish completion. Install: nemus completion fish > ~/.config/fish/completions/nemus.fish'];
|
|
100
|
+
for (const bin of bins) {
|
|
101
|
+
lines.push(`complete -c ${bin} -f`);
|
|
102
|
+
for (const c of cmds) {
|
|
103
|
+
for (const tok of [c.name, ...c.aliases]) {
|
|
104
|
+
lines.push(`complete -c ${bin} -n __fish_use_subcommand -a '${tok}' -d '${fishDesc(c.description)}'`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const wsToks = workspaceTokens(cmds).join(' ');
|
|
108
|
+
if (wsToks) {
|
|
109
|
+
lines.push(
|
|
110
|
+
`complete -c ${bin} -n '__fish_seen_subcommand_from ${wsToks}' -a '(${bin} completion --workspaces)'`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return lines.join('\n') + '\n';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Distill the program's top-level commands into CommandSpecs. */
|
|
118
|
+
export function specsFromProgram(program: Command): CommandSpec[] {
|
|
119
|
+
return program.commands
|
|
120
|
+
.map((c) => {
|
|
121
|
+
const args = (c as any).registeredArguments ?? [];
|
|
122
|
+
const firstArg: string | undefined = args[0]?.name?.();
|
|
123
|
+
return {
|
|
124
|
+
name: c.name(),
|
|
125
|
+
aliases: c.aliases(),
|
|
126
|
+
takesWorkspace: typeof firstArg === 'string' && firstArg.toLowerCase().includes('workspace'),
|
|
127
|
+
description: c.description() ?? '',
|
|
128
|
+
};
|
|
129
|
+
})
|
|
130
|
+
// The completion command itself and any hidden helper needn't clutter, but
|
|
131
|
+
// keeping them is harmless; only drop entries with no name.
|
|
132
|
+
.filter((s) => s.name);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function registerCompletionCommand(program: Command) {
|
|
136
|
+
program
|
|
137
|
+
.command('completion [shell]')
|
|
138
|
+
.description('Output a shell completion script (bash|zsh|fish)')
|
|
139
|
+
.option('--workspaces', 'Print workspace names (used internally by completion scripts)')
|
|
140
|
+
.action(async (shell: string | undefined, opts: { workspaces?: boolean }) => {
|
|
141
|
+
// Data helper the generated scripts call back into.
|
|
142
|
+
if (opts.workspaces) {
|
|
143
|
+
try {
|
|
144
|
+
const workspaces = await listWorkspaces(false);
|
|
145
|
+
for (const ws of workspaces) process.stdout.write(ws.name + '\n');
|
|
146
|
+
} catch {
|
|
147
|
+
// Silent: completion must never error out the user's shell.
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const shells: Shell[] = ['bash', 'zsh', 'fish'];
|
|
153
|
+
if (!shell || !shells.includes(shell as Shell)) {
|
|
154
|
+
logError(`completion: specify a shell — one of ${shells.join(', ')}`);
|
|
155
|
+
logError('e.g. nemus completion bash');
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
process.stdout.write(generateCompletion(shell as Shell, specsFromProgram(program)));
|
|
159
|
+
});
|
|
160
|
+
}
|