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 +215 -0
- package/core/agents.mjs +133 -0
- package/core/engine.mjs +84 -3
- package/core/harness.mjs +87 -0
- package/core/providers.mjs +20 -0
- package/core/tools.mjs +204 -1
- package/lib/commands/trust.mjs +57 -0
- package/lib/wispy-repl.mjs +141 -3
- package/package.json +1 -1
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") {
|
package/core/agents.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
package/core/providers.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/commands/trust.mjs
CHANGED
|
@@ -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/lib/wispy-repl.mjs
CHANGED
|
@@ -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