wispy-cli 2.7.13 → 2.7.15
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 +301 -0
- package/core/agents.mjs +133 -0
- package/core/budget.mjs +277 -0
- package/core/engine.mjs +216 -10
- package/core/harness.mjs +162 -0
- package/core/project-settings.mjs +122 -0
- package/core/session.mjs +27 -3
- package/lib/wispy-repl.mjs +141 -3
- package/package.json +1 -1
package/core/harness.mjs
CHANGED
|
@@ -19,6 +19,99 @@ import os from "node:os";
|
|
|
19
19
|
|
|
20
20
|
import { EVENT_TYPES } from "./audit.mjs";
|
|
21
21
|
|
|
22
|
+
// ── Tool allow/deny patterns ───────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Claude Code compatible tool name aliases.
|
|
26
|
+
* Maps user-facing alias to internal tool name.
|
|
27
|
+
*/
|
|
28
|
+
export const TOOL_ALIASES = {
|
|
29
|
+
"Bash": "run_command",
|
|
30
|
+
"Edit": "file_edit",
|
|
31
|
+
"Write": "write_file",
|
|
32
|
+
"Read": "read_file",
|
|
33
|
+
"Grep": "file_search",
|
|
34
|
+
"LS": "list_directory",
|
|
35
|
+
"WebSearch": "web_search",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a tool pattern string like:
|
|
40
|
+
* "Bash(git:*)" → { tool: "run_command", argPattern: "git *" }
|
|
41
|
+
* "Edit" → { tool: "file_edit", argPattern: "*" }
|
|
42
|
+
* "Read(*.ts)" → { tool: "read_file", argPattern: "*.ts" }
|
|
43
|
+
* "run_command" → { tool: "run_command", argPattern: "*" }
|
|
44
|
+
*
|
|
45
|
+
* @param {string} pattern
|
|
46
|
+
* @returns {{ tool: string, argPattern: string }}
|
|
47
|
+
*/
|
|
48
|
+
export function parseToolPattern(pattern) {
|
|
49
|
+
const m = pattern.match(/^([^(]+)(?:\(([^)]*)\))?$/);
|
|
50
|
+
if (!m) return null;
|
|
51
|
+
|
|
52
|
+
const rawName = m[1].trim();
|
|
53
|
+
const argSpec = m[2] ?? "*";
|
|
54
|
+
|
|
55
|
+
// Resolve alias
|
|
56
|
+
const toolName = TOOL_ALIASES[rawName] ?? rawName;
|
|
57
|
+
|
|
58
|
+
// Normalize "git:*" style to "git *" for command matching
|
|
59
|
+
const argPattern = argSpec.replace(/:/g, " ").trim() || "*";
|
|
60
|
+
|
|
61
|
+
return { tool: toolName, argPattern };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse a space-separated list of tool patterns.
|
|
66
|
+
* @param {string} patternsStr - e.g. "Bash(git:*) read_file Edit"
|
|
67
|
+
* @returns {Array<{ tool: string, argPattern: string }>}
|
|
68
|
+
*/
|
|
69
|
+
export function parseToolPatternList(patternsStr) {
|
|
70
|
+
if (!patternsStr) return [];
|
|
71
|
+
// Split on whitespace but respect parentheses
|
|
72
|
+
const patterns = [];
|
|
73
|
+
let current = "";
|
|
74
|
+
let depth = 0;
|
|
75
|
+
for (const ch of patternsStr) {
|
|
76
|
+
if (ch === "(") { depth++; current += ch; }
|
|
77
|
+
else if (ch === ")") { depth--; current += ch; }
|
|
78
|
+
else if ((ch === " " || ch === ",") && depth === 0) {
|
|
79
|
+
if (current.trim()) patterns.push(current.trim());
|
|
80
|
+
current = "";
|
|
81
|
+
} else {
|
|
82
|
+
current += ch;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (current.trim()) patterns.push(current.trim());
|
|
86
|
+
return patterns.map(parseToolPattern).filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check whether a tool call matches a pattern.
|
|
91
|
+
* @param {string} toolName
|
|
92
|
+
* @param {object} args
|
|
93
|
+
* @param {{ tool: string, argPattern: string }} pattern
|
|
94
|
+
*/
|
|
95
|
+
export function matchesPattern(toolName, args, pattern) {
|
|
96
|
+
// Tool name must match
|
|
97
|
+
if (pattern.tool !== toolName && pattern.tool !== "*") return false;
|
|
98
|
+
// If no arg pattern, it's a match
|
|
99
|
+
if (pattern.argPattern === "*") return true;
|
|
100
|
+
// Get the relevant arg string
|
|
101
|
+
const argStr = _getArgString(toolName, args);
|
|
102
|
+
return _globMatch(argStr, pattern.argPattern);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Simple glob matching (supports * wildcard).
|
|
107
|
+
*/
|
|
108
|
+
function _globMatch(str, pattern) {
|
|
109
|
+
if (pattern === "*") return true;
|
|
110
|
+
// Convert glob to regex
|
|
111
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
112
|
+
return new RegExp(`^${escaped}`, "i").test(str);
|
|
113
|
+
}
|
|
114
|
+
|
|
22
115
|
// ── Approval gate constants ────────────────────────────────────────────────────
|
|
23
116
|
|
|
24
117
|
// Tools that require approval depending on security mode
|
|
@@ -491,6 +584,55 @@ export class Harness extends EventEmitter {
|
|
|
491
584
|
file_edit: "diff",
|
|
492
585
|
git: "preview",
|
|
493
586
|
};
|
|
587
|
+
|
|
588
|
+
// Tool allow/deny patterns (from config)
|
|
589
|
+
this._allowedPatterns = config.allowedTools ? parseToolPatternList(config.allowedTools) : null; // null = allow all
|
|
590
|
+
this._disallowedPatterns = config.disallowedTools ? parseToolPatternList(config.disallowedTools) : [];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Set allowed tools (replaces current filter).
|
|
595
|
+
* @param {string|Array} patterns - Space-separated string or array of pattern strings
|
|
596
|
+
*/
|
|
597
|
+
setAllowedTools(patterns) {
|
|
598
|
+
if (!patterns) { this._allowedPatterns = null; return; }
|
|
599
|
+
const str = Array.isArray(patterns) ? patterns.join(" ") : patterns;
|
|
600
|
+
this._allowedPatterns = parseToolPatternList(str);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Set disallowed tools.
|
|
605
|
+
* @param {string|Array} patterns
|
|
606
|
+
*/
|
|
607
|
+
setDisallowedTools(patterns) {
|
|
608
|
+
if (!patterns) { this._disallowedPatterns = []; return; }
|
|
609
|
+
const str = Array.isArray(patterns) ? patterns.join(" ") : patterns;
|
|
610
|
+
this._disallowedPatterns = parseToolPatternList(str);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Check if a tool call is allowed by the current allow/deny filters.
|
|
615
|
+
* @param {string} toolName
|
|
616
|
+
* @param {object} args
|
|
617
|
+
* @returns {{ allowed: boolean, reason?: string }}
|
|
618
|
+
*/
|
|
619
|
+
checkToolFilter(toolName, args) {
|
|
620
|
+
// Check disallowed patterns first (deny takes precedence)
|
|
621
|
+
for (const pattern of this._disallowedPatterns) {
|
|
622
|
+
if (matchesPattern(toolName, args, pattern)) {
|
|
623
|
+
return { allowed: false, reason: `Tool '${toolName}' is in the disallowed list.` };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Check allowed patterns (if set)
|
|
628
|
+
if (this._allowedPatterns !== null && this._allowedPatterns.length > 0) {
|
|
629
|
+
const allowed = this._allowedPatterns.some(p => matchesPattern(toolName, args, p));
|
|
630
|
+
if (!allowed) {
|
|
631
|
+
return { allowed: false, reason: `Tool '${toolName}' is not in the allowed list.` };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return { allowed: true };
|
|
494
636
|
}
|
|
495
637
|
|
|
496
638
|
/**
|
|
@@ -510,6 +652,26 @@ export class Harness extends EventEmitter {
|
|
|
510
652
|
|
|
511
653
|
const callStart = Date.now();
|
|
512
654
|
|
|
655
|
+
// ── 0. Tool allow/deny filter ────────────────────────────────────────────
|
|
656
|
+
const filterResult = this.checkToolFilter(toolName, args);
|
|
657
|
+
if (!filterResult.allowed) {
|
|
658
|
+
receipt.approved = false;
|
|
659
|
+
receipt.success = false;
|
|
660
|
+
receipt.error = filterResult.reason ?? `Tool '${toolName}' is not available.`;
|
|
661
|
+
receipt.duration = 0;
|
|
662
|
+
this.emit("tool:denied", { toolName, args, receipt, context, reason: "tool-filter" });
|
|
663
|
+
// Return a clear error that the LLM can understand
|
|
664
|
+
return new HarnessResult({
|
|
665
|
+
result: {
|
|
666
|
+
success: false,
|
|
667
|
+
error: filterResult.reason ?? `Tool '${toolName}' is not available in this session.`,
|
|
668
|
+
tool_not_available: true,
|
|
669
|
+
},
|
|
670
|
+
receipt,
|
|
671
|
+
denied: true,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
513
675
|
// ── 1. Permission check ──────────────────────────────────────────────────
|
|
514
676
|
const permResult = await this.permissions.check(toolName, args, context);
|
|
515
677
|
receipt.permissionLevel = permResult.level ?? "auto";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/project-settings.mjs — Per-project configuration for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Supports .wispy/settings.json in the project root (or any parent dir).
|
|
5
|
+
* Settings precedence (highest to lowest):
|
|
6
|
+
* 1. CLI flags
|
|
7
|
+
* 2. Project settings (.wispy/settings.json)
|
|
8
|
+
* 3. User profile settings (wispy -p <name>)
|
|
9
|
+
* 4. User config (~/.wispy/config.json)
|
|
10
|
+
* 5. Defaults
|
|
11
|
+
*
|
|
12
|
+
* Example .wispy/settings.json:
|
|
13
|
+
* {
|
|
14
|
+
* "model": "gpt-4o",
|
|
15
|
+
* "personality": "pragmatic",
|
|
16
|
+
* "effort": "high",
|
|
17
|
+
* "appendSystemPrompt": "This is a TypeScript project using Next.js 15.",
|
|
18
|
+
* "features": { "browser_integration": true },
|
|
19
|
+
* "tools": {
|
|
20
|
+
* "allow": ["run_command", "read_file", "write_file", "file_edit"],
|
|
21
|
+
* "deny": ["delete_file"]
|
|
22
|
+
* },
|
|
23
|
+
* "agents": {
|
|
24
|
+
* "styler": { "description": "CSS specialist", "prompt": "You are a CSS/Tailwind expert." }
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { readFile } from "node:fs/promises";
|
|
31
|
+
|
|
32
|
+
const SETTINGS_FILENAME = "settings.json";
|
|
33
|
+
const SETTINGS_DIR = ".wispy";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Walk up from startDir looking for .wispy/settings.json.
|
|
37
|
+
* Returns parsed settings object (with _projectRoot added) or null.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} startDir - Directory to start searching from (default: cwd)
|
|
40
|
+
* @returns {Promise<object|null>}
|
|
41
|
+
*/
|
|
42
|
+
export async function findProjectSettings(startDir = process.cwd()) {
|
|
43
|
+
let dir = path.resolve(startDir);
|
|
44
|
+
const root = path.parse(dir).root;
|
|
45
|
+
|
|
46
|
+
while (true) {
|
|
47
|
+
const settingsPath = path.join(dir, SETTINGS_DIR, SETTINGS_FILENAME);
|
|
48
|
+
try {
|
|
49
|
+
const raw = await readFile(settingsPath, "utf8");
|
|
50
|
+
const settings = JSON.parse(raw);
|
|
51
|
+
if (settings && typeof settings === "object") {
|
|
52
|
+
settings._projectRoot = dir;
|
|
53
|
+
settings._settingsPath = settingsPath;
|
|
54
|
+
return settings;
|
|
55
|
+
}
|
|
56
|
+
} catch { /* not found or parse error, keep walking */ }
|
|
57
|
+
|
|
58
|
+
const parent = path.dirname(dir);
|
|
59
|
+
if (parent === dir || dir === root) break;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Deep-merge settings with correct precedence.
|
|
67
|
+
* CLI flags > project settings > profile settings > base config > defaults.
|
|
68
|
+
*
|
|
69
|
+
* For object values (like "features", "tools", "agents"), performs shallow merge.
|
|
70
|
+
* For scalar values, higher-precedence wins.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} base - Base user config (~/.wispy/config.json)
|
|
73
|
+
* @param {object|null} project - Project settings (.wispy/settings.json)
|
|
74
|
+
* @param {object|null} profile - Named profile from config.profiles
|
|
75
|
+
* @param {object} cli - CLI flag overrides
|
|
76
|
+
* @returns {object} Merged settings
|
|
77
|
+
*/
|
|
78
|
+
export function mergeSettings(base = {}, project = null, profile = null, cli = {}) {
|
|
79
|
+
// Start with base config (strip profiles map to avoid leakage)
|
|
80
|
+
const { profiles: _profiles, ...baseClean } = base;
|
|
81
|
+
let merged = { ...baseClean };
|
|
82
|
+
|
|
83
|
+
// Apply profile on top (profile keys override base)
|
|
84
|
+
if (profile && typeof profile === "object") {
|
|
85
|
+
const { profiles: _pp, ...profileClean } = profile;
|
|
86
|
+
for (const [key, value] of Object.entries(profileClean)) {
|
|
87
|
+
if (value !== undefined) merged[key] = value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Apply project settings on top (project overrides profile + base)
|
|
92
|
+
if (project && typeof project === "object") {
|
|
93
|
+
for (const [key, value] of Object.entries(project)) {
|
|
94
|
+
if (key.startsWith("_")) continue; // skip internal metadata keys
|
|
95
|
+
if (value !== undefined && value !== null) {
|
|
96
|
+
// Deep merge objects (features, tools, agents)
|
|
97
|
+
if (
|
|
98
|
+
typeof value === "object" &&
|
|
99
|
+
!Array.isArray(value) &&
|
|
100
|
+
typeof merged[key] === "object" &&
|
|
101
|
+
!Array.isArray(merged[key]) &&
|
|
102
|
+
merged[key] !== null
|
|
103
|
+
) {
|
|
104
|
+
merged[key] = { ...merged[key], ...value };
|
|
105
|
+
} else {
|
|
106
|
+
merged[key] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Apply CLI flags last (highest precedence)
|
|
113
|
+
if (cli && typeof cli === "object") {
|
|
114
|
+
for (const [key, value] of Object.entries(cli)) {
|
|
115
|
+
if (value !== undefined && value !== null) {
|
|
116
|
+
merged[key] = value;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return merged;
|
|
122
|
+
}
|
package/core/session.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
|
23
23
|
import { SESSIONS_DIR } from "./config.mjs";
|
|
24
24
|
|
|
25
25
|
export class Session {
|
|
26
|
-
constructor({ id, workstream = "default", channel = null, chatId = null, messages = [], createdAt = null }) {
|
|
26
|
+
constructor({ id, workstream = "default", channel = null, chatId = null, messages = [], createdAt = null, name = null, model = null, cwd = null }) {
|
|
27
27
|
this.id = id;
|
|
28
28
|
this.workstream = workstream;
|
|
29
29
|
this.channel = channel;
|
|
@@ -31,14 +31,20 @@ export class Session {
|
|
|
31
31
|
this.messages = messages;
|
|
32
32
|
this.createdAt = createdAt ?? new Date().toISOString();
|
|
33
33
|
this.updatedAt = new Date().toISOString();
|
|
34
|
+
this.name = name ?? null; // user-given display name
|
|
35
|
+
this.model = model ?? null; // model used in this session
|
|
36
|
+
this.cwd = cwd ?? process.cwd(); // working directory when session was created
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
toJSON() {
|
|
37
40
|
return {
|
|
38
41
|
id: this.id,
|
|
42
|
+
name: this.name,
|
|
39
43
|
workstream: this.workstream,
|
|
40
44
|
channel: this.channel,
|
|
41
45
|
chatId: this.chatId,
|
|
46
|
+
model: this.model,
|
|
47
|
+
cwd: this.cwd,
|
|
42
48
|
messages: this.messages,
|
|
43
49
|
createdAt: this.createdAt,
|
|
44
50
|
updatedAt: this.updatedAt,
|
|
@@ -55,9 +61,9 @@ export class SessionManager {
|
|
|
55
61
|
/**
|
|
56
62
|
* Create a new session
|
|
57
63
|
*/
|
|
58
|
-
create({ workstream = "default", channel = null, chatId = null } = {}) {
|
|
64
|
+
create({ workstream = "default", channel = null, chatId = null, name = null, model = null, cwd = null } = {}) {
|
|
59
65
|
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
60
|
-
const session = new Session({ id, workstream, channel, chatId });
|
|
66
|
+
const session = new Session({ id, workstream, channel, chatId, name, model, cwd });
|
|
61
67
|
this._sessions.set(id, session);
|
|
62
68
|
if (channel && chatId) {
|
|
63
69
|
this._keyMap.set(`${channel}:${chatId}`, id);
|
|
@@ -136,6 +142,7 @@ export class SessionManager {
|
|
|
136
142
|
|
|
137
143
|
results.push({
|
|
138
144
|
id: data.id,
|
|
145
|
+
name: data.name ?? null,
|
|
139
146
|
workstream: data.workstream ?? "default",
|
|
140
147
|
channel: data.channel ?? null,
|
|
141
148
|
chatId: data.chatId ?? null,
|
|
@@ -184,6 +191,8 @@ export class SessionManager {
|
|
|
184
191
|
workstream: opts.workstream ?? source.workstream,
|
|
185
192
|
channel: opts.channel ?? null,
|
|
186
193
|
chatId: opts.chatId ?? null,
|
|
194
|
+
name: opts.name ?? null,
|
|
195
|
+
model: opts.model ?? source.model ?? null,
|
|
187
196
|
});
|
|
188
197
|
|
|
189
198
|
// Copy message history (deep copy)
|
|
@@ -196,6 +205,21 @@ export class SessionManager {
|
|
|
196
205
|
return forked;
|
|
197
206
|
}
|
|
198
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Set (or update) the display name of a session.
|
|
210
|
+
* Persists immediately to disk.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} id - Session ID
|
|
213
|
+
* @param {string} name - Display name
|
|
214
|
+
*/
|
|
215
|
+
async setName(id, name) {
|
|
216
|
+
const session = this._sessions.get(id);
|
|
217
|
+
if (!session) throw new Error(`Session not found: ${id}`);
|
|
218
|
+
session.name = name;
|
|
219
|
+
session.updatedAt = new Date().toISOString();
|
|
220
|
+
await this.save(id);
|
|
221
|
+
}
|
|
222
|
+
|
|
199
223
|
/**
|
|
200
224
|
* Add a message to a session.
|
|
201
225
|
*/
|
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