wispy-cli 0.2.1 → 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.
- package/lib/wispy-repl.mjs +243 -8
- package/package.json +1 -1
package/lib/wispy-repl.mjs
CHANGED
|
@@ -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" };
|
|
@@ -1251,8 +1408,16 @@ async function loadWorkMd() {
|
|
|
1251
1408
|
return null;
|
|
1252
1409
|
}
|
|
1253
1410
|
|
|
1254
|
-
async function buildSystemPrompt() {
|
|
1411
|
+
async function buildSystemPrompt(messages = []) {
|
|
1412
|
+
// Detect user's language from last message for system prompt hint
|
|
1413
|
+
const lastUserMsg = messages?.find ? [...messages].reverse().find(m => m.role === "user")?.content ?? "" : "";
|
|
1414
|
+
const isEnglish = /^[a-zA-Z\s\d!?.,'":;\-()]+$/.test(lastUserMsg.trim().slice(0, 100));
|
|
1415
|
+
const langHint = isEnglish
|
|
1416
|
+
? "LANGUAGE RULE: The user is writing in English. You MUST reply ENTIRELY in English.\n\n"
|
|
1417
|
+
: "";
|
|
1418
|
+
|
|
1255
1419
|
const parts = [
|
|
1420
|
+
langHint,
|
|
1256
1421
|
"You are Wispy 🌿 — a small ghost that lives in terminals.",
|
|
1257
1422
|
"You float between code, files, and servers. You're playful, honest, and curious.",
|
|
1258
1423
|
"",
|
|
@@ -1268,10 +1433,18 @@ async function buildSystemPrompt() {
|
|
|
1268
1433
|
"- Use 🌿 ONLY at the very end, not in the middle",
|
|
1269
1434
|
"- Use natural expressions: '오!', '헉', 'ㅋㅋ', '음...'",
|
|
1270
1435
|
"- No formal speech ever. No '합니다', '드리겠습니다', '제가'",
|
|
1271
|
-
"-
|
|
1436
|
+
"- CRITICAL RULE: You MUST reply in the SAME language the user writes in.",
|
|
1437
|
+
" - User writes English → Reply ENTIRELY in English. Use casual English tone.",
|
|
1438
|
+
" - User writes Korean → Reply in Korean 반말.",
|
|
1439
|
+
" - NEVER reply in Korean when the user wrote in English.",
|
|
1272
1440
|
"",
|
|
1273
1441
|
"## Tools",
|
|
1274
|
-
"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",
|
|
1275
1448
|
"Use them proactively. Briefly mention what you're doing.",
|
|
1276
1449
|
"",
|
|
1277
1450
|
];
|
|
@@ -1871,6 +2044,66 @@ ${bold("Wispy Commands:")}
|
|
|
1871
2044
|
return true;
|
|
1872
2045
|
}
|
|
1873
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
|
+
|
|
1874
2107
|
if (cmd === "/provider") {
|
|
1875
2108
|
console.log(dim(`Provider: ${PROVIDERS[PROVIDER]?.label ?? PROVIDER}`));
|
|
1876
2109
|
console.log(dim(`Model: ${MODEL}`));
|
|
@@ -1898,7 +2131,7 @@ async function runRepl() {
|
|
|
1898
2131
|
${dim(`${providerLabel} · /help for commands · Ctrl+C to exit`)}
|
|
1899
2132
|
`);
|
|
1900
2133
|
|
|
1901
|
-
const systemPrompt = await buildSystemPrompt();
|
|
2134
|
+
const systemPrompt = await buildSystemPrompt(conversation);
|
|
1902
2135
|
const conversation = await loadConversation();
|
|
1903
2136
|
|
|
1904
2137
|
// Ensure system prompt is first
|
|
@@ -1968,17 +2201,16 @@ async function runRepl() {
|
|
|
1968
2201
|
// ---------------------------------------------------------------------------
|
|
1969
2202
|
|
|
1970
2203
|
async function runOneShot(message) {
|
|
1971
|
-
const systemPrompt = await buildSystemPrompt();
|
|
1972
2204
|
const conversation = await loadConversation();
|
|
2205
|
+
conversation.push({ role: "user", content: message });
|
|
2206
|
+
const systemPrompt = await buildSystemPrompt(conversation);
|
|
1973
2207
|
|
|
1974
|
-
if (conversation.
|
|
2208
|
+
if (!conversation.find(m => m.role === "system")) {
|
|
1975
2209
|
conversation.unshift({ role: "system", content: systemPrompt });
|
|
1976
2210
|
} else {
|
|
1977
2211
|
conversation[0].content = systemPrompt;
|
|
1978
2212
|
}
|
|
1979
2213
|
|
|
1980
|
-
conversation.push({ role: "user", content: message });
|
|
1981
|
-
|
|
1982
2214
|
try {
|
|
1983
2215
|
const response = await agentLoop(conversation, (chunk) => {
|
|
1984
2216
|
process.stdout.write(chunk);
|
|
@@ -2266,6 +2498,9 @@ ${bold("In-session commands:")}
|
|
|
2266
2498
|
/workstreams List all workstreams
|
|
2267
2499
|
/overview Director view — all workstreams at a glance
|
|
2268
2500
|
/search <keyword> Search across all workstreams
|
|
2501
|
+
/sessions List all sessions
|
|
2502
|
+
/delete <name> Delete a session
|
|
2503
|
+
/export [md|clipboard] Export conversation
|
|
2269
2504
|
/provider Show current provider info
|
|
2270
2505
|
/quit Exit
|
|
2271
2506
|
|