wispy-cli 2.7.12 → 2.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/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;
@@ -333,7 +334,38 @@ export class WispyEngine {
333
334
  // Optimize context
334
335
  messages = this._optimizeContext(messages);
335
336
 
337
+ // Create a fresh loop detector per agent turn
338
+ const loopDetector = new LoopDetector();
339
+ let loopWarned = false;
340
+
336
341
  for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
342
+ // ── Loop detection check before LLM call ─────────────────────────────
343
+ if (loopDetector.size >= 2) {
344
+ const loopCheck = loopDetector.check();
345
+ if (loopCheck.looping) {
346
+ if (opts.onLoopDetected) opts.onLoopDetected(loopCheck);
347
+
348
+ if (!loopWarned) {
349
+ // First warning: inject a system message and continue
350
+ loopWarned = true;
351
+ const warningMsg = loopCheck.suggestion ?? `Loop detected: agent called ${loopCheck.tool} multiple times without progress. Try a different approach.`;
352
+ messages.push({
353
+ role: "user",
354
+ content: `[SYSTEM WARNING] ${warningMsg}`,
355
+ });
356
+ if (process.env.WISPY_DEBUG) {
357
+ console.error(`[wispy] Loop detected: ${loopCheck.reason} — warning injected`);
358
+ }
359
+ } else {
360
+ // Second time loop detected after warning: force-break the agent turn
361
+ if (process.env.WISPY_DEBUG) {
362
+ console.error(`[wispy] Loop force-break: ${loopCheck.reason}`);
363
+ }
364
+ return `⚠️ Agent loop detected and stopped: ${loopCheck.suggestion ?? loopCheck.reason}. Please try rephrasing your request.`;
365
+ }
366
+ }
367
+ }
368
+
337
369
  const result = await this.providers.chat(messages, this.tools.getDefinitions(), {
338
370
  onChunk: opts.onChunk,
339
371
  model: opts.model,
@@ -371,6 +403,9 @@ export class WispyEngine {
371
403
  }
372
404
  }
373
405
 
406
+ // Record into loop detector
407
+ loopDetector.record(call.name, call.args, toolResult);
408
+
374
409
  const _toolDuration = Date.now() - _toolStartMs;
375
410
  if (opts.onToolResult) opts.onToolResult(call.name, toolResult);
376
411
  loopEmitter.toolResult(call.name, toolResult, _toolDuration);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.12",
3
+ "version": "2.7.13",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",