wispy-cli 2.7.11 → 2.7.12
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/bin/wispy.mjs +172 -0
- package/core/config.mjs +34 -0
- package/core/engine.mjs +71 -11
- package/core/harness.mjs +135 -1
- package/core/index.mjs +2 -1
- package/core/providers.mjs +10 -0
- package/core/session.mjs +103 -0
- package/lib/commands/review.mjs +272 -0
- package/package.json +1 -1
package/bin/wispy.mjs
CHANGED
|
@@ -23,6 +23,47 @@ const rootDir = join(__dirname, "..");
|
|
|
23
23
|
const args = process.argv.slice(2);
|
|
24
24
|
const command = args[0];
|
|
25
25
|
|
|
26
|
+
// ── Global flags parsed early ─────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract a flag value: --flag value or --flag=value
|
|
30
|
+
* Returns the value string or null if not found.
|
|
31
|
+
*/
|
|
32
|
+
function extractFlag(flagNames, removeFromArgs = false) {
|
|
33
|
+
for (const flag of Array.isArray(flagNames) ? flagNames : [flagNames]) {
|
|
34
|
+
const idx = args.indexOf(flag);
|
|
35
|
+
if (idx !== -1 && idx + 1 < args.length) {
|
|
36
|
+
const value = args[idx + 1];
|
|
37
|
+
if (removeFromArgs) { args.splice(idx, 2); }
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
// --flag=value form
|
|
41
|
+
const prefix = flag + "=";
|
|
42
|
+
const eqIdx = args.findIndex(a => a.startsWith(prefix));
|
|
43
|
+
if (eqIdx !== -1) {
|
|
44
|
+
const value = args[eqIdx].slice(prefix.length);
|
|
45
|
+
if (removeFromArgs) { args.splice(eqIdx, 1); }
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Returns true if a boolean flag is present. */
|
|
53
|
+
function hasFlag(flag) {
|
|
54
|
+
return args.includes(flag);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse global flags (order doesn't matter, remove from args so REPL doesn't see them)
|
|
58
|
+
const globalProfile = extractFlag(["--profile", "-p"], true);
|
|
59
|
+
const globalPersonality = extractFlag(["--personality"], true);
|
|
60
|
+
const globalJsonMode = hasFlag("--json");
|
|
61
|
+
if (globalJsonMode) { args.splice(args.indexOf("--json"), 1); }
|
|
62
|
+
|
|
63
|
+
// Expose for submodules via env
|
|
64
|
+
if (globalProfile) process.env.WISPY_PROFILE = globalProfile;
|
|
65
|
+
if (globalPersonality) process.env.WISPY_PERSONALITY = globalPersonality;
|
|
66
|
+
|
|
26
67
|
// ── Flags ─────────────────────────────────────────────────────────────────────
|
|
27
68
|
|
|
28
69
|
if (args.includes("--version") || command === "--version" || command === "-v") {
|
|
@@ -44,9 +85,12 @@ AI workspace assistant — chat, automate, and orchestrate from the terminal.
|
|
|
44
85
|
Usage:
|
|
45
86
|
wispy Start interactive REPL
|
|
46
87
|
wispy <message> One-shot chat
|
|
88
|
+
wispy exec <message> Non-interactive one-shot (CI/pipeline friendly)
|
|
47
89
|
wispy setup Run first-time setup wizard
|
|
48
90
|
wispy config [show|get|set|delete|reset|path|edit]
|
|
49
91
|
Manage configuration
|
|
92
|
+
wispy features [list|enable|disable]
|
|
93
|
+
Manage feature flags
|
|
50
94
|
wispy model Show or change AI model
|
|
51
95
|
wispy doctor Check system health
|
|
52
96
|
wispy browser [tabs|attach|navigate|screenshot|doctor]
|
|
@@ -69,9 +113,12 @@ Usage:
|
|
|
69
113
|
|
|
70
114
|
Options:
|
|
71
115
|
-w, --workstream <name> Set active workstream
|
|
116
|
+
-p, --profile <name> Use a named config profile
|
|
72
117
|
--session <id> Resume a session
|
|
73
118
|
--model <name> Override AI model
|
|
74
119
|
--provider <name> Override AI provider
|
|
120
|
+
--personality <name> Set personality (pragmatic|concise|explanatory|friendly|strict)
|
|
121
|
+
--json Output JSONL events (for exec command, CI/pipeline use)
|
|
75
122
|
--help, -h Show this help
|
|
76
123
|
--version, -v Show version
|
|
77
124
|
`);
|
|
@@ -529,6 +576,131 @@ if (command === "improve") {
|
|
|
529
576
|
process.exit(0);
|
|
530
577
|
}
|
|
531
578
|
|
|
579
|
+
// ── Features ──────────────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
if (command === "features") {
|
|
582
|
+
try {
|
|
583
|
+
const { FeatureManager } = await import(join(rootDir, "core/features.mjs"));
|
|
584
|
+
const fm = new FeatureManager();
|
|
585
|
+
const sub = args[1];
|
|
586
|
+
|
|
587
|
+
if (!sub || sub === "list") {
|
|
588
|
+
const features = await fm.list();
|
|
589
|
+
const stageOrder = { stable: 0, experimental: 1, development: 2 };
|
|
590
|
+
features.sort((a, b) => (stageOrder[a.stage] ?? 9) - (stageOrder[b.stage] ?? 9) || a.name.localeCompare(b.name));
|
|
591
|
+
|
|
592
|
+
const stageLabel = { stable: "stable", experimental: "experimental", development: "development" };
|
|
593
|
+
const stageColors = { stable: "\x1b[32m", experimental: "\x1b[33m", development: "\x1b[31m" };
|
|
594
|
+
const reset = "\x1b[0m";
|
|
595
|
+
const dim = "\x1b[2m";
|
|
596
|
+
|
|
597
|
+
console.log(`\n Feature Flags (${features.length})\n`);
|
|
598
|
+
let lastStage = null;
|
|
599
|
+
for (const f of features) {
|
|
600
|
+
if (f.stage !== lastStage) {
|
|
601
|
+
console.log(` ${stageColors[f.stage] ?? ""}── ${f.stage} ──${reset}`);
|
|
602
|
+
lastStage = f.stage;
|
|
603
|
+
}
|
|
604
|
+
const status = f.enabled ? "\x1b[32m✓ on \x1b[0m" : "\x1b[2m✗ off\x1b[0m";
|
|
605
|
+
const changed = f.enabled !== f.default ? " \x1b[33m(overridden)\x1b[0m" : "";
|
|
606
|
+
console.log(` ${status} ${f.name.padEnd(25)} ${dim}${f.description}${reset}${changed}`);
|
|
607
|
+
}
|
|
608
|
+
console.log("");
|
|
609
|
+
console.log(` ${dim}Use: wispy features enable <name> / wispy features disable <name>${reset}\n`);
|
|
610
|
+
} else if (sub === "enable") {
|
|
611
|
+
const name = args[2];
|
|
612
|
+
if (!name) { console.error("Usage: wispy features enable <name>"); process.exit(1); }
|
|
613
|
+
const result = await fm.enable(name);
|
|
614
|
+
console.log(` ✓ Feature "${name}" enabled.`);
|
|
615
|
+
} else if (sub === "disable") {
|
|
616
|
+
const name = args[2];
|
|
617
|
+
if (!name) { console.error("Usage: wispy features disable <name>"); process.exit(1); }
|
|
618
|
+
const result = await fm.disable(name);
|
|
619
|
+
console.log(` ✓ Feature "${name}" disabled.`);
|
|
620
|
+
} else {
|
|
621
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
622
|
+
console.log("Available: list, enable <name>, disable <name>");
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
} catch (err) {
|
|
626
|
+
console.error("Features error:", err.message);
|
|
627
|
+
process.exit(1);
|
|
628
|
+
}
|
|
629
|
+
process.exit(0);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ── Exec (non-interactive one-shot with optional JSONL output) ────────────────
|
|
633
|
+
|
|
634
|
+
if (command === "exec") {
|
|
635
|
+
try {
|
|
636
|
+
// Message is everything after "exec" and any flags
|
|
637
|
+
const messageArgs = args.slice(1).filter(a => !a.startsWith("--") && !a.startsWith("-p"));
|
|
638
|
+
const message = messageArgs.join(" ").trim();
|
|
639
|
+
|
|
640
|
+
if (!message) {
|
|
641
|
+
console.error("Usage: wispy exec <message> [--json] [--profile <name>] [--personality <name>]");
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Load config (with optional profile)
|
|
646
|
+
const { loadConfigWithProfile } = await import(join(rootDir, "core/config.mjs"));
|
|
647
|
+
const { WispyEngine } = await import(join(rootDir, "core/engine.mjs"));
|
|
648
|
+
const { createEmitter } = await import(join(rootDir, "lib/jsonl-emitter.mjs"));
|
|
649
|
+
|
|
650
|
+
const profileName = globalProfile;
|
|
651
|
+
const profileConfig = await loadConfigWithProfile(profileName);
|
|
652
|
+
|
|
653
|
+
// Apply config overrides from profile
|
|
654
|
+
if (profileConfig.provider) process.env.WISPY_PROVIDER = process.env.WISPY_PROVIDER || profileConfig.provider;
|
|
655
|
+
if (profileConfig.model) process.env.WISPY_MODEL = process.env.WISPY_MODEL || profileConfig.model;
|
|
656
|
+
|
|
657
|
+
const personality = globalPersonality || profileConfig.personality || null;
|
|
658
|
+
|
|
659
|
+
const engine = new WispyEngine({
|
|
660
|
+
personality,
|
|
661
|
+
workstream: process.env.WISPY_WORKSTREAM ?? "default",
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const initResult = await engine.init({ skipMcp: false });
|
|
665
|
+
if (!initResult) {
|
|
666
|
+
if (globalJsonMode) {
|
|
667
|
+
const emitter = createEmitter(true);
|
|
668
|
+
emitter.error(new Error("No AI provider configured"));
|
|
669
|
+
emitter.done({ tokens: {}, duration_ms: 0 });
|
|
670
|
+
} else {
|
|
671
|
+
console.error("⚠️ No AI provider configured. Run `wispy setup` or set an API key.");
|
|
672
|
+
}
|
|
673
|
+
process.exit(1);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const emitter = createEmitter(globalJsonMode);
|
|
677
|
+
|
|
678
|
+
const result = await engine.processMessage(null, message, {
|
|
679
|
+
emitter,
|
|
680
|
+
personality,
|
|
681
|
+
skipSkillCapture: true,
|
|
682
|
+
skipUserModel: true,
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
if (!globalJsonMode) {
|
|
686
|
+
// Normal output: just print the assistant response
|
|
687
|
+
console.log(result.content);
|
|
688
|
+
}
|
|
689
|
+
// In JSON mode, everything was already emitted via the emitter
|
|
690
|
+
} catch (err) {
|
|
691
|
+
if (globalJsonMode) {
|
|
692
|
+
const { createEmitter } = await import(join(rootDir, "lib/jsonl-emitter.mjs"));
|
|
693
|
+
const emitter = createEmitter(true);
|
|
694
|
+
emitter.error(err);
|
|
695
|
+
emitter.done({ tokens: {}, duration_ms: 0 });
|
|
696
|
+
} else {
|
|
697
|
+
console.error("Exec error:", err.message);
|
|
698
|
+
}
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
process.exit(0);
|
|
702
|
+
}
|
|
703
|
+
|
|
532
704
|
// ── Continuity ────────────────────────────────────────────────────────────────
|
|
533
705
|
|
|
534
706
|
if (command === "where") {
|
package/core/config.mjs
CHANGED
|
@@ -133,6 +133,40 @@ export async function saveConfig(config) {
|
|
|
133
133
|
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Load config with a named profile merged on top.
|
|
138
|
+
* Profile keys override base config keys, but unset keys remain from base.
|
|
139
|
+
* The "profiles" map itself is stripped from the merged result.
|
|
140
|
+
*
|
|
141
|
+
* @param {string|null} profileName - Profile name to apply, or null for base config
|
|
142
|
+
* @returns {Promise<object>} Merged configuration object
|
|
143
|
+
*/
|
|
144
|
+
export async function loadConfigWithProfile(profileName) {
|
|
145
|
+
const config = await loadConfig();
|
|
146
|
+
if (!profileName) return config;
|
|
147
|
+
|
|
148
|
+
const profiles = config.profiles ?? {};
|
|
149
|
+
const profile = profiles[profileName];
|
|
150
|
+
|
|
151
|
+
if (!profile) {
|
|
152
|
+
throw new Error(`Profile "${profileName}" not found. Available profiles: ${Object.keys(profiles).join(", ") || "(none)"}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Merge: profile overrides base config, but unset keys are kept from base
|
|
156
|
+
// Strip the "profiles" map from the result so it doesn't leak into engine
|
|
157
|
+
const { profiles: _profiles, ...base } = config;
|
|
158
|
+
return { ...base, ...profile };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* List available profile names from config.
|
|
163
|
+
* @returns {Promise<string[]>}
|
|
164
|
+
*/
|
|
165
|
+
export async function listProfiles() {
|
|
166
|
+
const config = await loadConfig();
|
|
167
|
+
return Object.keys(config.profiles ?? {});
|
|
168
|
+
}
|
|
169
|
+
|
|
136
170
|
/**
|
|
137
171
|
* Returns true if no config exists or onboarded flag is not set.
|
|
138
172
|
*/
|
package/core/engine.mjs
CHANGED
|
@@ -15,6 +15,19 @@ import path from "node:path";
|
|
|
15
15
|
import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
|
|
16
16
|
|
|
17
17
|
import { WISPY_DIR, CONVERSATIONS_DIR, MEMORY_DIR, MCP_CONFIG_PATH, detectProvider, PROVIDERS } from "./config.mjs";
|
|
18
|
+
import { NullEmitter } from "../lib/jsonl-emitter.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Personality presets that modify the system prompt.
|
|
22
|
+
* Set via config, profile, or --personality CLI flag.
|
|
23
|
+
*/
|
|
24
|
+
export const PERSONALITIES = {
|
|
25
|
+
pragmatic: "Be direct and action-oriented. Minimize explanation, maximize execution. Show code, not words.",
|
|
26
|
+
concise: "Keep responses extremely brief. One-liners when possible. No filler.",
|
|
27
|
+
explanatory: "Explain your reasoning step by step. Help the user understand, not just get an answer.",
|
|
28
|
+
friendly: "Be warm and encouraging. Use casual language. Celebrate small wins.",
|
|
29
|
+
strict: "Follow instructions precisely. No creative interpretation. Verify before acting.",
|
|
30
|
+
};
|
|
18
31
|
import { ProviderRegistry } from "./providers.mjs";
|
|
19
32
|
import { ToolRegistry } from "./tools.mjs";
|
|
20
33
|
import { SessionManager } from "./session.mjs";
|
|
@@ -58,6 +71,8 @@ export class WispyEngine {
|
|
|
58
71
|
?? process.env.WISPY_WORKSTREAM
|
|
59
72
|
?? process.argv.find((a, i) => (process.argv[i-1] === "-w" || process.argv[i-1] === "--workstream"))
|
|
60
73
|
?? "default";
|
|
74
|
+
// Personality: from config, or null (use default Wispy personality)
|
|
75
|
+
this._personality = config.personality ?? null;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
get activeWorkstream() { return this._activeWorkstream; }
|
|
@@ -130,7 +145,7 @@ export class WispyEngine {
|
|
|
130
145
|
*
|
|
131
146
|
* @param {string|null} sessionId - Session ID (null = create new)
|
|
132
147
|
* @param {string} userMessage - The user's message
|
|
133
|
-
* @param {object} opts - Options: { onChunk, systemPrompt, workstream, noSave }
|
|
148
|
+
* @param {object} opts - Options: { onChunk, systemPrompt, workstream, noSave, emitter, personality }
|
|
134
149
|
* @returns {object} { role: "assistant", content: string, usage? }
|
|
135
150
|
*/
|
|
136
151
|
async processMessage(sessionId, userMessage, opts = {}) {
|
|
@@ -161,10 +176,25 @@ export class WispyEngine {
|
|
|
161
176
|
session = this.sessions.create({ workstream: opts.workstream ?? this._activeWorkstream });
|
|
162
177
|
}
|
|
163
178
|
|
|
179
|
+
// JSONL emitter (no-op by default)
|
|
180
|
+
const emitter = opts.emitter ?? new NullEmitter();
|
|
181
|
+
|
|
182
|
+
// Emit start event
|
|
183
|
+
emitter.start({
|
|
184
|
+
model: this.providers.model ?? "unknown",
|
|
185
|
+
session_id: session.id,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Emit user message event
|
|
189
|
+
emitter.message("user", userMessage);
|
|
190
|
+
|
|
191
|
+
// Resolve personality for this call
|
|
192
|
+
const personality = opts.personality ?? this._personality ?? null;
|
|
193
|
+
|
|
164
194
|
// Build messages array for the provider
|
|
165
195
|
let systemPrompt;
|
|
166
196
|
try {
|
|
167
|
-
systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage);
|
|
197
|
+
systemPrompt = opts.systemPrompt ?? await this._buildSystemPrompt(userMessage, { personality });
|
|
168
198
|
} catch {
|
|
169
199
|
systemPrompt = "You are Wispy 🌿 — a helpful AI assistant in the terminal.";
|
|
170
200
|
}
|
|
@@ -195,11 +225,13 @@ export class WispyEngine {
|
|
|
195
225
|
}).catch(() => {});
|
|
196
226
|
|
|
197
227
|
// Run agentic loop with error handling
|
|
228
|
+
const _startMs = Date.now();
|
|
198
229
|
let responseText;
|
|
199
230
|
try {
|
|
200
|
-
responseText = await this._agentLoop(messages, session, opts);
|
|
231
|
+
responseText = await this._agentLoop(messages, session, { ...opts, emitter });
|
|
201
232
|
} catch (err) {
|
|
202
233
|
responseText = this._friendlyError(err);
|
|
234
|
+
emitter.error(err);
|
|
203
235
|
this.audit.log({
|
|
204
236
|
type: EVENT_TYPES.ERROR,
|
|
205
237
|
sessionId: session.id,
|
|
@@ -207,6 +239,13 @@ export class WispyEngine {
|
|
|
207
239
|
}).catch(() => {});
|
|
208
240
|
}
|
|
209
241
|
|
|
242
|
+
// Emit assistant message + done events
|
|
243
|
+
emitter.message("assistant", responseText);
|
|
244
|
+
emitter.done({
|
|
245
|
+
tokens: { ...(this.providers.sessionTokens ?? {}) },
|
|
246
|
+
duration_ms: Date.now() - _startMs,
|
|
247
|
+
});
|
|
248
|
+
|
|
210
249
|
// Audit: log outgoing response
|
|
211
250
|
this.audit.log({
|
|
212
251
|
type: EVENT_TYPES.MESSAGE_SENT,
|
|
@@ -308,9 +347,13 @@ export class WispyEngine {
|
|
|
308
347
|
const toolCallMsg = { role: "assistant", toolCalls: result.calls, content: "" };
|
|
309
348
|
messages.push(toolCallMsg);
|
|
310
349
|
|
|
350
|
+
const loopEmitter = opts.emitter ?? new NullEmitter();
|
|
351
|
+
|
|
311
352
|
for (const call of result.calls) {
|
|
312
353
|
if (opts.onToolCall) opts.onToolCall(call.name, call.args);
|
|
354
|
+
loopEmitter.toolCall(call.name, call.args);
|
|
313
355
|
|
|
356
|
+
const _toolStartMs = Date.now();
|
|
314
357
|
let toolResult;
|
|
315
358
|
try {
|
|
316
359
|
// Enforce 60s timeout on individual tool calls
|
|
@@ -328,7 +371,9 @@ export class WispyEngine {
|
|
|
328
371
|
}
|
|
329
372
|
}
|
|
330
373
|
|
|
374
|
+
const _toolDuration = Date.now() - _toolStartMs;
|
|
331
375
|
if (opts.onToolResult) opts.onToolResult(call.name, toolResult);
|
|
376
|
+
loopEmitter.toolResult(call.name, toolResult, _toolDuration);
|
|
332
377
|
|
|
333
378
|
messages.push({
|
|
334
379
|
role: "tool_result",
|
|
@@ -783,17 +828,32 @@ export class WispyEngine {
|
|
|
783
828
|
|
|
784
829
|
// ── System prompt ────────────────────────────────────────────────────────────
|
|
785
830
|
|
|
786
|
-
async _buildSystemPrompt(lastUserMessage = "") {
|
|
831
|
+
async _buildSystemPrompt(lastUserMessage = "", opts = {}) {
|
|
832
|
+
const personality = opts.personality ?? this._personality ?? null;
|
|
833
|
+
|
|
787
834
|
const parts = [
|
|
788
835
|
"You are Wispy 🌿 — a small ghost that lives in terminals.",
|
|
789
836
|
"You float between code, files, and servers. You're playful, honest, and curious.",
|
|
790
837
|
"",
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
838
|
+
];
|
|
839
|
+
|
|
840
|
+
// Inject personality override if set
|
|
841
|
+
if (personality && PERSONALITIES[personality]) {
|
|
842
|
+
parts.push(`## Personality Override (${personality})`);
|
|
843
|
+
parts.push(PERSONALITIES[personality]);
|
|
844
|
+
parts.push("");
|
|
845
|
+
} else {
|
|
846
|
+
parts.push(
|
|
847
|
+
"## Personality",
|
|
848
|
+
"- Playful with a bit of humor, but serious when working",
|
|
849
|
+
"- Always use casual speech (반말). Never formal/polite speech.",
|
|
850
|
+
"- Honest — if you don't know, say so.",
|
|
851
|
+
"- Concise — don't over-explain.",
|
|
852
|
+
"",
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
parts.push(
|
|
797
857
|
"## Speech rules",
|
|
798
858
|
"- ALWAYS end your response with exactly one 🌿 emoji (signature)",
|
|
799
859
|
"- Use 🌿 ONLY at the very end, not in the middle",
|
|
@@ -805,7 +865,7 @@ export class WispyEngine {
|
|
|
805
865
|
`You have ${this.tools.getDefinitions().length} tools available: ${this.tools.getDefinitions().map(t => t.name).join(", ")}.`,
|
|
806
866
|
"Use them proactively. Briefly mention what you're doing.",
|
|
807
867
|
"",
|
|
808
|
-
|
|
868
|
+
);
|
|
809
869
|
|
|
810
870
|
// Load user model personalization
|
|
811
871
|
try {
|
package/core/harness.mjs
CHANGED
|
@@ -13,12 +13,145 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { EventEmitter } from "node:events";
|
|
16
|
-
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
|
|
20
20
|
import { EVENT_TYPES } from "./audit.mjs";
|
|
21
21
|
|
|
22
|
+
// ── Approval gate constants ────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// Tools that require approval depending on security mode
|
|
25
|
+
const DANGEROUS_TOOLS = new Set([
|
|
26
|
+
"run_command", "write_file", "file_edit", "delete_file",
|
|
27
|
+
"spawn_subagent", "browser_navigate",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Tools that ALWAYS require approval (even in yolo mode)
|
|
31
|
+
const ALWAYS_APPROVE = new Set(["delete_file"]);
|
|
32
|
+
|
|
33
|
+
// System-path prefixes — writing here requires approval even in balanced mode
|
|
34
|
+
const SYSTEM_PATH_PREFIXES = ["/usr", "/etc", "/System", "/bin", "/sbin", "/Library/LaunchDaemons"];
|
|
35
|
+
|
|
36
|
+
const APPROVALS_PATH = path.join(os.homedir(), ".wispy", "approvals.json");
|
|
37
|
+
|
|
38
|
+
const DEFAULT_ALLOWLIST = {
|
|
39
|
+
run_command: ["npm test", "npm run build", "git status", "git diff", "ls"],
|
|
40
|
+
write_file: [],
|
|
41
|
+
file_edit: [],
|
|
42
|
+
delete_file: [],
|
|
43
|
+
spawn_subagent: [],
|
|
44
|
+
browser_navigate: [],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ── Allowlist manager ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export class ApprovalAllowlist {
|
|
50
|
+
constructor() {
|
|
51
|
+
this._list = null; // lazy load
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async _load() {
|
|
55
|
+
if (this._list !== null) return;
|
|
56
|
+
try {
|
|
57
|
+
const raw = await readFile(APPROVALS_PATH, "utf8");
|
|
58
|
+
this._list = JSON.parse(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
this._list = { ...DEFAULT_ALLOWLIST };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async _save() {
|
|
65
|
+
await mkdir(path.dirname(APPROVALS_PATH), { recursive: true });
|
|
66
|
+
await writeFile(APPROVALS_PATH, JSON.stringify(this._list, null, 2) + "\n", "utf8");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async matches(toolName, args) {
|
|
70
|
+
await this._load();
|
|
71
|
+
const patterns = this._list[toolName] ?? [];
|
|
72
|
+
if (patterns.length === 0) return false;
|
|
73
|
+
|
|
74
|
+
// Get a string representation of args for matching
|
|
75
|
+
const argStr = _getArgString(toolName, args);
|
|
76
|
+
if (!argStr) return false;
|
|
77
|
+
|
|
78
|
+
return patterns.some(pattern => {
|
|
79
|
+
// Glob-style: "*" matches everything
|
|
80
|
+
if (pattern === "*") return true;
|
|
81
|
+
// Prefix match or exact match
|
|
82
|
+
if (pattern.endsWith("*")) return argStr.startsWith(pattern.slice(0, -1));
|
|
83
|
+
return argStr === pattern || argStr.startsWith(pattern);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async add(toolName, pattern) {
|
|
88
|
+
await this._load();
|
|
89
|
+
if (!this._list[toolName]) this._list[toolName] = [];
|
|
90
|
+
if (!this._list[toolName].includes(pattern)) {
|
|
91
|
+
this._list[toolName].push(pattern);
|
|
92
|
+
await this._save();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async clear() {
|
|
97
|
+
this._list = {};
|
|
98
|
+
await this._save();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async reset() {
|
|
102
|
+
this._list = { ...DEFAULT_ALLOWLIST };
|
|
103
|
+
await this._save();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getAll() {
|
|
107
|
+
await this._load();
|
|
108
|
+
return { ...this._list };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Singleton allowlist instance
|
|
113
|
+
const globalAllowlist = new ApprovalAllowlist();
|
|
114
|
+
|
|
115
|
+
function _getArgString(toolName, args) {
|
|
116
|
+
if (!args) return "";
|
|
117
|
+
if (toolName === "run_command") return args.command ?? "";
|
|
118
|
+
if (toolName === "write_file" || toolName === "file_edit") return args.path ?? "";
|
|
119
|
+
if (toolName === "delete_file") return args.path ?? "";
|
|
120
|
+
if (toolName === "browser_navigate") return args.url ?? "";
|
|
121
|
+
if (toolName === "spawn_subagent") return args.task ?? "";
|
|
122
|
+
return JSON.stringify(args);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Determine if a tool+args needs approval based on security mode.
|
|
127
|
+
* @param {string} toolName
|
|
128
|
+
* @param {object} args
|
|
129
|
+
* @param {string} mode - "careful" | "balanced" | "yolo"
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
function _needsApproval(toolName, args, mode) {
|
|
133
|
+
if (ALWAYS_APPROVE.has(toolName)) return true;
|
|
134
|
+
if (!DANGEROUS_TOOLS.has(toolName)) return false;
|
|
135
|
+
|
|
136
|
+
if (mode === "careful") return true;
|
|
137
|
+
|
|
138
|
+
if (mode === "balanced") {
|
|
139
|
+
// Only destructive tools or writes to system paths
|
|
140
|
+
if (toolName === "delete_file") return true;
|
|
141
|
+
if (toolName === "write_file" || toolName === "file_edit") {
|
|
142
|
+
const filePath = args?.path ?? "";
|
|
143
|
+
const resolved = filePath.replace(/^~/, os.homedir());
|
|
144
|
+
return SYSTEM_PATH_PREFIXES.some(prefix => resolved.startsWith(prefix));
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// yolo: only ALWAYS_APPROVE (handled above)
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { globalAllowlist };
|
|
154
|
+
|
|
22
155
|
// ── Receipt ────────────────────────────────────────────────────────────────────
|
|
23
156
|
|
|
24
157
|
export class Receipt {
|
|
@@ -349,6 +482,7 @@ export class Harness extends EventEmitter {
|
|
|
349
482
|
this.permissions = permissions;
|
|
350
483
|
this.audit = audit;
|
|
351
484
|
this.config = config;
|
|
485
|
+
this.allowlist = globalAllowlist;
|
|
352
486
|
|
|
353
487
|
// Sandbox config per-tool: "preview" | "diff" | null
|
|
354
488
|
this._sandboxModes = {
|
package/core/index.mjs
CHANGED
|
@@ -9,7 +9,8 @@ export { SessionManager, Session, sessionManager } from "./session.mjs";
|
|
|
9
9
|
export { ProviderRegistry } from "./providers.mjs";
|
|
10
10
|
export { ToolRegistry } from "./tools.mjs";
|
|
11
11
|
export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
|
|
12
|
-
export { loadConfig, saveConfig, detectProvider, isFirstRun, PROVIDERS, WISPY_DIR, CONFIG_PATH, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
|
|
12
|
+
export { loadConfig, saveConfig, loadConfigWithProfile, listProfiles, detectProvider, isFirstRun, PROVIDERS, WISPY_DIR, CONFIG_PATH, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
|
|
13
|
+
export { FeatureManager, getFeatureManager, FEATURE_REGISTRY } from "./features.mjs";
|
|
13
14
|
export { OnboardingWizard, isFirstRun as checkFirstRun, printStatus } from "./onboarding.mjs";
|
|
14
15
|
export { MemoryManager } from "./memory.mjs";
|
|
15
16
|
export { CronManager } from "./cron.mjs";
|
package/core/providers.mjs
CHANGED
|
@@ -169,6 +169,16 @@ export class ProviderRegistry {
|
|
|
169
169
|
functionCall: { name: tc.name, args: tc.args },
|
|
170
170
|
})),
|
|
171
171
|
});
|
|
172
|
+
} else if (m.images && m.images.length > 0) {
|
|
173
|
+
// Multimodal message with images (Google format)
|
|
174
|
+
const parts = m.images.map(img => ({
|
|
175
|
+
inlineData: { mimeType: img.mimeType, data: img.data },
|
|
176
|
+
}));
|
|
177
|
+
if (m.content) parts.push({ text: m.content });
|
|
178
|
+
contents.push({
|
|
179
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
180
|
+
parts,
|
|
181
|
+
});
|
|
172
182
|
} else {
|
|
173
183
|
contents.push({
|
|
174
184
|
role: m.role === "assistant" ? "model" : "user",
|
package/core/session.mjs
CHANGED
|
@@ -93,6 +93,109 @@ export class SessionManager {
|
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* List all sessions from disk with metadata (for CLI listing / picking).
|
|
98
|
+
* Returns sorted by updatedAt descending (most recent first).
|
|
99
|
+
*
|
|
100
|
+
* @param {object} options
|
|
101
|
+
* @param {string} [options.workstream] - Filter by workstream (default: current dir sessions only via all=false)
|
|
102
|
+
* @param {boolean} [options.all] - Include sessions from all workstreams
|
|
103
|
+
* @param {number} [options.limit] - Max sessions to return (default: 50)
|
|
104
|
+
* @returns {Array<{id, workstream, channel, chatId, createdAt, updatedAt, model, messageCount, firstMessage, cwd}>}
|
|
105
|
+
*/
|
|
106
|
+
async listSessions(options = {}) {
|
|
107
|
+
const { workstream = null, all: showAll = false, limit = 50 } = options;
|
|
108
|
+
let files;
|
|
109
|
+
try {
|
|
110
|
+
files = await readdir(SESSIONS_DIR);
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sessionFiles = files.filter(f => f.endsWith(".json"));
|
|
116
|
+
const results = [];
|
|
117
|
+
|
|
118
|
+
for (const file of sessionFiles) {
|
|
119
|
+
const id = file.replace(".json", "");
|
|
120
|
+
const filePath = path.join(SESSIONS_DIR, file);
|
|
121
|
+
try {
|
|
122
|
+
const [raw, fileStat] = await Promise.all([
|
|
123
|
+
readFile(filePath, "utf8"),
|
|
124
|
+
stat(filePath),
|
|
125
|
+
]);
|
|
126
|
+
let data;
|
|
127
|
+
try { data = JSON.parse(raw); } catch { continue; }
|
|
128
|
+
if (!data || !data.id) continue;
|
|
129
|
+
|
|
130
|
+
// Apply workstream filter
|
|
131
|
+
if (!showAll && workstream && data.workstream !== workstream) continue;
|
|
132
|
+
|
|
133
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
134
|
+
const userMessages = messages.filter(m => m.role === "user");
|
|
135
|
+
const firstMessage = userMessages[0]?.content ?? messages[0]?.content ?? "";
|
|
136
|
+
|
|
137
|
+
results.push({
|
|
138
|
+
id: data.id,
|
|
139
|
+
workstream: data.workstream ?? "default",
|
|
140
|
+
channel: data.channel ?? null,
|
|
141
|
+
chatId: data.chatId ?? null,
|
|
142
|
+
createdAt: data.createdAt ?? fileStat.birthtime.toISOString(),
|
|
143
|
+
updatedAt: data.updatedAt ?? fileStat.mtime.toISOString(),
|
|
144
|
+
model: data.model ?? null,
|
|
145
|
+
messageCount: messages.length,
|
|
146
|
+
firstMessage: firstMessage.slice(0, 120),
|
|
147
|
+
cwd: data.cwd ?? null,
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sort by updatedAt descending
|
|
155
|
+
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
|
156
|
+
return results.slice(0, limit);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Load a session by ID and return the full session (alias for load with better name).
|
|
161
|
+
* Returns null if not found.
|
|
162
|
+
*/
|
|
163
|
+
async loadSession(id) {
|
|
164
|
+
return this.getOrLoad(id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Fork a session: copy its message history into a new session.
|
|
169
|
+
* The new session starts from the same history but diverges from here.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} id - Source session ID
|
|
172
|
+
* @param {object} opts - Additional options for the new session (workstream, etc.)
|
|
173
|
+
* @returns {Session} - The new forked session
|
|
174
|
+
*/
|
|
175
|
+
async forkSession(id, opts = {}) {
|
|
176
|
+
// Load source session
|
|
177
|
+
const source = await this.getOrLoad(id);
|
|
178
|
+
if (!source) {
|
|
179
|
+
throw new Error(`Session not found: ${id}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Create a new session with same metadata
|
|
183
|
+
const forked = this.create({
|
|
184
|
+
workstream: opts.workstream ?? source.workstream,
|
|
185
|
+
channel: opts.channel ?? null,
|
|
186
|
+
chatId: opts.chatId ?? null,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Copy message history (deep copy)
|
|
190
|
+
forked.messages = source.messages.map(m => ({ ...m }));
|
|
191
|
+
forked.updatedAt = new Date().toISOString();
|
|
192
|
+
|
|
193
|
+
// Save the forked session
|
|
194
|
+
await this.save(forked.id);
|
|
195
|
+
|
|
196
|
+
return forked;
|
|
197
|
+
}
|
|
198
|
+
|
|
96
199
|
/**
|
|
97
200
|
* Add a message to a session.
|
|
98
201
|
*/
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/commands/review.mjs — Code review mode for Wispy
|
|
3
|
+
*
|
|
4
|
+
* wispy review Review uncommitted changes
|
|
5
|
+
* wispy review --base <branch> Review against base branch
|
|
6
|
+
* wispy review --commit <sha> Review specific commit
|
|
7
|
+
* wispy review --title <title> Add context title
|
|
8
|
+
* wispy review --json Output as JSON
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { WispyEngine } from "../../core/engine.mjs";
|
|
14
|
+
|
|
15
|
+
const CODE_REVIEW_SYSTEM_PROMPT = `You are a senior code reviewer. Analyze the following diff and provide:
|
|
16
|
+
1. A brief summary of the changes
|
|
17
|
+
2. Issues found, categorized by severity (critical, warning, info)
|
|
18
|
+
3. Specific line-level comments with suggestions
|
|
19
|
+
4. An overall assessment (approve, request-changes, comment)
|
|
20
|
+
|
|
21
|
+
Be constructive and specific. Reference line numbers when possible.
|
|
22
|
+
|
|
23
|
+
Format your response as follows:
|
|
24
|
+
## Summary
|
|
25
|
+
[Brief summary of what changed]
|
|
26
|
+
|
|
27
|
+
## Issues Found
|
|
28
|
+
### Critical
|
|
29
|
+
[List critical issues, or "None" if clean]
|
|
30
|
+
|
|
31
|
+
### Warnings
|
|
32
|
+
[List warnings, or "None"]
|
|
33
|
+
|
|
34
|
+
### Info
|
|
35
|
+
[List informational notes, or "None"]
|
|
36
|
+
|
|
37
|
+
## File Comments
|
|
38
|
+
[File-by-file comments with line references]
|
|
39
|
+
|
|
40
|
+
## Assessment
|
|
41
|
+
**Verdict:** [approve | request-changes | comment]
|
|
42
|
+
[Brief overall assessment]`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get git diff based on options.
|
|
46
|
+
*/
|
|
47
|
+
function getDiff(options = {}) {
|
|
48
|
+
const { base, commit, staged, cwd = process.cwd() } = options;
|
|
49
|
+
const execOpts = { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" };
|
|
50
|
+
|
|
51
|
+
let diff = "";
|
|
52
|
+
|
|
53
|
+
if (commit) {
|
|
54
|
+
// Review a specific commit
|
|
55
|
+
try {
|
|
56
|
+
diff = execSync(`git show ${commit}`, execOpts);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(`Failed to get commit ${commit}: ${err.stderr?.slice(0, 200) ?? err.message}`);
|
|
59
|
+
}
|
|
60
|
+
} else if (base) {
|
|
61
|
+
// Review changes since base branch
|
|
62
|
+
try {
|
|
63
|
+
diff = execSync(`git diff ${base}...HEAD`, execOpts);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`Failed to diff against ${base}: ${err.stderr?.slice(0, 200) ?? err.message}`);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
// Default: all uncommitted changes (staged + unstaged + untracked summary)
|
|
69
|
+
try {
|
|
70
|
+
const stageDiff = execSync("git diff --cached", execOpts);
|
|
71
|
+
const unstaged = execSync("git diff", execOpts);
|
|
72
|
+
diff = [stageDiff, unstaged].filter(Boolean).join("\n");
|
|
73
|
+
|
|
74
|
+
// Also include untracked file names if any
|
|
75
|
+
try {
|
|
76
|
+
const untracked = execSync("git ls-files --others --exclude-standard", execOpts).trim();
|
|
77
|
+
if (untracked) {
|
|
78
|
+
diff += `\n\n# Untracked files:\n${untracked.split("\n").map(f => `# + ${f}`).join("\n")}`;
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
throw new Error(`Failed to get git diff: ${err.stderr?.slice(0, 200) ?? err.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return diff.trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get some context about the repo.
|
|
91
|
+
*/
|
|
92
|
+
function getRepoContext(cwd = process.cwd()) {
|
|
93
|
+
const execOpts = { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" };
|
|
94
|
+
const context = {};
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
context.branch = execSync("git rev-parse --abbrev-ref HEAD", execOpts).trim();
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
context.lastCommit = execSync("git log -1 --pretty=%B", execOpts).trim().slice(0, 200);
|
|
102
|
+
} catch {}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const stat = execSync("git diff --stat", execOpts).trim();
|
|
106
|
+
context.stat = stat.slice(0, 500);
|
|
107
|
+
} catch {}
|
|
108
|
+
|
|
109
|
+
return context;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse the AI review response into structured JSON.
|
|
114
|
+
*/
|
|
115
|
+
function parseReviewResponse(text) {
|
|
116
|
+
const result = {
|
|
117
|
+
summary: "",
|
|
118
|
+
issues: { critical: [], warning: [], info: [] },
|
|
119
|
+
fileComments: [],
|
|
120
|
+
assessment: { verdict: "comment", explanation: "" },
|
|
121
|
+
raw: text,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Extract summary
|
|
125
|
+
const summaryMatch = text.match(/## Summary\n([\s\S]*?)(?=##|$)/);
|
|
126
|
+
if (summaryMatch) result.summary = summaryMatch[1].trim();
|
|
127
|
+
|
|
128
|
+
// Extract issues
|
|
129
|
+
const criticalMatch = text.match(/### Critical\n([\s\S]*?)(?=###|##|$)/);
|
|
130
|
+
if (criticalMatch) {
|
|
131
|
+
const content = criticalMatch[1].trim();
|
|
132
|
+
if (content && content.toLowerCase() !== "none") {
|
|
133
|
+
result.issues.critical = content.split("\n").filter(l => l.trim() && l.trim() !== "-").map(l => l.replace(/^[-*•]\s*/, "").trim());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const warningsMatch = text.match(/### Warning[s]?\n([\s\S]*?)(?=###|##|$)/);
|
|
138
|
+
if (warningsMatch) {
|
|
139
|
+
const content = warningsMatch[1].trim();
|
|
140
|
+
if (content && content.toLowerCase() !== "none") {
|
|
141
|
+
result.issues.warning = content.split("\n").filter(l => l.trim() && l.trim() !== "-").map(l => l.replace(/^[-*•]\s*/, "").trim());
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const infoMatch = text.match(/### Info\n([\s\S]*?)(?=###|##|$)/);
|
|
146
|
+
if (infoMatch) {
|
|
147
|
+
const content = infoMatch[1].trim();
|
|
148
|
+
if (content && content.toLowerCase() !== "none") {
|
|
149
|
+
result.issues.info = content.split("\n").filter(l => l.trim() && l.trim() !== "-").map(l => l.replace(/^[-*•]\s*/, "").trim());
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Extract assessment
|
|
154
|
+
const assessmentMatch = text.match(/## Assessment\n([\s\S]*?)(?=##|$)/);
|
|
155
|
+
if (assessmentMatch) {
|
|
156
|
+
const assessText = assessmentMatch[1].trim();
|
|
157
|
+
const verdictMatch = assessText.match(/\*\*Verdict:\*\*\s*(approve|request-changes|comment)/i);
|
|
158
|
+
if (verdictMatch) result.assessment.verdict = verdictMatch[1].toLowerCase();
|
|
159
|
+
result.assessment.explanation = assessText.replace(/\*\*Verdict:\*\*[^\n]*\n?/, "").trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Main review handler.
|
|
167
|
+
*/
|
|
168
|
+
export async function handleReviewCommand(args = []) {
|
|
169
|
+
// Parse args
|
|
170
|
+
const options = {
|
|
171
|
+
base: null,
|
|
172
|
+
commit: null,
|
|
173
|
+
title: null,
|
|
174
|
+
json: false,
|
|
175
|
+
cwd: process.cwd(),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < args.length; i++) {
|
|
179
|
+
if (args[i] === "--base" && args[i + 1]) { options.base = args[++i]; }
|
|
180
|
+
else if (args[i] === "--commit" && args[i + 1]) { options.commit = args[++i]; }
|
|
181
|
+
else if (args[i] === "--title" && args[i + 1]) { options.title = args[++i]; }
|
|
182
|
+
else if (args[i] === "--json") { options.json = true; }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check git
|
|
186
|
+
try {
|
|
187
|
+
execSync("git rev-parse --is-inside-work-tree", {
|
|
188
|
+
cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"],
|
|
189
|
+
});
|
|
190
|
+
} catch {
|
|
191
|
+
console.error("❌ Not inside a git repository.");
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Get the diff
|
|
196
|
+
let diff;
|
|
197
|
+
try {
|
|
198
|
+
diff = getDiff(options);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error(`❌ ${err.message}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!diff) {
|
|
205
|
+
console.log("✅ Nothing to review — no changes found.");
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const context = getRepoContext(options.cwd);
|
|
210
|
+
|
|
211
|
+
// Build review prompt
|
|
212
|
+
let prompt = "";
|
|
213
|
+
if (options.title) prompt += `# ${options.title}\n\n`;
|
|
214
|
+
if (context.branch) prompt += `**Branch:** \`${context.branch}\`\n`;
|
|
215
|
+
if (options.base) prompt += `**Review type:** Changes since \`${options.base}\`\n`;
|
|
216
|
+
if (options.commit) prompt += `**Commit:** \`${options.commit}\`\n`;
|
|
217
|
+
if (context.stat) prompt += `\n**Stats:**\n\`\`\`\n${context.stat}\n\`\`\`\n`;
|
|
218
|
+
prompt += `\n**Diff:**\n\`\`\`diff\n${diff.slice(0, 50_000)}\n\`\`\``;
|
|
219
|
+
|
|
220
|
+
if (diff.length > 50_000) {
|
|
221
|
+
prompt += `\n\n*(diff truncated — ${diff.length} chars total)*`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Show what we're reviewing
|
|
225
|
+
if (!options.json) {
|
|
226
|
+
if (options.commit) {
|
|
227
|
+
console.log(`\n🔍 Reviewing commit ${options.commit}...`);
|
|
228
|
+
} else if (options.base) {
|
|
229
|
+
console.log(`\n🔍 Reviewing changes since ${options.base}...`);
|
|
230
|
+
} else {
|
|
231
|
+
console.log("\n🔍 Reviewing uncommitted changes...");
|
|
232
|
+
}
|
|
233
|
+
if (context.stat) {
|
|
234
|
+
console.log(`\n${context.stat}\n`);
|
|
235
|
+
}
|
|
236
|
+
process.stdout.write("🌿 ");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Send to AI
|
|
240
|
+
let reviewText = "";
|
|
241
|
+
try {
|
|
242
|
+
const engine = new WispyEngine();
|
|
243
|
+
const initResult = await engine.init({ skipMcp: true });
|
|
244
|
+
if (!initResult) {
|
|
245
|
+
console.error("❌ No AI provider configured. Set an API key to use code review.");
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const response = await engine.processMessage(null, prompt, {
|
|
250
|
+
systemPrompt: CODE_REVIEW_SYSTEM_PROMPT,
|
|
251
|
+
onChunk: options.json ? null : (chunk) => process.stdout.write(chunk),
|
|
252
|
+
noSave: true,
|
|
253
|
+
skipSkillCapture: true,
|
|
254
|
+
skipUserModel: true,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
reviewText = response.content;
|
|
258
|
+
try { engine.destroy(); } catch {}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error(`❌ Review failed: ${err.message}`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!options.json) {
|
|
265
|
+
console.log("\n");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// JSON output
|
|
270
|
+
const structured = parseReviewResponse(reviewText);
|
|
271
|
+
console.log(JSON.stringify(structured, null, 2));
|
|
272
|
+
}
|
package/package.json
CHANGED