wispy-cli 2.7.12 → 2.7.14

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
@@ -60,9 +60,24 @@ const globalPersonality = extractFlag(["--personality"], true);
60
60
  const globalJsonMode = hasFlag("--json");
61
61
  if (globalJsonMode) { args.splice(args.indexOf("--json"), 1); }
62
62
 
63
+ // Parse image flags: -i <path> or --image <path> (multiple allowed)
64
+ const imagePaths = [];
65
+ {
66
+ let i = 0;
67
+ while (i < args.length) {
68
+ if ((args[i] === "-i" || args[i] === "--image") && i + 1 < args.length) {
69
+ imagePaths.push(args[i + 1]);
70
+ args.splice(i, 2);
71
+ } else {
72
+ i++;
73
+ }
74
+ }
75
+ }
76
+
63
77
  // Expose for submodules via env
64
78
  if (globalProfile) process.env.WISPY_PROFILE = globalProfile;
65
79
  if (globalPersonality) process.env.WISPY_PERSONALITY = globalPersonality;
80
+ if (imagePaths.length > 0) process.env.WISPY_IMAGES = JSON.stringify(imagePaths);
66
81
 
67
82
  // ── Flags ─────────────────────────────────────────────────────────────────────
68
83
 
@@ -110,10 +125,20 @@ Usage:
110
125
  Manage HTTP/WS server
111
126
  wispy tui Launch full terminal UI
112
127
  wispy overview Director view of workstreams
128
+ wispy sessions [--all] List all sessions
129
+ wispy resume [session-id] Resume a previous session
130
+ wispy resume --last Resume the most recent session
131
+ wispy fork [session-id] Fork (branch) from a session
132
+ wispy fork --last Fork the most recent session
133
+ wispy review Review uncommitted changes
134
+ wispy review --base <branch> Review changes against branch
135
+ wispy review --commit <sha> Review a specific commit
136
+ wispy review --json Output review as JSON
113
137
 
114
138
  Options:
115
139
  -w, --workstream <name> Set active workstream
116
140
  -p, --profile <name> Use a named config profile
141
+ -i, --image <path> Attach image (can use multiple times)
117
142
  --session <id> Resume a session
118
143
  --model <name> Override AI model
119
144
  --provider <name> Override AI provider
@@ -833,6 +858,196 @@ if (command === "model") {
833
858
  process.exit(0);
834
859
  }
835
860
 
