wispy-cli 0.2.2 → 0.3.0

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.
Files changed (2) hide show
  1. package/lib/wispy-repl.mjs +226 -1
  2. package/package.json +1 -1
@@ -676,6 +676,66 @@ const TOOL_DEFINITIONS = [
676
676
  required: ["query"],
677
677
  },
678
678
  },
679
+ {
680
+ name: "file_edit",
681
+ description: "Edit a file by replacing specific text. More precise than write_file — use this for targeted changes.",
682
+ parameters: {
683
+ type: "object",
684
+ properties: {
685
+ path: { type: "string", description: "File path" },
686
+ old_text: { type: "string", description: "Exact text to find and replace" },
687
+ new_text: { type: "string", description: "Replacement text" },
688
+ },
689
+ required: ["path", "old_text", "new_text"],
690
+ },
691
+ },
692
+ {
693
+ name: "file_search",
694
+ description: "Search for text patterns in files recursively (like grep). Returns matching lines with file paths and line numbers.",
695
+ parameters: {
696
+ type: "object",
697
+ properties: {
698
+ pattern: { type: "string", description: "Text or regex pattern to search for" },
699
+ path: { type: "string", description: "Directory to search in (default: current dir)" },
700
+ file_glob: { type: "string", description: "File glob filter (e.g., '*.ts', '*.py')" },
701
+ },
702
+ required: ["pattern"],
703
+ },
704
+ },
705
+ {
706
+ name: "git",
707
+ description: "Run git operations: status, diff, log, branch, add, commit, stash, checkout. Use for version control tasks.",
708
+ parameters: {
709
+ type: "object",
710
+ properties: {
711
+ command: { type: "string", description: "Git subcommand and args (e.g., 'status', 'diff --cached', 'log --oneline -10', 'commit -m \"msg\"')" },
712
+ },
713
+ required: ["command"],
714
+ },
715
+ },
716
+ {
717
+ name: "web_fetch",
718
+ description: "Fetch content from a URL and return it as text/markdown. Use to read web pages, docs, APIs.",
719
+ parameters: {
720
+ type: "object",
721
+ properties: {
722
+ url: { type: "string", description: "URL to fetch" },
723
+ },
724
+ required: ["url"],
725
+ },
726
+ },
727
+ {
728
+ name: "clipboard",
729
+ description: "Copy text to clipboard (macOS/Linux) or read current clipboard contents.",
730
+ parameters: {
731
+ type: "object",
732
+ properties: {
733
+ action: { type: "string", enum: ["copy", "paste"], description: "copy: write to clipboard, paste: read from clipboard" },
734
+ text: { type: "string", description: "Text to copy (only for copy action)" },
735
+ },
736
+ required: ["action"],
737
+ },
738
+ },
679
739
  {
680
740
  name: "spawn_agent",
681
741
  description: "Spawn a sub-agent for a well-scoped task. Use for sidecar tasks that can run in parallel. Do NOT spawn for the immediate blocking step — do that yourself. Each agent gets its own context. Prefer concrete, bounded tasks with clear deliverables.",
@@ -939,6 +999,103 @@ async function executeTool(name, args) {
939
999
  };
940
1000
  }
941
1001
 
