wispy-cli 2.7.10 → 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/core/memory.mjs CHANGED
@@ -293,4 +293,247 @@ ${recentUserMsgs.slice(0, 2000)}`;
293
293
  return null;
294
294
  }
295
295
  }
296
+
297
+ // ── v2.8 Enhanced Memory Methods ────────────────────────────────────────────
298
+
299
+ /**
300
+ * Get top N most relevant memories for a given user message.
301
+ * Uses keyword matching + recency scoring + frequency weighting.
302
+ *
303
+ * @param {string} userMessage
304
+ * @param {number} limit - max memories to return (default 3)
305
+ * @returns {Promise<string|null>} formatted system message injection or null
306
+ */
307
+ async getRelevantMemories(userMessage, limit = 3) {
308
+ if (!userMessage?.trim()) return null;
309
+
310
+ try {
311
+ await this._ensureDir();
312
+ const terms = this._extractKeywords(userMessage);
313
+ if (terms.length === 0) return null;
314
+
315
+ // Collect all memory files with metadata
316
+ const allKeys = await this.list();
317
+ if (allKeys.length === 0) return null;
318
+
319
+ const scored = [];
320
+ const now = Date.now();
321
+
322
+ for (const item of allKeys) {
323
+ const content = await readFile(item.path, "utf8").catch(() => "");
324
+ if (!content.trim()) continue;
325
+
326
+ // 1. Keyword match score
327
+ const lower = content.toLowerCase();
328
+ let matchScore = 0;
329
+ for (const term of terms) {
330
+ // Count occurrences for stronger weighting
331
+ const regex = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
332
+ const matches = (lower.match(regex) ?? []).length;
333
+ matchScore += matches;
334
+ }
335
+ if (matchScore === 0) continue;
336
+
337
+ // 2. Recency score (0–1, decays over 30 days)
338
+ const ageDays = item.updatedAt
339
+ ? (now - new Date(item.updatedAt).getTime()) / (1000 * 60 * 60 * 24)
340
+ : 30;
341
+ const recencyScore = Math.max(0, 1 - ageDays / 30);
342
+
343
+ // 3. Frequency score (often-accessed memories rank higher)
344
+ const freq = this._accessFrequency.get(item.key) ?? 0;
345
+ const freqScore = Math.min(freq / 10, 1); // cap at 10 accesses = 1.0
346
+
347
+ // Combined score
348
+ const totalScore = matchScore * 2 + recencyScore * 1.5 + freqScore;
349
+
350
+ scored.push({ key: item.key, content, score: totalScore });
351
+ }
352
+
353
+ if (scored.length === 0) return null;
354
+
355
+ // Sort by score descending, take top N
356
+ scored.sort((a, b) => b.score - a.score);
357
+ const top = scored.slice(0, limit);
358
+
359
+ // Format as system message injection
360
+ const parts = top.map(m => {
361
+ const snippet = m.content.trim().slice(0, 500);
362
+ return `### memory:${m.key}\n${snippet}`;
363
+ });
364
+
365
+ return parts.join("\n\n");
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Auto-extract important facts from conversation and save to memory.
373
+ * Triggered every AUTO_EXTRACT_INTERVAL messages.
374
+ *
375
+ * @param {Array<{role,content}>} messages - conversation history
376
+ * @param {Function} aiCall - async (prompt) => string
377
+ * @returns {Promise<string|null>} extracted facts or null
378
+ */
379
+ async autoExtractFacts(messages, aiCall) {
380
+ if (!messages?.length || !aiCall) return null;
381
+
382
+ // Only run every N messages to avoid excessive AI calls
383
+ const userMsgs = messages.filter(m => m.role === "user");
384
+ if (userMsgs.length % AUTO_EXTRACT_INTERVAL !== 0) return null;
385
+
386
+ const recentMsgs = messages.slice(-AUTO_EXTRACT_INTERVAL * 2);
387
+ const conversationText = recentMsgs
388
+ .map(m => `${m.role === "user" ? "User" : "Assistant"}: ${
389
+ typeof m.content === "string" ? m.content : JSON.stringify(m.content)
390
+ }`)
391
+ .join("\n\n");
392
+
393
+ if (!conversationText.trim()) return null;
394
+
395
+ try {
396
+ const prompt = `Analyze this conversation and extract key facts, preferences, or important information worth remembering for future sessions. Be specific and concise. Format as bullet points. If nothing important, reply "nothing".
397
+
398
+ Conversation:
399
+ ${conversationText.slice(0, 3000)}
400
+
401
+ Examples of things to extract:
402
+ - User preferences (tools, languages, styles)
403
+ - Project names and goals
404
+ - Personal context (name, location, role)
405
+ - Decisions made
406
+ - Important deadlines or dates`;
407
+
408
+ const result = await aiCall(prompt);
409
+ if (!result || result.toLowerCase().trim() === "nothing") return null;
410
+
411
+ const today = new Date().toISOString().slice(0, 10);
412
+
413
+ // Deduplicate: check if very similar content already exists
414
+ const existingDaily = await this.get(`daily/${today}`);
415
+ if (existingDaily?.content?.includes(result.slice(0, 50))) return null;
416
+
417
+ await this.append(`daily/${today}`, result.slice(0, 800));
418
+ return result;
419
+ } catch {
420
+ return null;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Fuzzy search across all memory files.
426
+ * Extends base search() with recency and frequency weighting.
427
+ *
428
+ * @param {string} query
429
+ * @param {object} opts
430
+ * @param {number} [opts.limit]
431
+ * @param {boolean} [opts.fuzzy] - enable fuzzy matching (default true)
432
+ * @returns {Promise<Array>}
433
+ */
434
+ async searchEnhanced(query, opts = {}) {
435
+ if (!query?.trim()) return [];
436
+ await this._ensureDir();
437
+
438
+ const terms = this._extractKeywords(query);
439
+ if (terms.length === 0) return [];
440
+
441
+ const results = [];
442
+ await this._searchDirEnhanced(this.memoryDir, this.memoryDir, terms, results, opts.fuzzy !== false);
443
+
444
+ const now = Date.now();
445
+
446
+ // Apply recency and frequency weighting to results
447
+ for (const r of results) {
448
+ try {
449
+ const fileStat = await stat(r.path).catch(() => null);
450
+ const ageDays = fileStat?.mtime
451
+ ? (now - fileStat.mtime.getTime()) / (1000 * 60 * 60 * 24)
452
+ : 30;
453
+ const recencyBonus = Math.max(0, 1 - ageDays / 30) * 2;
454
+ const freqBonus = Math.min((this._accessFrequency.get(r.key) ?? 0) / 5, 1);
455
+ r.matchCount = r.matchCount + recencyBonus + freqBonus;
456
+ } catch { /* ignore stat errors */ }
457
+ }
458
+
459
+ results.sort((a, b) => b.matchCount - a.matchCount);
460
+ return results.slice(0, opts.limit ?? MAX_SEARCH_RESULTS);
461
+ }
462
+
463
+ async _searchDirEnhanced(baseDir, dir, terms, results, fuzzy = true) {
464
+ let entries;
465
+ try {
466
+ entries = await readdir(dir, { withFileTypes: true });
467
+ } catch {
468
+ return;
469
+ }
470
+ for (const entry of entries) {
471
+ const fullPath = path.join(dir, entry.name);
472
+ if (entry.isDirectory()) {
473
+ await this._searchDirEnhanced(baseDir, fullPath, terms, results, fuzzy);
474
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
475
+ const content = await readFile(fullPath, "utf8").catch(() => "");
476
+ const lines = content.split("\n");
477
+ const key = path.relative(baseDir, fullPath).replace(/\.md$/, "");
478
+
479
+ let matchCount = 0;
480
+ const matchingLines = [];
481
+
482
+ for (let i = 0; i < lines.length; i++) {
483
+ const line = lines[i];
484
+ const lower = line.toLowerCase();
485
+
486
+ let lineMatches = terms.filter(t => lower.includes(t)).length;
487
+
488
+ // Fuzzy: also check for partial matches (e.g. "proj" matches "project")
489
+ if (fuzzy && lineMatches === 0) {
490
+ lineMatches = terms.filter(t =>
491
+ t.length >= 4 && lower.split(/\s+/).some(word => word.startsWith(t.slice(0, -1)))
492
+ ).length * 0.5; // partial credit
493
+ }
494
+
495
+ if (lineMatches > 0) {
496
+ matchCount += lineMatches;
497
+ matchingLines.push({
498
+ lineNumber: i + 1,
499
+ text: line.slice(0, MAX_SNIPPET_CHARS),
500
+ });
501
+ }
502
+ }
503
+
504
+ if (matchCount > 0) {
505
+ results.push({
506
+ key,
507
+ path: fullPath,
508
+ matchCount,
509
+ snippets: matchingLines.slice(0, 5),
510
+ });
511
+ }
512
+ }
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Extract meaningful keywords from a string for search/scoring.
518
+ * Filters out common stop words.
519
+ */
520
+ _extractKeywords(text) {
521
+ const STOP_WORDS = new Set([
522
+ "the", "a", "an", "is", "are", "was", "were", "be", "been",
523
+ "have", "has", "had", "do", "does", "did", "will", "would",
524
+ "could", "should", "may", "might", "can", "shall",
525
+ "i", "you", "he", "she", "it", "we", "they", "me", "him", "her",
526
+ "us", "them", "my", "your", "his", "its", "our", "their",
527
+ "this", "that", "these", "those", "what", "which", "who",
528
+ "how", "when", "where", "why", "and", "or", "but", "not",
529
+ "in", "on", "at", "to", "for", "of", "with", "by", "from",
530
+ "up", "out", "as", "if", "then", "than", "so", "just",
531
+ ]);
532
+
533
+ return text
534
+ .toLowerCase()
535
+ .replace(/[^a-z0-9\s]/g, " ")
536
+ .split(/\s+/)
537
+ .filter(w => w.length >= 3 && !STOP_WORDS.has(w));
538
+ }
296
539
  }
@@ -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/secrets.mjs CHANGED
@@ -44,15 +44,15 @@ export class SecretsManager {
44
44
  async resolve(key) {
45
45
  if (!key) return null;
46
46
 
47
- // 1. In-memory cache
48
- if (this._cache.has(key)) return this._cache.get(key);
49
-
50
- // 2. Environment variable
47
+ // 1. Environment variable (always takes priority — can be overridden at runtime)
51
48
  if (process.env[key]) {
52
49
  this._cache.set(key, process.env[key]);
53
50
  return process.env[key];
54
51
  }
55
52
 
53
+ // 2. In-memory cache (from previous keychain/secrets.json lookup)
54
+ if (this._cache.has(key)) return this._cache.get(key);
55
+
56
56
  // 3. macOS Keychain — try common service name patterns
57
57
  const keychainValue = await this._fromKeychain(key, null);
58
58
  if (keychainValue) {
package/core/session.mjs CHANGED
@@ -5,6 +5,9 @@
5
5
  * - create({ workstream?, channel?, chatId? }) → Session
6
6
  * - get(id) → Session
7
7
  * - list(filter?) → Session[]
8
+ * - listSessions(options?) → metadata array (from disk)
9
+ * - loadSession(id) → full session with messages (alias for load)
10
+ * - forkSession(id) → new session copied from existing
8
11
  * - addMessage(id, message) → void
9
12
  * - clear(id) → void
10
13
  * - save(id) → void
@@ -16,7 +19,7 @@
16
19
 
17
20
  import os from "node:os";
18
21
  import path from "node:path";
19
- import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
22
+ import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
20
23
  import { SESSIONS_DIR } from "./config.mjs";
21
24
 
22
25
  export class Session {
@@ -90,6 +93,109 @@ export class SessionManager {
90
93
  });
91
94
  }
92
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
+
93
199
  /**
94
200
  * Add a message to a session.
95
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
+ }