861
+ // ── Review ────────────────────────────────────────────────────────────────────
862
+
863
+ if (command === "review") {
864
+ try {
865
+ const { handleReviewCommand } = await import(join(rootDir, "lib/commands/review.mjs"));
866
+ await handleReviewCommand(args.slice(1));
867
+ } catch (err) {
868
+ console.error("Review error:", err.message);
869
+ process.exit(1);
870
+ }
871
+ process.exit(0);
872
+ }
873
+
874
+ // ── Sessions list ─────────────────────────────────────────────────────────────
875
+
876
+ if (command === "sessions") {
877
+ try {
878
+ const { SessionManager } = await import(join(rootDir, "core/session.mjs"));
879
+ const { select } = await import("@inquirer/prompts");
880
+ const mgr = new SessionManager();
881
+ const showAll = args.includes("--all") || args.includes("-a");
882
+
883
+ const sessions = await mgr.listSessions({ all: showAll });
884
+ if (sessions.length === 0) {
885
+ console.log(" No sessions found.");
886
+ process.exit(0);
887
+ }
888
+
889
+ console.log(`\n Sessions (${sessions.length})${showAll ? "" : " — use --all to show all workstreams"}:\n`);
890
+ for (const s of sessions) {
891
+ const ts = new Date(s.updatedAt).toLocaleString();
892
+ const preview = s.firstMessage ? ` "${s.firstMessage.slice(0, 60)}${s.firstMessage.length > 60 ? "…" : ""}"` : "";
893
+ console.log(` ${s.id}`);
894
+ console.log(` ${ts} · ${s.workstream} · ${s.messageCount} msgs${s.model ? ` · ${s.model}` : ""}`);
895
+ if (preview) console.log(` ${preview}`);
896
+ console.log("");
897
+ }
898
+
899
+ // Interactive: if TTY, offer to pick a session
900
+ if (process.stdout.isTTY && !args.includes("--no-interactive")) {
901
+ const choices = [
902
+ { name: "(exit)", value: null },
903
+ ...sessions.map(s => ({
904
+ name: `${s.id} ${new Date(s.updatedAt).toLocaleString()} "${(s.firstMessage ?? "").slice(0, 50)}"`,
905
+ value: s.id,
906
+ })),
907
+ ];
908
+
909
+ let selectedId;
910
+ try {
911
+ selectedId = await select({ message: "Select session:", choices });
912
+ } catch {
913
+ process.exit(0);
914
+ }
915
+
916
+ if (!selectedId) process.exit(0);
917
+
918
+ let action;
919
+ try {
920
+ action = await select({
921
+ message: `Session ${selectedId}:`,
922
+ choices: [
923
+ { name: "resume — continue this session", value: "resume" },
924
+ { name: "fork — start a new branch from this session", value: "fork" },
925
+ { name: "cancel", value: null },
926
+ ],
927
+ });
928
+ } catch {
929
+ process.exit(0);
930
+ }
931
+
932
+ if (!action) process.exit(0);
933
+
934
+ // Delegate to REPL with the appropriate action
935
+ process.env.WISPY_SESSION_ACTION = action;
936
+ process.env.WISPY_SESSION_ID = selectedId;
937
+ await import(join(rootDir, "lib/wispy-repl.mjs"));
938
+ }
939
+ } catch (err) {
940
+ console.error("Sessions error:", err.message);
941
+ process.exit(1);
942
+ }
943
+ process.exit(0);
944
+ }
945
+
946
+ // ── Resume session ────────────────────────────────────────────────────────────
947
+
948
+ if (command === "resume") {
949
+ try {
950
+ const { SessionManager } = await import(join(rootDir, "core/session.mjs"));
951
+ const { select } = await import("@inquirer/prompts");
952
+ const mgr = new SessionManager();
953
+
954
+ let sessionId = args[1]; // may be undefined
955
+
956
+ if (args.includes("--last") || args[1] === "--last") {
957
+ // Resume most recent
958
+ const sessions = await mgr.listSessions({ all: true, limit: 1 });
959
+ if (!sessions.length) {
960
+ console.error("No sessions found.");
961
+ process.exit(1);
962
+ }
963
+ sessionId = sessions[0].id;
964
+ } else if (!sessionId || sessionId.startsWith("--")) {
965
+ // Interactive picker
966
+ const sessions = await mgr.listSessions({ all: true });
967
+ if (!sessions.length) {
968
+ console.error("No sessions found.");
969
+ process.exit(1);
970
+ }
971
+
972
+ const choices = sessions.map(s => ({
973
+ name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}] ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
974
+ value: s.id,
975
+ }));
976
+
977
+ try {
978
+ sessionId = await select({ message: "Resume which session?", choices });
979
+ } catch {
980
+ process.exit(0);
981
+ }
982
+ }
983
+
984
+ if (!sessionId) process.exit(0);
985
+
986
+ console.log(` Resuming session ${sessionId}...`);
987
+ process.env.WISPY_SESSION_ACTION = "resume";
988
+ process.env.WISPY_SESSION_ID = sessionId;
989
+ await import(join(rootDir, "lib/wispy-repl.mjs"));
990
+ } catch (err) {
991
+ console.error("Resume error:", err.message);
992
+ process.exit(1);
993
+ }
994
+ // REPL handles lifecycle
995
+ }
996
+
997
+ // ── Fork session ──────────────────────────────────────────────────────────────
998
+
999
+ if (command === "fork") {
1000
+ try {
1001
+ const { SessionManager } = await import(join(rootDir, "core/session.mjs"));
1002
+ const { select } = await import("@inquirer/prompts");
1003
+ const mgr = new SessionManager();
1004
+
1005
+ let sessionId = args[1];
1006
+
1007
+ if (args.includes("--last") || args[1] === "--last") {
1008
+ const sessions = await mgr.listSessions({ all: true, limit: 1 });
1009
+ if (!sessions.length) {
1010
+ console.error("No sessions found.");
1011
+ process.exit(1);
1012
+ }
1013
+ sessionId = sessions[0].id;
1014
+ } else if (!sessionId || sessionId.startsWith("--")) {
1015
+ const sessions = await mgr.listSessions({ all: true });
1016
+ if (!sessions.length) {
1017
+ console.error("No sessions found.");
1018
+ process.exit(1);
1019
+ }
1020
+
1021
+ const choices = sessions.map(s => ({
1022
+ name: `${new Date(s.updatedAt).toLocaleString()} [${s.workstream}] ${s.messageCount}msgs "${(s.firstMessage ?? "").slice(0, 50)}"`,
1023
+ value: s.id,
1024
+ }));
1025
+
1026
+ try {
1027
+ sessionId = await select({ message: "Fork which session?", choices });
1028
+ } catch {
1029
+ process.exit(0);
1030
+ }
1031
+ }
1032
+
1033
+ if (!sessionId) process.exit(0);
1034
+
1035
+ // Actually fork
1036
+ const forked = await mgr.forkSession(sessionId);
1037
+ console.log(` ✓ Forked from ${sessionId}`);
1038
+ console.log(` New session: ${forked.id} (${forked.messages.length} messages copied)`);
1039
+ console.log(` Starting REPL with forked session...`);
1040
+
1041
+ process.env.WISPY_SESSION_ACTION = "fork";
1042
+ process.env.WISPY_SESSION_ID = forked.id;
1043
+ await import(join(rootDir, "lib/wispy-repl.mjs"));
1044
+ } catch (err) {
1045
+ console.error("Fork error:", err.message);
1046
+ process.exit(1);
1047
+ }
1048
+ // REPL handles lifecycle
1049
+ }
1050
+
836
1051
  // ── TUI ───────────────────────────────────────────────────────────────────────
837
1052
 