1002
+ case "file_edit": {
1003
+ const filePath = args.path.replace(/^~/, os.homedir());
1004
+ try {
1005
+ const content = await readFile(filePath, "utf8");
1006
+ if (!content.includes(args.old_text)) {
1007
+ return { success: false, error: `Text not found in ${filePath}` };
1008
+ }
1009
+ const newContent = content.replace(args.old_text, args.new_text);
1010
+ await writeFile(filePath, newContent, "utf8");
1011
+ return { success: true, message: `Edited ${filePath}: replaced ${args.old_text.length} chars` };
1012
+ } catch (err) {
1013
+ return { success: false, error: err.message };
1014
+ }
1015
+ }
1016
+
1017
+ case "file_search": {
1018
+ const { promisify: prom } = await import("node:util");
1019
+ const { execFile: ef2 } = await import("node:child_process");
1020
+ const exec2 = prom(ef2);
1021
+ const searchPath = (args.path || ".").replace(/^~/, os.homedir());
1022
+ const glob = args.file_glob ? `--include="${args.file_glob}"` : "";
1023
+ try {
1024
+ const { stdout } = await exec2("/bin/bash", ["-c",
1025
+ `grep -rn ${glob} "${args.pattern}" "${searchPath}" 2>/dev/null | head -30`
1026
+ ], { timeout: 10_000 });
1027
+ const lines = stdout.trim().split("\n").filter(Boolean);
1028
+ return { success: true, matches: lines.length, results: stdout.trim().slice(0, 5000) };
1029
+ } catch {
1030
+ return { success: true, matches: 0, results: "No matches found." };
1031
+ }
1032
+ }
1033
+
1034
+ case "git": {
1035
+ const { promisify: prom } = await import("node:util");
1036
+ const { execFile: ef2 } = await import("node:child_process");
1037
+ const exec2 = prom(ef2);
1038
+ console.log(dim(` $ git ${args.command}`));
1039
+ try {
1040
+ const { stdout, stderr } = await exec2("/bin/bash", ["-c", `git ${args.command}`], {
1041
+ timeout: 15_000, cwd: process.cwd(),
1042
+ });
1043
+ return { success: true, output: (stdout + (stderr ? `\n${stderr}` : "")).trim().slice(0, 5000) };
1044
+ } catch (err) {
1045
+ return { success: false, error: err.stderr?.trim() || err.message };
1046
+ }
1047
+ }
1048
+
1049
+ case "web_fetch": {
1050
+ try {
1051
+ const resp = await fetch(args.url, {
1052
+ headers: { "User-Agent": "Wispy/0.2" },
1053
+ signal: AbortSignal.timeout(15_000),
1054
+ });
1055
+ const contentType = resp.headers.get("content-type") ?? "";
1056
+ const text = await resp.text();
1057
+ // Basic HTML → text conversion
1058
+ const cleaned = contentType.includes("html")
1059
+ ? text.replace(/<script[\s\S]*?<\/script>/gi, "")
1060
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
1061
+ .replace(/<[^>]+>/g, " ")
1062
+ .replace(/\s+/g, " ")
1063
+ .trim()
1064
+ : text;
1065
+ return { success: true, content: cleaned.slice(0, 10_000), contentType, status: resp.status };
1066
+ } catch (err) {
1067
+ return { success: false, error: err.message };
1068
+ }
1069
+ }
1070
+
1071
+ case "clipboard": {
1072
+ const { promisify: prom } = await import("node:util");
1073
+ const { execFile: ef2 } = await import("node:child_process");
1074
+ const exec2 = prom(ef2);
1075
+ if (args.action === "copy") {
1076
+ const { exec: execCb } = await import("node:child_process");
1077
+ const execP = prom(execCb);
1078
+ try {
1079
+ // macOS: pbcopy, Linux: xclip
1080
+ const copyCmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard";
1081
+ await execP(`echo "${args.text.replace(/"/g, '\\"')}" | ${copyCmd}`);
1082
+ return { success: true, message: `Copied ${args.text.length} chars to clipboard` };
1083
+ } catch (err) {
1084
+ return { success: false, error: err.message };
1085
+ }
1086
+ }
1087
+ if (args.action === "paste") {
1088
+ try {
1089
+ const pasteCmd = process.platform === "darwin" ? "pbpaste" : "xclip -selection clipboard -o";
1090
+ const { stdout } = await exec2("/bin/bash", ["-c", pasteCmd], { timeout: 3000 });
1091
+ return { success: true, content: stdout.slice(0, 5000) };
1092
+ } catch (err) {
1093
+ return { success: false, error: err.message };
1094
+ }
1095
+ }
1096
+ return { success: false, error: "action must be 'copy' or 'paste'" };
1097
+ }
1098
+
942
1099
  case "spawn_agent": {
943
1100
  const role = args.role ?? "worker";
944
1101
  const tierMap = { explorer: "cheap", planner: "mid", worker: "mid", reviewer: "mid" };
@@ -1282,7 +1439,12 @@ async function buildSystemPrompt(messages = []) {
1282
1439
  " - NEVER reply in Korean when the user wrote in English.",
1283
1440
  "",
1284
1441
  "## Tools",
1285
- "You have: read_file, write_file, run_command, list_directory, web_search, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result.",
1442
+ "You have these tools: read_file, write_file, file_edit, file_search, run_command, list_directory, git, web_search, web_fetch, clipboard, spawn_agent, spawn_async_agent, pipeline, ralph_loop, update_plan, list_agents, get_agent_result.",
1443
+ "- file_edit: for targeted text replacement (prefer over write_file for edits)",
1444
+ "- file_search: grep across codebase",
1445
+ "- git: any git command",
1446
+ "- web_fetch: read URL content",
1447
+ "- clipboard: copy/paste system clipboard",
1286
1448
  "Use them proactively. Briefly mention what you're doing.",
1287
1449
  "",
1288
1450
  ];
@@ -1882,6 +2044,66 @@ ${bold("Wispy Commands:")}
1882
2044
  return true;
1883
2045
  }
