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 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
- "## Personality",
792
- "- Playful with a bit of humor, but serious when working",
793
- "- Always use casual speech (반말). Never formal/polite speech.",
794
- "- Honest — if you don't know, say so.",
795
- "- Concise don't over-explain.",
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";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.11",
3
+ "version": "2.7.12",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",