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/bin/wispy.mjs +302 -0
- package/core/config.mjs +63 -11
- package/core/engine.mjs +71 -11
- package/core/features.mjs +225 -0
- package/core/harness.mjs +135 -1
- package/core/index.mjs +2 -1
- package/core/loop-detector.mjs +183 -0
- package/core/memory.mjs +243 -0
- package/core/providers.mjs +10 -0
- package/core/secrets.mjs +4 -4
- package/core/session.mjs +107 -1
- package/lib/commands/review.mjs +272 -0
- package/lib/jsonl-emitter.mjs +101 -0
- package/package.json +1 -1
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
|
}
|
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/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.
|
|
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
|
+
}
|