wispy-cli 2.7.10 → 2.7.12

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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * core/features.mjs — Feature flag system for Wispy
3
+ *
4
+ * Provides a registry of feature flags with stages (stable/experimental/development)
5
+ * and methods to enable/disable features, persisting state to config.
6
+ */
7
+
8
+ import path from "node:path";
9
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
10
+ import { WISPY_DIR, CONFIG_PATH, loadConfig, saveConfig } from "./config.mjs";
11
+
12
+ /**
13
+ * Registry of all known features.
14
+ * @type {Record<string, { stage: "stable"|"experimental"|"development", default: boolean, description: string }>}
15
+ */
16
+ const FEATURE_REGISTRY = {
17
+ // ── Stable features ──────────────────────────────────────────────────────
18
+ smart_routing: {
19
+ stage: "stable",
20
+ default: true,
21
+ description: "Route tasks to optimal models",
22
+ },
23
+ task_decomposition: {
24
+ stage: "stable",
25
+ default: true,
26
+ description: "Split complex tasks into parallel subtasks",
27
+ },
28
+ loop_detection: {
29
+ stage: "stable",
30
+ default: true,
31
+ description: "Detect and break tool-call loops",
32
+ },
33
+ context_compaction: {
34
+ stage: "stable",
35
+ default: true,
36
+ description: "Auto-compact context when approaching token limit",
37
+ },
38
+
39
+ // ── Experimental features ────────────────────────────────────────────────
40
+ browser_integration: {
41
+ stage: "experimental",
42
+ default: false,
43
+ description: "Native browser control via local-browser-bridge",
44
+ },
45
+ auto_memory: {
46
+ stage: "experimental",
47
+ default: false,
48
+ description: "Auto-extract facts from conversations to memory",
49
+ },
50
+ tts: {
51
+ stage: "experimental",
52
+ default: false,
53
+ description: "Text-to-speech output",
54
+ },
55
+
56
+ // ── Under development ────────────────────────────────────────────────────
57
+ multi_agent: {
58
+ stage: "development",
59
+ default: false,
60
+ description: "Multi-agent orchestration patterns",
61
+ },
62
+ cloud_sync: {
63
+ stage: "development",
64
+ default: false,
65
+ description: "Sync sessions to cloud",
66
+ },
67
+ image_generation: {
68
+ stage: "development",
69
+ default: false,
70
+ description: "Generate images from prompts",
71
+ },
72
+ };
73
+
74
+ export class FeatureManager {
75
+ /**
76
+ * @param {string} [configPath] - Path to config.json (defaults to ~/.wispy/config.json)
77
+ */
78
+ constructor(configPath) {
79
+ this._configPath = configPath ?? CONFIG_PATH;
80
+ /** @type {Record<string, boolean>} — cached overrides from config */
81
+ this._overrides = null;
82
+ }
83
+
84
+ /**
85
+ * Load feature overrides from config (lazy, cached per call).
86
+ * @returns {Promise<Record<string, boolean>>}
87
+ */
88
+ async _loadOverrides() {
89
+ if (this._overrides !== null) return this._overrides;
90
+ try {
91
+ const raw = await readFile(this._configPath, "utf8");
92
+ const cfg = JSON.parse(raw);
93
+ this._overrides = cfg.features ?? {};
94
+ } catch {
95
+ this._overrides = {};
96
+ }
97
+ return this._overrides;
98
+ }
99
+
100
+ /** Invalidate the cache so next read picks up fresh config. */
101
+ _invalidate() {
102
+ this._overrides = null;
103
+ }
104
+
105
+ /**
106
+ * Check if a feature is enabled.
107
+ * Config override takes precedence over registry default.
108
+ * Profile-level features (passed in opts) take precedence over global config.
109
+ * @param {string} name
110
+ * @param {Record<string, boolean>} [profileFeatures] - Profile-level overrides
111
+ * @returns {Promise<boolean>}
112
+ */
113
+ async isEnabled(name, profileFeatures = {}) {
114
+ const reg = FEATURE_REGISTRY[name];
115
+ if (!reg) return false; // Unknown features are disabled
116
+
117
+ // Profile override first
118
+ if (name in profileFeatures) return Boolean(profileFeatures[name]);
119
+
120
+ // Config override second
121
+ const overrides = await this._loadOverrides();
122
+ if (name in overrides) return Boolean(overrides[name]);
123
+
124
+ // Registry default
125
+ return reg.default;
126
+ }
127
+
128
+ /**
129
+ * Synchronous isEnabled using pre-loaded overrides.
130
+ * Call _loadOverrides() first if needed.
131
+ * @param {string} name
132
+ * @param {Record<string, boolean>} [overrides]
133
+ * @param {Record<string, boolean>} [profileFeatures]
134
+ * @returns {boolean}
135
+ */
136
+ isEnabledSync(name, overrides = {}, profileFeatures = {}) {
137
+ const reg = FEATURE_REGISTRY[name];
138
+ if (!reg) return false;
139
+ if (name in profileFeatures) return Boolean(profileFeatures[name]);
140
+ if (name in overrides) return Boolean(overrides[name]);
141
+ return reg.default;
142
+ }
143
+
144
+ /**
145
+ * Enable a feature and persist to config.
146
+ * @param {string} name
147
+ */
148
+ async enable(name) {
149
+ if (!FEATURE_REGISTRY[name]) {
150
+ throw new Error(`Unknown feature: "${name}". Use "wispy features" to see available features.`);
151
+ }
152
+ const raw = await this._readConfig();
153
+ if (!raw.features) raw.features = {};
154
+ raw.features[name] = true;
155
+ await this._writeConfig(raw);
156
+ this._invalidate();
157
+ return { success: true, name, enabled: true };
158
+ }
159
+
160
+ /**
161
+ * Disable a feature and persist to config.
162
+ * @param {string} name
163
+ */
164
+ async disable(name) {
165
+ if (!FEATURE_REGISTRY[name]) {
166
+ throw new Error(`Unknown feature: "${name}". Use "wispy features" to see available features.`);
167
+ }
168
+ const raw = await this._readConfig();
169
+ if (!raw.features) raw.features = {};
170
+ raw.features[name] = false;
171
+ await this._writeConfig(raw);
172
+ this._invalidate();
173
+ return { success: true, name, enabled: false };
174
+ }
175
+
176
+ /**
177
+ * List all features with their current status.
178
+ * @returns {Promise<Array<{ name: string, stage: string, enabled: boolean, default: boolean, description: string }>>}
179
+ */
180
+ async list() {
181
+ const overrides = await this._loadOverrides();
182
+ return Object.entries(FEATURE_REGISTRY).map(([name, meta]) => ({
183
+ name,
184
+ stage: meta.stage,
185
+ enabled: name in overrides ? Boolean(overrides[name]) : meta.default,
186
+ default: meta.default,
187
+ description: meta.description,
188
+ }));
189
+ }
190
+
191
+ /**
192
+ * Get the stage of a feature.
193
+ * @param {string} name
194
+ * @returns {"stable"|"experimental"|"development"|null}
195
+ */
196
+ getStage(name) {
197
+ return FEATURE_REGISTRY[name]?.stage ?? null;
198
+ }
199
+
200
+ async _readConfig() {
201
+ await mkdir(WISPY_DIR, { recursive: true });
202
+ try {
203
+ return JSON.parse(await readFile(this._configPath, "utf8"));
204
+ } catch {
205
+ return {};
206
+ }
207
+ }
208
+
209
+ async _writeConfig(cfg) {
210
+ await mkdir(WISPY_DIR, { recursive: true });
211
+ await writeFile(this._configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
212
+ }
213
+ }
214
+
215
+ /** Singleton instance */
216
+ let _instance = null;
217
+
218
+ /** Get or create the global FeatureManager instance. */
219
+ export function getFeatureManager(configPath) {
220
+ if (!_instance) _instance = new FeatureManager(configPath);
221
+ return _instance;
222
+ }
223
+
224
+ /** Export the registry for introspection. */
225
+ export { FEATURE_REGISTRY };
package/core/harness.mjs CHANGED
@@ -13,12 +13,145 @@
13
13
  */
14
14
 
15
15
  import { EventEmitter } from "node:events";
16
- import { readFile } from "node:fs/promises";
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
17
  import path from "node:path";
18
18
  import os from "node:os";
19
19
 
20
20
  import { EVENT_TYPES } from "./audit.mjs";
21
21
 
22
+ // ── Approval gate constants ────────────────────────────────────────────────────
23
+
24
+ // Tools that require approval depending on security mode
25
+ const DANGEROUS_TOOLS = new Set([
26
+ "run_command", "write_file", "file_edit", "delete_file",
27
+ "spawn_subagent", "browser_navigate",
28
+ ]);
29
+
30
+ // Tools that ALWAYS require approval (even in yolo mode)
31
+ const ALWAYS_APPROVE = new Set(["delete_file"]);
32
+
33
+ // System-path prefixes — writing here requires approval even in balanced mode
34
+ const SYSTEM_PATH_PREFIXES = ["/usr", "/etc", "/System", "/bin", "/sbin", "/Library/LaunchDaemons"];
35
+
36
+ const APPROVALS_PATH = path.join(os.homedir(), ".wispy", "approvals.json");
37
+
38
+ const DEFAULT_ALLOWLIST = {
39
+ run_command: ["npm test", "npm run build", "git status", "git diff", "ls"],
40
+ write_file: [],
41
+ file_edit: [],
42
+ delete_file: [],
43
+ spawn_subagent: [],
44
+ browser_navigate: [],
45
+ };
46
+
47
+ // ── Allowlist manager ─────────────────────────────────────────────────────────
48
+
49
+ export class ApprovalAllowlist {
50
+ constructor() {
51
+ this._list = null; // lazy load
52
+ }
53
+
54
+ async _load() {
55
+ if (this._list !== null) return;
56
+ try {
57
+ const raw = await readFile(APPROVALS_PATH, "utf8");
58
+ this._list = JSON.parse(raw);
59
+ } catch {
60
+ this._list = { ...DEFAULT_ALLOWLIST };
61
+ }
62
+ }
63
+
64
+ async _save() {
65
+ await mkdir(path.dirname(APPROVALS_PATH), { recursive: true });
66
+ await writeFile(APPROVALS_PATH, JSON.stringify(this._list, null, 2) + "\n", "utf8");
67
+ }
68
+
69
+ async matches(toolName, args) {
70
+ await this._load();
71
+ const patterns = this._list[toolName] ?? [];
72
+ if (patterns.length === 0) return false;
73
+
74
+ // Get a string representation of args for matching
75
+ const argStr = _getArgString(toolName, args);
76
+ if (!argStr) return false;
77
+
78
+ return patterns.some(pattern => {
79
+ // Glob-style: "*" matches everything
80
+ if (pattern === "*") return true;
81
+ // Prefix match or exact match
82
+ if (pattern.endsWith("*")) return argStr.startsWith(pattern.slice(0, -1));
83
+ return argStr === pattern || argStr.startsWith(pattern);
84
+ });
85
+ }
86
+
87
+ async add(toolName, pattern) {
88
+ await this._load();
89
+ if (!this._list[toolName]) this._list[toolName] = [];
90
+ if (!this._list[toolName].includes(pattern)) {
91
+ this._list[toolName].push(pattern);
92
+ await this._save();
93
+ }
94
+ }
95
+
96
+ async clear() {
97
+ this._list = {};
98
+ await this._save();
99
+ }
100
+
101
+ async reset() {
102
+ this._list = { ...DEFAULT_ALLOWLIST };
103
+ await this._save();
104
+ }
105
+
106
+ async getAll() {
107
+ await this._load();
108
+ return { ...this._list };
109
+ }
110
+ }
111
+
112
+ // Singleton allowlist instance
113
+ const globalAllowlist = new ApprovalAllowlist();
114
+
115
+ function _getArgString(toolName, args) {
116
+ if (!args) return "";
117
+ if (toolName === "run_command") return args.command ?? "";
118
+ if (toolName === "write_file" || toolName === "file_edit") return args.path ?? "";
119
+ if (toolName === "delete_file") return args.path ?? "";
120
+ if (toolName === "browser_navigate") return args.url ?? "";
121
+ if (toolName === "spawn_subagent") return args.task ?? "";
122
+ return JSON.stringify(args);
123
+ }
124
+
125
+ /**
126
+ * Determine if a tool+args needs approval based on security mode.
127
+ * @param {string} toolName
128
+ * @param {object} args
129
+ * @param {string} mode - "careful" | "balanced" | "yolo"
130
+ * @returns {boolean}
131
+ */
132
+ function _needsApproval(toolName, args, mode) {
133
+ if (ALWAYS_APPROVE.has(toolName)) return true;
134
+ if (!DANGEROUS_TOOLS.has(toolName)) return false;
135
+
136
+ if (mode === "careful") return true;
137
+
138
+ if (mode === "balanced") {
139
+ // Only destructive tools or writes to system paths
140
+ if (toolName === "delete_file") return true;
141
+ if (toolName === "write_file" || toolName === "file_edit") {
142
+ const filePath = args?.path ?? "";
143
+ const resolved = filePath.replace(/^~/, os.homedir());
144
+ return SYSTEM_PATH_PREFIXES.some(prefix => resolved.startsWith(prefix));
145
+ }
146
+ return false;
147
+ }
148
+
149
+ // yolo: only ALWAYS_APPROVE (handled above)
150
+ return false;
151
+ }
152
+
153
+ export { globalAllowlist };
154
+
22
155
  // ── Receipt ────────────────────────────────────────────────────────────────────
23
156
 
24
157
  export class Receipt {
@@ -349,6 +482,7 @@ export class Harness extends EventEmitter {
349
482
  this.permissions = permissions;
350
483
  this.audit = audit;
351
484
  this.config = config;
485
+ this.allowlist = globalAllowlist;
352
486
 
353
487
  // Sandbox config per-tool: "preview" | "diff" | null
354
488
  this._sandboxModes = {
package/core/index.mjs CHANGED
@@ -9,7 +9,8 @@ export { SessionManager, Session, sessionManager } from "./session.mjs";
9
9
  export { ProviderRegistry } from "./providers.mjs";
10
10
  export { ToolRegistry } from "./tools.mjs";
11
11
  export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
12
- export { loadConfig, saveConfig, detectProvider, isFirstRun, PROVIDERS, WISPY_DIR, CONFIG_PATH, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
12
+ export { loadConfig, saveConfig, loadConfigWithProfile, listProfiles, detectProvider, isFirstRun, PROVIDERS, WISPY_DIR, CONFIG_PATH, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
13
+ export { FeatureManager, getFeatureManager, FEATURE_REGISTRY } from "./features.mjs";
13
14
  export { OnboardingWizard, isFirstRun as checkFirstRun, printStatus } from "./onboarding.mjs";
14
15
  export { MemoryManager } from "./memory.mjs";
15
16
  export { CronManager } from "./cron.mjs";
@@ -0,0 +1,183 @@
1
+ /**
2
+ * core/loop-detector.mjs — Tool-call loop detection for Wispy
3
+ *
4
+ * Detects four patterns:
5
+ * 1. Exact repeat: same tool + same args hash 3+ times in window
6
+ * 2. Oscillation: A→B→A→B pattern (alternating between two calls)
7
+ * 3. No progress: 5+ tool calls where result hash doesn't change
8
+ * 4. Error loop: same tool fails 3+ times in a row
9
+ *
10
+ * v1.0.0
11
+ */
12
+
13
+ import { createHash } from "node:crypto";
14
+ import { EventEmitter } from "node:events";
15
+
16
+ /**
17
+ * Compute a short hash of any value.
18
+ */
19
+ function hashValue(value) {
20
+ if (value === null || value === undefined) return "null";
21
+ let str;
22
+ try {
23
+ str = JSON.stringify(value);
24
+ } catch {
25
+ str = String(value);
26
+ }
27
+ return createHash("sha1").update(str).digest("hex").slice(0, 16);
28
+ }
29
+
30
+ export class LoopDetector extends EventEmitter {
31
+ /**
32
+ * @param {object} options
33
+ * @param {number} options.windowSize - Look at last N calls (default 10)
34
+ * @param {number} options.maxRepeats - Same call N times = loop (default 3)
35
+ * @param {number} options.maxNoProgress - N calls with no new output = stuck (default 5)
36
+ */
37
+ constructor(options = {}) {
38
+ super();
39
+ this.windowSize = options.windowSize ?? 10;
40
+ this.maxRepeats = options.maxRepeats ?? 3;
41
+ this.maxNoProgress = options.maxNoProgress ?? 5;
42
+
43
+ // Array of { tool, argsHash, resultHash, isError, timestamp }
44
+ this._history = [];
45
+
46
+ // How many warnings have been emitted (to decide force-break)
47
+ this._warningCount = 0;
48
+ }
49
+
50
+ /**
51
+ * Record a tool call result.
52
+ * @param {string} toolName
53
+ * @param {object} args
54
+ * @param {any} result - The result (or Error/string on failure)
55
+ */
56
+ record(toolName, args, result) {
57
+ const argsHash = hashValue(args);
58
+ const isError = (result && typeof result === "object" && result.success === false)
59
+ || (result instanceof Error);
60
+
61
+ // Use error message as result hash if error, so errors are deduplicated
62
+ const resultForHash = isError
63
+ ? (result?.error ?? result?.message ?? "error")
64
+ : result;
65
+
66
+ const resultHash = hashValue(resultForHash);
67
+
68
+ this._history.push({
69
+ tool: toolName,
70
+ argsHash,
71
+ resultHash,
72
+ isError,
73
+ timestamp: Date.now(),
74
+ });
75
+
76
+ // Keep only the window
77
+ if (this._history.length > this.windowSize) {
78
+ this._history = this._history.slice(-this.windowSize);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Check if the agent is in a loop.
84
+ * @returns {{ looping: boolean, reason?: string, suggestion?: string, warningCount: number }}
85
+ */
86
+ check() {
87
+ const history = this._history;
88
+ if (history.length < 2) return { looping: false, warningCount: this._warningCount };
89
+
90
+ // ── 1. Exact repeat: same tool + same args hash N+ times in window ────────
91
+ const callKey = (e) => `${e.tool}:${e.argsHash}`;
92
+ const counts = new Map();
93
+ for (const entry of history) {
94
+ const key = callKey(entry);
95
+ counts.set(key, (counts.get(key) ?? 0) + 1);
96
+ }
97
+ for (const [key, count] of counts) {
98
+ if (count >= this.maxRepeats) {
99
+ const [tool] = key.split(":");
100
+ this._warningCount++;
101
+ return {
102
+ looping: true,
103
+ reason: `exact_repeat`,
104
+ tool,
105
+ count,
106
+ warningCount: this._warningCount,
107
+ suggestion: `Loop detected: you've called ${tool} with the same arguments ${count} times. Try a different approach or verify the outcome before retrying.`,
108
+ };
109
+ }
110
+ }
111
+
112
+ // ── 2. Oscillation: A→B→A→B pattern ─────────────────────────────────────
113
+ if (history.length >= 4) {
114
+ const last4 = history.slice(-4).map(callKey);
115
+ // A→B→A→B
116
+ if (last4[0] === last4[2] && last4[1] === last4[3] && last4[0] !== last4[1]) {
117
+ const toolA = history[history.length - 4].tool;
118
+ const toolB = history[history.length - 3].tool;
119
+ this._warningCount++;
120
+ return {
121
+ looping: true,
122
+ reason: `oscillation`,
123
+ tool: `${toolA}↔${toolB}`,
124
+ warningCount: this._warningCount,
125
+ suggestion: `Loop detected: oscillating between ${toolA} and ${toolB}. These calls aren't making progress. Try a different strategy.`,
126
+ };
127
+ }
128
+ }
129
+
130
+ // ── 3. No progress: N consecutive calls with identical result hash ────────
131
+ if (history.length >= this.maxNoProgress) {
132
+ const recent = history.slice(-this.maxNoProgress);
133
+ const firstHash = recent[0].resultHash;
134
+ // All results are the same AND none are "null" (null = tool produced nothing)
135
+ if (firstHash !== "null" && recent.every(e => e.resultHash === firstHash)) {
136
+ const tool = recent[recent.length - 1].tool;
137
+ this._warningCount++;
138
+ return {
139
+ looping: true,
140
+ reason: `no_progress`,
141
+ tool,
142
+ count: this.maxNoProgress,
143
+ warningCount: this._warningCount,
144
+ suggestion: `Loop detected: ${this.maxNoProgress} consecutive tool calls produced identical results. The agent appears to be stuck — try a different approach.`,
145
+ };
146
+ }
147
+ }
148
+
149
+ // ── 4. Error loop: same tool fails 3+ times in a row ─────────────────────
150
+ if (history.length >= 3) {
151
+ const recent = history.slice(-3);
152
+ if (recent.every(e => e.isError && e.tool === recent[0].tool)) {
153
+ const tool = recent[0].tool;
154
+ this._warningCount++;
155
+ return {
156
+ looping: true,
157
+ reason: `error_loop`,
158
+ tool,
159
+ count: 3,
160
+ warningCount: this._warningCount,
161
+ suggestion: `Loop detected: ${tool} has failed 3 times in a row. Stop retrying and try a different approach or report the error.`,
162
+ };
163
+ }
164
+ }
165
+
166
+ return { looping: false, warningCount: this._warningCount };
167
+ }
168
+
169
+ /**
170
+ * Reset the detector state (e.g., after user intervention or successful progress).
171
+ */
172
+ reset() {
173
+ this._history = [];
174
+ this._warningCount = 0;
175
+ }
176
+
177
+ /**
178
+ * Returns the number of recorded entries.
179
+ */
180
+ get size() {
181
+ return this._history.length;
182
+ }
183
+ }