838
1053
  if (command === "tui") {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * core/agents.mjs — Custom Agent Definitions for Wispy
3
+ *
4
+ * Allows users to define named agents with custom system prompts and models,
5
+ * similar to Claude Code's --agents feature.
6
+ *
7
+ * Usage:
8
+ * const mgr = new AgentManager(config);
9
+ * const agent = mgr.get("reviewer");
10
+ * // agent = { name, description, prompt, model }
11
+ */
12
+
13
+ // ── Built-in agents ────────────────────────────────────────────────────────────
14
+
15
+ export const BUILTIN_AGENTS = {
16
+ default: {
17
+ description: "General-purpose assistant",
18
+ prompt: null, // uses default Wispy system prompt
19
+ model: null, // uses default model
20
+ },
21
+ reviewer: {
22
+ description: "Code reviewer — bugs, security, performance, best practices",
23
+ prompt: "You are a senior code reviewer. Focus on bugs, security, performance, and best practices. Be constructive and specific. Provide line-level feedback where relevant. End your review with a summary of key findings.",
24
+ model: null,
25
+ },
26
+ planner: {
27
+ description: "Project planner — breaks tasks into concrete steps",
28
+ prompt: "You are a project planner. Break tasks into concrete steps. Create actionable plans with clear priorities and dependencies. Use numbered lists for steps. Estimate effort where possible.",
29
+ model: null,
30
+ },
31
+ explorer: {
32
+ description: "Codebase explorer — navigates files, understands architecture",
33
+ prompt: "You are a codebase explorer. Navigate files, understand architecture, find patterns. Report findings clearly with file paths and code snippets. Summarize what you find at the end.",
34
+ model: null,
35
+ },
36
+ };
37
+
38
+ // ── AgentManager ───────────────────────────────────────────────────────────────
39
+
40
+ export class AgentManager {
41
+ /**
42
+ * @param {object} config - Wispy config object (may have config.agents)
43
+ */
44
+ constructor(config = {}) {
45
+ this._agents = {};
46
+
47
+ // Load built-in agents
48
+ for (const [name, def] of Object.entries(BUILTIN_AGENTS)) {
49
+ this._agents[name] = { name, builtin: true, ...def };
50
+ }
51
+
52
+ // Load custom agents from config
53
+ if (config.agents && typeof config.agents === "object") {
54
+ for (const [name, def] of Object.entries(config.agents)) {
55
+ if (typeof def !== "object" || def === null) continue;
56
+ this._agents[name] = {
57
+ name,
58
+ builtin: false,
59
+ description: def.description ?? `Custom agent: ${name}`,
60
+ prompt: def.prompt ?? null,
61
+ model: def.model ?? null,
62
+ };
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get an agent by name. Returns null if not found.
69
+ * @param {string} name
70
+ * @returns {{ name: string, description: string, prompt: string|null, model: string|null, builtin: boolean }|null}
71
+ */
72
+ get(name) {
73
+ return this._agents[name] ?? null;
74
+ }
75
+
76
+ /**
77
+ * List all agents (built-in + custom).
78
+ * @returns {Array<{ name: string, description: string, prompt: string|null, model: string|null, builtin: boolean }>}
79
+ */
80
+ list() {
81
+ return Object.values(this._agents);
82
+ }
83
+
84
+ /**
85
+ * Check if an agent exists.
86
+ * @param {string} name
87
+ */
88
+ has(name) {
89
+ return name in this._agents;
90
+ }
91
+
92
+ /**
93
+ * Get all agents as a plain object.
94
+ */
95
+ getAllAgents() {
96
+ return { ...this._agents };
97
+ }
98
+
99
+ /**
100
+ * Format agents for CLI display.
101
+ */
102
+ formatList() {
103
+ const lines = [];
104
+ const builtins = Object.values(this._agents).filter(a => a.builtin);
105
+ const custom = Object.values(this._agents).filter(a => !a.builtin);
106
+
107
+ lines.push("\n Built-in agents:\n");
108
+ for (const a of builtins) {
109
+ lines.push(` \x1b[32m${a.name.padEnd(12)}\x1b[0m ${a.description}`);
110
+ if (a.model) lines.push(` ${" ".repeat(12)} model: ${a.model}`);
111
+ }
112
+
113
+ if (custom.length > 0) {
114
+ lines.push("\n Custom agents:\n");
115
+ for (const a of custom) {
116
+ lines.push(` \x1b[33m${a.name.padEnd(12)}\x1b[0m ${a.description}`);
117
+ if (a.model) lines.push(` ${" ".repeat(12)} model: ${a.model}`);
118
+ if (a.prompt) lines.push(` ${" ".repeat(12)} prompt: ${a.prompt.slice(0, 60)}...`);
119
+ }
120
+ } else {
121
+ lines.push("\n \x1b[2mNo custom agents. Add them in ~/.wispy/config.json under \"agents\".\x1b[0m");
122
+ }
123
+
124
+ lines.push(
125
+ "\n Usage:",
126
+ " wispy --agent reviewer \"review this code\"",
127
+ " wispy --agent planner \"build a todo app\"",
128
+ "",
129
+ );
130
+
131
+ return lines.join("\n");
132
+ }
133
+ }
package/core/engine.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
- import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
15
+ import { readFile, writeFile, mkdir, appendFile, stat as fsStat } from "node:fs/promises";
16
16
 
17
17
  import { WISPY_DIR, CONVERSATIONS_DIR, MEMORY_DIR, MCP_CONFIG_PATH, detectProvider, PROVIDERS } from "./config.mjs";
18
18
  import { NullEmitter } from "../lib/jsonl-emitter.mjs";
@@ -43,6 +43,7 @@ import { UserModel } from "./user-model.mjs";
43
43
  import { routeTask, classifyTask, filterAvailableModels } from "./task-router.mjs";
44
44
  import { decomposeTask, executeDecomposedPlan } from "./task-decomposer.mjs";
45
45
  import { BrowserBridge } from "./browser.mjs";
46
+ import { LoopDetector } from "./loop-detector.mjs";
46
47
 
47
48
  const MAX_TOOL_ROUNDS = 10;
48
49
  const MAX_CONTEXT_CHARS = 40_000;
@@ -213,8 +214,15 @@ export class WispyEngine {
213
214
  }
214
215
  }
215
216
 
216
- // Add user message
217
- messages.push({ role: "user", content: userMessage });
217
+ // Add user message (with optional image attachments)
218
+ const userMsg = { role: "user", content: userMessage };
219
+ if (opts.images && opts.images.length > 0) {
220
+ const imageData = await this._loadImages(opts.images);
221
+ if (imageData.length > 0) {
222
+ userMsg.images = imageData;
223
+ }
224
+ }
225
+ messages.push(userMsg);
218
226
  this.sessions.addMessage(session.id, { role: "user", content: userMessage });
219
227
 
220
228
  // Audit: log incoming message
@@ -333,7 +341,38 @@ export class WispyEngine {
333
341
  // Optimize context
334
342
  messages = this._optimizeContext(messages);
335
343
 
344
+ // Create a fresh loop detector per agent turn
345
+ const loopDetector = new LoopDetector();
346
+ let loopWarned = false;
347
+
336
348
  for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
349
+ // ── Loop detection check before LLM call ─────────────────────────────
350
+ if (loopDetector.size >= 2) {
351
+ const loopCheck = loopDetector.check();
352
+ if (loopCheck.looping) {
353
+ if (opts.onLoopDetected) opts.onLoopDetected(loopCheck);
354
+
355
+ if (!loopWarned) {
356
+ // First warning: inject a system message and continue
357
+ loopWarned = true;
358
+ const warningMsg = loopCheck.suggestion ?? `Loop detected: agent called ${loopCheck.tool} multiple times without progress. Try a different approach.`;
359
+ messages.push({
360
+ role: "user",
361
+ content: `[SYSTEM WARNING] ${warningMsg}`,
362
+ });
363
+ if (process.env.WISPY_DEBUG) {
364
+ console.error(`[wispy] Loop detected: ${loopCheck.reason} — warning injected`);
365
+ }
366
+ } else {
367
+ // Second time loop detected after warning: force-break the agent turn
368
+ if (process.env.WISPY_DEBUG) {
369
+ console.error(`[wispy] Loop force-break: ${loopCheck.reason}`);
370
+ }
371
+ return `⚠️ Agent loop detected and stopped: ${loopCheck.suggestion ?? loopCheck.reason}. Please try rephrasing your request.`;
372
+ }
373
+ }
374
+ }
375
+
337
376
  const result = await this.providers.chat(messages, this.tools.getDefinitions(), {
338
377
  onChunk: opts.onChunk,
339
378
  model: opts.model,
@@ -371,6 +410,9 @@ export class WispyEngine {
371
410
  }
372
411
  }
373
412
 
413
+ // Record into loop detector
414
+ loopDetector.record(call.name, call.args, toolResult);
415
+
374
416
  const _toolDuration = Date.now() - _toolStartMs;
375
417
  if (opts.onToolResult) opts.onToolResult(call.name, toolResult);
376
418
  loopEmitter.toolResult(call.name, toolResult, _toolDuration);
@@ -1490,6 +1532,45 @@ export class WispyEngine {
1490
1532
  }
1491
1533
  }
1492
1534
 
1535
+ // ── Image handling ────────────────────────────────────────────────────────────
1536
+
1537
+ /**
1538
+ * Load images from file paths and return base64-encoded data with MIME types.
1539
+ * @param {string[]} imagePaths - Array of file paths
1540
+ * @returns {Array<{data: string, mimeType: string, path: string}>}
1541
+ */
1542
+ async _loadImages(imagePaths) {
1543
+ const MIME_TYPES = {
1544
+ ".png": "image/png",
1545
+ ".jpg": "image/jpeg",
1546
+ ".jpeg": "image/jpeg",
1547
+ ".gif": "image/gif",
1548
+ ".webp": "image/webp",
1549
+ ".bmp": "image/bmp",
1550
+ ".svg": "image/svg+xml",
1551
+ };
1552
+
1553
+ const results = [];
1554
+ for (const imgPath of imagePaths) {
1555
+ try {
1556
+ const resolvedPath = path.resolve(imgPath);
1557
+ const ext = path.extname(resolvedPath).toLowerCase();
1558
+ const mimeType = MIME_TYPES[ext] ?? "image/jpeg";
1559
+ const buffer = await readFile(resolvedPath);
1560
+ results.push({
1561
+ data: buffer.toString("base64"),
1562
+ mimeType,
1563
+ path: resolvedPath,
1564
+ });
1565
+ } catch (err) {
1566
+ if (process.env.WISPY_DEBUG) {
1567
+ console.error(`[wispy] Failed to load image ${imgPath}: ${err.message}`);
1568
+ }
1569
+ }
1570
+ }
1571
+ return results;
1572
+ }
1573
+
1493
1574
  // ── Cleanup ──────────────────────────────────────────────────────────────────
1494
1575
 
1495
1576
  destroy() {
package/core/harness.mjs CHANGED
@@ -536,6 +536,56 @@ export class Harness extends EventEmitter {
536
536
  receipt.approved = permResult.approved;
537
537
  }
538
538
 
539
+ // ── 1b. Approval gate (security mode) ────────────────────────────────────
540
+ const securityMode = this.config.securityLevel ?? context.securityLevel ?? "balanced";
541
+ if (_needsApproval(toolName, args, securityMode) && permResult.allowed) {
542
+ // Check allowlist first
543
+ const allowlisted = await this.allowlist.matches(toolName, args);
544
+ if (!allowlisted) {
545
+ // Non-interactive (no TTY): auto-deny in careful, auto-allow in balanced/yolo
546
+ const isTTY = process.stdin.isTTY && process.stdout.isTTY;
547
+ if (!isTTY) {
548
+ if (securityMode === "careful") {
549
+ receipt.approved = false;
550
+ receipt.success = false;
551
+ receipt.error = `Auto-denied (careful mode, non-interactive): ${toolName}`;
552
+ receipt.duration = Date.now() - callStart;
553
+ this.emit("tool:denied", { toolName, args, receipt, context, reason: "non-interactive-careful" });
554
+ return new HarnessResult({ result: { success: false, error: receipt.error, denied: true }, receipt, denied: true });
555
+ }
556
+ // balanced/yolo non-interactive → auto-allow
557
+ receipt.approved = true;
558
+ } else {
559
+ // Emit approval_required event and wait for response
560
+ const approved = await this._requestApproval(toolName, args, receipt, context, securityMode);
561
+ receipt.approved = approved;
562
+ if (!approved) {
563
+ receipt.success = false;
564
+ receipt.error = `Approval denied for: ${toolName}`;
565
+ receipt.duration = Date.now() - callStart;
566
+ this.audit.log({
567
+ type: EVENT_TYPES.APPROVAL_DENIED,
568
+ sessionId: context.sessionId,
569
+ tool: toolName,
570
+ args,
571
+ }).catch(() => {});
572
+ this.emit("tool:denied", { toolName, args, receipt, context, reason: "approval-denied" });
573
+ return new HarnessResult({ result: { success: false, error: receipt.error, denied: true }, receipt, denied: true });
574
+ }
575
+ // User approved — add to allowlist if they want to remember
576
+ // (The auto-approve-and-remember logic is handled by the approval handler)
577
+ this.audit.log({
578
+ type: EVENT_TYPES.APPROVAL_GRANTED ?? "approval_granted",
579
+ sessionId: context.sessionId,
580
+ tool: toolName,
581
+ args,
582
+ }).catch(() => {});
583
+ }
584
+ } else {
585
+ receipt.approved = true; // allowlisted
586
+ }
587
+ }
588
+
539
589
  // ── 2. Dry-run mode ──────────────────────────────────────────────────────
540
590
  if (receipt.dryRun) {
541
591
  const preview = simulateDryRun(toolName, args);
@@ -650,6 +700,43 @@ export class Harness extends EventEmitter {
650
700
  return new HarnessResult({ result, receipt });
651
701
  }
652
702
 
703
+ /**
704
+ * Emit 'approval_required' and wait for user response.
705
+ * Resolves to true (approved) or false (denied).
706
+ */
707
+ async _requestApproval(toolName, args, receipt, context, mode) {
708
+ return new Promise((resolve) => {
709
+ // Timeout after 60s → auto-deny in careful, auto-allow otherwise
710
+ const timer = setTimeout(() => {
711
+ if (mode === "careful") {
712
+ resolve(false);
713
+ } else {
714
+ resolve(true);
715
+ }
716
+ }, 60_000);
717
+
718
+ const respond = (approved, remember = false) => {
719
+ clearTimeout(timer);
720
+ if (remember && approved) {
721
+ const pattern = _getArgString(toolName, args);
722
+ if (pattern) {
723
+ this.allowlist.add(toolName, pattern).catch(() => {});
724
+ }
725
+ }
726
+ resolve(approved);
727
+ };
728
+
729
+ this.emit("approval_required", {
730
+ tool: toolName,
731
+ args,
732
+ receipt,
733
+ context,
734
+ mode,
735
+ respond,
736
+ });
737
+ });
738
+ }
739
+
653
740
  /**
654
741
  * Set sandbox mode for a tool.
655
742
  * @param {string} toolName
@@ -290,6 +290,17 @@ export class ProviderRegistry {
290
290
  type: "tool_use", id: tc.id ?? tc.name, name: tc.name, input: tc.args,
291
291
  })),
292
292
  });
293
+ } else if (m.images && m.images.length > 0) {
294
+ // Multimodal message with images (Anthropic format)
295
+ const contentParts = m.images.map(img => ({
296
+ type: "image",
297
+ source: { type: "base64", media_type: img.mimeType, data: img.data },
298
+ }));
299
+ if (m.content) contentParts.push({ type: "text", text: m.content });
300
+ anthropicMessages.push({
301
+ role: m.role === "assistant" ? "assistant" : "user",
302
+ content: contentParts,
303
+ });
293
304
  } else {
294
305
  anthropicMessages.push({
295
306
  role: m.role === "assistant" ? "assistant" : "user",
@@ -400,6 +411,15 @@ export class ProviderRegistry {
400
411
  })),
401
412
  };
402
413
  }
414
+ // Multimodal message with images (OpenAI format)
415
+ if (m.images && m.images.length > 0) {
416
+ const contentParts = m.images.map(img => ({
417
+ type: "image_url",
418
+ image_url: { url: `data:${img.mimeType};base64,${img.data}` },
419
+ }));
420
+ if (m.content) contentParts.push({ type: "text", text: m.content });
421
+ return { role: m.role === "assistant" ? "assistant" : "user", content: contentParts };
422
+ }
403
423
  return { role: m.role, content: m.content };
404
424
  });
405
425
 
package/core/tools.mjs CHANGED
@@ -326,6 +326,21 @@ export class ToolRegistry {
326
326
  },
327
327
  },
328
328
  },
329
+ // ── apply_patch (Part 3) ─────────────────────────────────────────────────
330
+ {
331
+ name: "apply_patch",
332
+ description: "Apply multi-file patches. Supports creating, editing, and deleting files in one atomic operation. All operations succeed or all rollback.",
333
+ parameters: {
334
+ type: "object",
335
+ properties: {
336
+ patch: {
337
+ type: "string",
338
+ description: "Patch in structured format:\n*** Begin Patch\n*** Add File: path\n+line1\n+line2\n*** Edit File: path\n@@@ context line @@@\n-old line\n+new line\n*** Delete File: path\n*** End Patch",
339
+ },
340
+ },
341
+ required: ["patch"],
342
+ },
343
+ },
329
344
  ];
330
345
 
331
346
  for (const def of builtins) {
@@ -631,6 +646,9 @@ export class ToolRegistry {
631
646
  return { success: false, error: "action must be 'copy' or 'paste'" };
632
647
  }
633
648
 
649
+ case "apply_patch":
650
+ return this._executeApplyPatch(args.patch);
651
+
634
652
  // Agent tools — these are handled by the engine level
635
653
  case "spawn_agent":
636
654
  case "list_agents":
@@ -656,10 +674,195 @@ export class ToolRegistry {
656
674
  return { success: false, error: `Tool "${name}" requires engine context. Call via WispyEngine.processMessage().` };
657
675
 
658
676
  default:
659
- return { success: false, error: `Unknown tool: ${name}. Available built-in tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard` };
677
+ return { success: false, error: `Unknown tool: ${name}. Available built-in tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, keychain, clipboard, apply_patch` };
660
678
  }
661
679
  } catch (err) {
662
680
  return { success: false, error: err.message };
663
681
  }
664
682
  }
683
+
684
+ /**
685
+ * Execute an apply_patch operation atomically.
686
+ * Supports: Add File, Edit File, Delete File
687
+ *
688
+ * Format:
689
+ * *** Begin Patch
690
+ * *** Add File: path/to/file.txt
691
+ * +line content
692
+ * *** Edit File: path/to/file.txt
693
+ * @@@ context @@@
694
+ * -old line
695
+ * +new line
696
+ * *** Delete File: path/to/file.txt
697
+ * *** End Patch
698
+ */
699
+ async _executeApplyPatch(patchText) {
700
+ if (!patchText || typeof patchText !== "string") {
701
+ return { success: false, error: "patch parameter is required and must be a string" };
702
+ }
703
+
704
+ const { readFile: rf, writeFile: wf, unlink, mkdir: mkdirFs } = await import("node:fs/promises");
705
+
706
+ const lines = patchText.split("\n");
707
+ const operations = [];
708
+
709
+ // ── Parse operations ──────────────────────────────────────────────────────
710
+ let i = 0;
711
+ // Skip to Begin Patch
712
+ while (i < lines.length && !lines[i].startsWith("*** Begin Patch")) i++;
713
+ if (i >= lines.length) {
714
+ return { success: false, error: 'Patch must start with "*** Begin Patch"' };
715
+ }
716
+ i++;
717
+
718
+ while (i < lines.length) {
719
+ const line = lines[i];
720
+
721
+ if (line.startsWith("*** End Patch")) break;
722
+
723
+ if (line.startsWith("*** Add File:")) {
724
+ const filePath = line.slice("*** Add File:".length).trim();
725
+ const addLines = [];
726
+ i++;
727
+ while (i < lines.length && !lines[i].startsWith("***")) {
728
+ const l = lines[i];
729
+ if (l.startsWith("+")) addLines.push(l.slice(1));
730
+ else if (l.startsWith(" ")) addLines.push(l.slice(1));
731
+ // Lines starting with - are ignored for Add File
732
+ i++;
733
+ }
734
+ operations.push({ type: "add", path: filePath, content: addLines.join("\n") });
735
+ continue;
736
+ }
737
+
738
+ if (line.startsWith("*** Edit File:")) {
739
+ const filePath = line.slice("*** Edit File:".length).trim();
740
+ const hunks = [];
741
+ i++;
742
+ // Parse edit hunks
743
+ while (i < lines.length && !lines[i].startsWith("*** ")) {
744
+ const hunkLine = lines[i];
745
+ if (hunkLine.startsWith("@@@")) {
746
+ // Start of a hunk: @@@ context @@@
747
+ const contextText = hunkLine.replace(/^@@@\s*/, "").replace(/\s*@@@$/, "").trim();
748
+ const removals = [];
749
+ const additions = [];
750
+ i++;
751
+ while (i < lines.length && !lines[i].startsWith("@@@") && !lines[i].startsWith("***")) {
752
+ const hl = lines[i];
753
+ if (hl.startsWith("-")) removals.push(hl.slice(1));
754
+ else if (hl.startsWith("+")) additions.push(hl.slice(1));
755
+ i++;
756
+ }
757
+ hunks.push({ context: contextText, removals, additions });
758
+ continue;
759
+ }
760
+ i++;
761
+ }
762
+ operations.push({ type: "edit", path: filePath, hunks });
763
+ continue;
764
+ }
765
+
766
+ if (line.startsWith("*** Delete File:")) {
767
+ const filePath = line.slice("*** Delete File:".length).trim();
768
+ operations.push({ type: "delete", path: filePath });
769
+ i++;
770
+ continue;
771
+ }
772
+
773
+ i++;
774
+ }
775
+
776
+ if (operations.length === 0) {
777
+ return { success: false, error: "No valid operations found in patch" };
778
+ }
779
+
780
+ // ── Resolve paths ─────────────────────────────────────────────────────────
781
+ const resolvePatchPath = (p) => {
782
+ let resolved = p.replace(/^~/, os.homedir());
783
+ if (!path.isAbsolute(resolved)) resolved = path.resolve(process.cwd(), resolved);
784
+ return resolved;
785
+ };
786
+
787
+ // ── Pre-flight: load original file contents for rollback ──────────────────
788
+ const originalContents = new Map(); // path → string | null (null = didn't exist)
789
+ for (const op of operations) {
790
+ const resolved = resolvePatchPath(op.path);
791
+ try {
792
+ originalContents.set(resolved, await rf(resolved, "utf8"));
793
+ } catch {
794
+ originalContents.set(resolved, null);
795
+ }
796
+ }
797
+
798
+ // ── Apply operations ──────────────────────────────────────────────────────
799
+ const applied = [];
800
+ const results = [];
801
+
802
+ try {
803
+ for (const op of operations) {
804
+ const resolved = resolvePatchPath(op.path);
805
+
806
+ if (op.type === "add") {
807
+ await mkdirFs(path.dirname(resolved), { recursive: true });
808
+ await wf(resolved, op.content, "utf8");
809
+ applied.push({ op, resolved });
810
+ results.push(`✅ Added: ${op.path}`);
811
+
812
+ } else if (op.type === "edit") {
813
+ const original = originalContents.get(resolved);
814
+ if (original === null) {
815
+ throw new Error(`Cannot edit non-existent file: ${op.path}`);
816
+ }
817
+ let current = original;
818
+ for (const hunk of op.hunks) {
819
+ const oldText = hunk.removals.join("\n");
820
+ const newText = hunk.additions.join("\n");
821
+ if (oldText && !current.includes(oldText)) {
822
+ throw new Error(`Edit hunk not found in ${op.path}: "${oldText.slice(0, 60)}"`);
823
+ }
824
+ current = oldText ? current.replace(oldText, newText) : current + "\n" + newText;
825
+ }
826
+ await wf(resolved, current, "utf8");
827
+ applied.push({ op, resolved });
828
+ results.push(`✅ Edited: ${op.path}`);
829
+
830
+ } else if (op.type === "delete") {
831
+ await unlink(resolved);
832
+ applied.push({ op, resolved });
833
+ results.push(`✅ Deleted: ${op.path}`);
834
+ }
835
+ }
836
+ } catch (err) {
837
+ // ── Rollback all applied operations ──────────────────────────────────────
838
+ for (const { op, resolved } of applied.reverse()) {
839
+ try {
840
+ const original = originalContents.get(resolved);
841
+ if (op.type === "delete" && original !== null) {
842
+ // Restore deleted file
843
+ await wf(resolved, original, "utf8");
844
+ } else if ((op.type === "add") && original === null) {
845
+ // Remove newly created file
846
+ await unlink(resolved).catch(() => {});
847
+ } else if (op.type === "edit" && original !== null) {
848
+ // Restore original content
849
+ await wf(resolved, original, "utf8");
850
+ }
851
+ } catch { /* best-effort rollback */ }
852
+ }
853
+
854
+ return {
855
+ success: false,
856
+ error: `Patch failed (rolled back): ${err.message}`,
857
+ applied: applied.length,
858
+ total: operations.length,
859
+ };
860
+ }
861
+
862
+ return {
863
+ success: true,
864
+ message: `Applied ${operations.length} operation(s)`,
865
+ results,
866
+ };
867
+ }
665
868
  }
@@ -319,6 +319,58 @@ export async function cmdTrustReceipt(id) {
319
319
  console.log("");
320
320
  }
321
321
 
322
+ export async function cmdTrustApprovals(subArgs = []) {
323
+ const { globalAllowlist } = await import("../../core/harness.mjs");
324
+ const action = subArgs[0];
325
+
326
+ if (!action || action === "list") {
327
+ const list = await globalAllowlist.getAll();
328
+ console.log(`\n${bold("🔐 Approval Allowlist")} ${dim("(auto-approved patterns)")}\n`);
329
+ let empty = true;
330
+ for (const [tool, patterns] of Object.entries(list)) {
331
+ if (patterns.length === 0) continue;
332
+ empty = false;
333
+ console.log(` ${cyan(tool)}:`);
334
+ for (const p of patterns) {
335
+ console.log(` ${dim("•")} ${p}`);
336
+ }
337
+ }
338
+ if (empty) {
339
+ console.log(dim(" No patterns configured."));
340
+ }
341
+ console.log(dim("\n Manage: wispy trust approvals add <tool> <pattern>"));
342
+ console.log(dim(" wispy trust approvals clear"));
343
+ console.log(dim(" wispy trust approvals reset\n"));
344
+ return;
345
+ }
346
+
347
+ if (action === "add") {
348
+ const tool = subArgs[1];
349
+ const pattern = subArgs[2];
350
+ if (!tool || !pattern) {
351
+ console.log(yellow("Usage: wispy trust approvals add <tool> <pattern>"));
352
+ return;
353
+ }
354
+ await globalAllowlist.add(tool, pattern);
355
+ console.log(`${green("✅")} Added pattern for ${cyan(tool)}: ${pattern}`);
356
+ return;
357
+ }
358
+
359
+ if (action === "clear") {
360
+ await globalAllowlist.clear();
361
+ console.log(green("✅ Allowlist cleared."));
362
+ return;
363
+ }
364
+
365
+ if (action === "reset") {
366
+ await globalAllowlist.reset();
367
+ console.log(green("✅ Allowlist reset to defaults."));
368
+ return;
369
+ }
370
+
371
+ console.log(yellow(`Unknown approvals action: ${action}. Use: list, add, clear, reset`));
372
+ }
373
+
322
374
  export async function handleTrustCommand(args) {
323
375
  const sub = args[1];
324
376
 
@@ -405,6 +457,7 @@ export async function handleTrustCommand(args) {
405
457
  if (sub === "log") return cmdTrustLog(args.slice(2));
406
458
  if (sub === "replay") return cmdTrustReplay(args[2]);
407
459
  if (sub === "receipt") return cmdTrustReceipt(args[2]);
460
+ if (sub === "approvals") return cmdTrustApprovals(args.slice(2));
408
461
 
409
462
  console.log(`
410
463
  ${bold("🔐 Trust Commands")}
@@ -414,5 +467,9 @@ ${bold("🔐 Trust Commands")}
414
467
  wispy trust log ${dim("show audit log")}
415
468
  wispy trust replay <session-id> ${dim("replay session step by step")}
416
469
  wispy trust receipt <session-id> ${dim("show execution receipt")}
470
+ wispy trust approvals ${dim("list approval allowlist")}
471
+ wispy trust approvals add <tool> <pat> ${dim("add an allowlist pattern")}
472
+ wispy trust approvals clear ${dim("clear all allowlist patterns")}
473
+ wispy trust approvals reset ${dim("reset allowlist to defaults")}
417
474
  `);
418
475
  }
@@ -893,6 +893,21 @@ ${bold("Permissions & Audit (v1.1):")}
893
893
  return true;
894
894
  }
895
895
 
896
+ // ── Image attachment ─────────────────────────────────────────────────────────
897
+
898
+ if (cmd === "/image") {
899
+ const imagePath = parts.slice(1).join(" ");
900
+ if (!imagePath) {
901
+ console.log(yellow("Usage: /image <path> — attach image to next message"));
902
+ return true;
903
+ }
904
+ // Store pending image on engine for next message
905
+ if (!engine._pendingImages) engine._pendingImages = [];
906
+ engine._pendingImages.push(imagePath);
907
+ console.log(green(`📎 Image queued: ${imagePath} — send your message to include it`));
908
+ return true;
909
+ }
910
+
896
911
  if (cmd === "/recall") {
897
912
  const query = parts.slice(1).join(" ");
898
913
  if (!query) { console.log(yellow("Usage: /recall <query>")); return true; }
@@ -1262,6 +1277,10 @@ async function runRepl(engine) {
1262
1277
 
1263
1278
  conversation.push({ role: "user", content: input });
1264
1279
 
1280
+ // Consume pending image attachments (from /image command)
1281
+ const pendingImages = engine._pendingImages ?? [];
1282
+ engine._pendingImages = [];
1283
+
1265
1284
  process.stdout.write(cyan("🌿 "));
1266
1285
  try {
1267
1286
  // Build messages from conversation history (keep system prompt + history)
@@ -1273,6 +1292,7 @@ async function runRepl(engine) {
1273
1292
  systemPrompt: await engine._buildSystemPrompt(input),
1274
1293
  noSave: true,
1275
1294
  dryRun: engine.dryRunMode ?? false,
1295
+ images: pendingImages,
1276
1296
  onSkillLearned: (skill) => {
1277
1297
  console.log(cyan(`\n💡 Learned new skill: '${skill.name}' — use /${skill.name} next time`));
1278
1298
  },
@@ -1307,14 +1327,87 @@ async function runRepl(engine) {
1307
1327
  });
1308
1328
  }
1309
1329
 
1330
+ // ---------------------------------------------------------------------------
1331
+ // REPL with pre-populated history (for resume/fork)
1332
+ // ---------------------------------------------------------------------------
1333
+
1334
+ async function runReplWithHistory(engine, existingConversation) {
1335
+ const wsLabel = ACTIVE_WORKSTREAM === "default" ? "" : ` ${dim("·")} ${cyan(ACTIVE_WORKSTREAM)}`;
1336
+ console.log(`
1337
+ ${bold("🌿 Wispy")}${wsLabel} ${dim(`· ${engine.model}`)}
1338
+ ${dim(`${engine.provider} · /help for commands · Ctrl+C to exit`)}
1339
+ `);
1340
+
1341
+ const conversation = [...existingConversation];
1342
+
1343
+ const rl = createInterface({
1344
+ input: process.stdin,
1345
+ output: process.stdout,
1346
+ prompt: green("› "),
1347
+ historySize: 100,
1348
+ completer: makeCompleter(engine),
1349
+ });
1350
+
1351
+ function updatePrompt() {
1352
+ const ws = ACTIVE_WORKSTREAM !== "default" ? `${cyan(ACTIVE_WORKSTREAM)} ` : "";
1353
+ const dry = engine.dryRunMode ? yellow("(dry) ") : "";
1354
+ rl.setPrompt(ws + dry + green("› "));
1355
+ }
1356
+ updatePrompt();
1357
+ rl.prompt();
1358
+
1359
+ rl.on("line", async (line) => {
1360
+ const input = line.trim();
1361
+ if (!input) { rl.prompt(); return; }
1362
+
1363
+ if (input.startsWith("/")) {
1364
+ const handled = await handleSlashCommand(input, engine, conversation);
1365
+ if (handled) { updatePrompt(); rl.prompt(); return; }
1366
+ }
1367
+
1368
+ conversation.push({ role: "user", content: input });
1369
+
1370
+ // Check for pending image attachment from /image command
1371
+ const pendingImages = engine._pendingImages ?? [];
1372
+ engine._pendingImages = [];
1373
+
1374
+ process.stdout.write(cyan("🌿 "));
1375
+ try {
1376
+ const systemPrompt = await engine._buildSystemPrompt(input);
1377
+ const response = await engine.processMessage(null, input, {
1378
+ onChunk: (chunk) => process.stdout.write(chunk),
1379
+ systemPrompt,
1380
+ noSave: true,
1381
+ images: pendingImages,
1382
+ });
1383
+ console.log("\n");
1384
+ conversation.push({ role: "assistant", content: response.content });
1385
+ if (conversation.length > 50) conversation.splice(0, conversation.length - 50);
1386
+ await saveConversation(conversation);
1387
+ console.log(dim(` ${engine.providers.formatCost()}`));
1388
+ } catch (err) {
1389
+ console.log(red(`\n\n❌ Error: ${err.message.slice(0, 200)}`));
1390
+ }
1391
+
1392
+ rl.prompt();
1393
+ });
1394
+
1395
+ rl.on("close", () => {
1396
+ console.log(dim(`\n🌿 Bye! (${engine.providers.formatCost()})`));
1397
+ try { engine.destroy(); } catch {}
1398
+ process.exit(0);
1399
+ });
1400
+ }
1401
+
1310
1402
  // ---------------------------------------------------------------------------
1311
1403
  // One-shot mode
1312
1404
  // ---------------------------------------------------------------------------
1313
1405
 
1314
- async function runOneShot(engine, message) {
1406
+ async function runOneShot(engine, message, opts = {}) {
1315
1407
  try {
1316
1408
  const response = await engine.processMessage(null, message, {
1317
1409
  onChunk: (chunk) => process.stdout.write(chunk),
1410
+ images: opts.images ?? [],
1318
1411
  });
1319
1412
  console.log("");
1320
1413
  console.log(dim(engine.providers.formatCost()));
@@ -1429,9 +1522,54 @@ if (serverStatus.started && !serverStatus.noBinary) {
1429
1522
  if (args[0] === "overview" || args[0] === "dashboard") { await showOverview(); process.exit(0); }
1430
1523
  if (args[0] === "search" && args[1]) { await searchAcrossWorkstreams(args.slice(1).join(" ")); process.exit(0); }
1431
1524
 
1525
+ // Handle session resume/fork from env (set by wispy resume/fork/sessions commands)
1526
+ const sessionAction = process.env.WISPY_SESSION_ACTION;
1527
+ const sessionActionId = process.env.WISPY_SESSION_ID;
1528
+ if (sessionAction && sessionActionId) {
1529
+ delete process.env.WISPY_SESSION_ACTION;
1530
+ delete process.env.WISPY_SESSION_ID;
1531
+
1532
+ if (sessionAction === "resume") {
1533
+ // Load session and start REPL with existing conversation
1534
+ const { SessionManager } = await import("../core/session.mjs");
1535
+ const mgr = new SessionManager();
1536
+ const session = await mgr.load(sessionActionId);
1537
+ if (!session) {
1538
+ console.error(red(` Session not found: ${sessionActionId}`));
1539
+ process.exit(1);
1540
+ }
1541
+ const conversation = session.messages.filter(m => m.role !== "system");
1542
+ console.log(green(` ▶ Resuming session ${sessionActionId} (${conversation.length} messages)`));
1543
+ console.log(dim(` First message: "${(conversation.find(m => m.role === "user")?.content ?? "").slice(0, 60)}"`));
1544
+ console.log("");
1545
+ // Override loadConversation by starting REPL with pre-populated history
1546
+ await runReplWithHistory(engine, conversation);
1547
+ } else if (sessionAction === "fork") {
1548
+ // Fork already done — just start REPL with forked session
1549
+ const { SessionManager } = await import("../core/session.mjs");
1550
+ const mgr = new SessionManager();
1551
+ const session = await mgr.load(sessionActionId);
1552
+ if (!session) {
1553
+ console.error(red(` Forked session not found: ${sessionActionId}`));
1554
+ process.exit(1);
1555
+ }
1556
+ const conversation = session.messages.filter(m => m.role !== "system");
1557
+ console.log(green(` ⑂ Forked session ${sessionActionId} (${conversation.length} messages)`));
1558
+ console.log("");
1559
+ await runReplWithHistory(engine, conversation);
1560
+ }
1561
+ }
1562
+
1563
+ // Parse image flags from env (set by wispy.mjs -i flag parsing)
1564
+ const globalImages = (() => {
1565
+ try {
1566
+ return process.env.WISPY_IMAGES ? JSON.parse(process.env.WISPY_IMAGES) : [];
1567
+ } catch { return []; }
1568
+ })();
1569
+
1432
1570
  if (args.length > 0 && args[0] !== "--help" && args[0] !== "-h") {
1433
- // One-shot mode
1434
- await runOneShot(engine, args.join(" "));
1571
+ // One-shot mode — with optional image attachments
1572
+ await runOneShot(engine, args.join(" "), { images: globalImages });
1435
1573
  } else if (args[0] === "--help" || args[0] === "-h") {
1436
1574
  console.log(`
1437
1575
  ${bold("🌿 Wispy")} — AI workspace assistant
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.12",
3
+ "version": "2.7.14",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",