1884
2046
 
2047
+ if (cmd === "/sessions" || cmd === "/ls") {
2048
+ const wsList = await listWorkstreams();
2049
+ if (wsList.length === 0) {
2050
+ console.log(dim("No sessions yet."));
2051
+ } else {
2052
+ console.log(bold("\n📂 Sessions:\n"));
2053
+ for (const ws of wsList) {
2054
+ const conv = await loadWorkstreamConversation(ws);
2055
+ const msgs = conv.filter(m => m.role === "user").length;
2056
+ const marker = ws === ACTIVE_WORKSTREAM ? green("● ") : " ";
2057
+ console.log(`${marker}${ws.padEnd(20)} ${dim(`${msgs} messages`)}`);
2058
+ }
2059
+ }
2060
+ console.log(dim(`\nSwitch: wispy -w <name> | Delete: /delete <name>`));
2061
+ return true;
2062
+ }
2063
+
2064
+ if (cmd === "/delete" || cmd === "/rm") {
2065
+ const target = parts[1];
2066
+ if (!target) { console.log(yellow("Usage: /delete <workstream-name>")); return true; }
2067
+ const wsPath = path.join(CONVERSATIONS_DIR, `${target}.json`);
2068
+ try {
2069
+ const { unlink } = await import("node:fs/promises");
2070
+ await unlink(wsPath);
2071
+ // Also delete work.md and plan
2072
+ await unlink(path.join(CONVERSATIONS_DIR, `${target}.work.md`)).catch(() => {});
2073
+ await unlink(path.join(CONVERSATIONS_DIR, `${target}.plan.json`)).catch(() => {});
2074
+ console.log(green(`🗑️ Deleted session "${target}"`));
2075
+ } catch {
2076
+ console.log(red(`Session "${target}" not found.`));
2077
+ }
2078
+ return true;
2079
+ }
2080
+
2081
+ if (cmd === "/export") {
2082
+ const conv = await loadConversation();
2083
+ const userAssistant = conv.filter(m => m.role === "user" || m.role === "assistant");
2084
+ if (userAssistant.length === 0) { console.log(dim("Nothing to export.")); return true; }
2085
+
2086
+ const format = parts[1] ?? "md";
2087
+ const lines = userAssistant.map(m => {
2088
+ const role = m.role === "user" ? "**You**" : "**Wispy**";
2089
+ return `${role}: ${m.content}`;
2090
+ });
2091
+
2092
+ if (format === "clipboard" || format === "copy") {
2093
+ const { execSync: es } = await import("node:child_process");
2094
+ try {
2095
+ const text = lines.join("\n\n");
2096
+ es(`echo "${text.replace(/"/g, '\\"').slice(0, 50000)}" | pbcopy`);
2097
+ console.log(green(`📋 Copied ${userAssistant.length} messages to clipboard`));
2098
+ } catch { console.log(red("Clipboard copy failed.")); }
2099
+ } else {
2100
+ const exportPath = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.export.md`);
2101
+ await writeFile(exportPath, `# ${ACTIVE_WORKSTREAM}\n\n${lines.join("\n\n")}\n`, "utf8");
2102
+ console.log(green(`📄 Exported to ${exportPath}`));
2103
+ }
2104
+ return true;
2105
+ }
2106
+
1885
2107
  if (cmd === "/provider") {
1886
2108
  console.log(dim(`Provider: ${PROVIDERS[PROVIDER]?.label ?? PROVIDER}`));
1887
2109
  console.log(dim(`Model: ${MODEL}`));
@@ -2276,6 +2498,9 @@ ${bold("In-session commands:")}
2276
2498
  /workstreams List all workstreams
2277
2499
  /overview Director view — all workstreams at a glance
2278
2500
  /search <keyword> Search across all workstreams
2501
+ /sessions List all sessions
2502
+ /delete <name> Delete a session
2503
+ /export [md|clipboard] Export conversation
2279
2504
  /provider Show current provider info
2280
2505
  /quit Exit
2281
2506
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "🌿 Wispy — AI workspace assistant with multi-agent orchestration",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Minseo & Poropo",