wispy-cli 2.7.9 → 2.7.10

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 CHANGED
@@ -49,6 +49,8 @@ Usage:
49
49
  Manage configuration
50
50
  wispy model Show or change AI model
51
51
  wispy doctor Check system health
52
+ wispy browser [tabs|attach|navigate|screenshot|doctor]
53
+ Browser control via local-browser-bridge
52
54
  wispy trust [level|log] Security level & audit
53
55
  wispy ws [start-client|run-debug]
54
56
  WebSocket operations
@@ -161,6 +163,27 @@ if (command === "doctor") {
161
163
  for (const c of checks) {
162
164
  console.log(` ${c.ok ? "✓" : "✗"} ${c.name}`);
163
165
  }
166
+
167
+ // Browser bridge check
168
+ try {
169
+ const { BrowserBridge } = await import(join(rootDir, "core/browser.mjs"));
170
+ const bridge = new BrowserBridge();
171
+ const h = await bridge.health();
172
+ if (h?.ok || (!h?.error && h !== null)) {
173
+ const caps = await bridge.capabilities().catch(() => ({}));
174
+ const browsers = Array.isArray(caps?.browsers) ? caps.browsers : [];
175
+ const session = bridge._session;
176
+ const detail = browsers.length
177
+ ? `${browsers.join(", ")} available${session ? ", session active" : ""}`
178
+ : "running";
179
+ console.log(` ✓ Browser bridge ${detail}`);
180
+ } else {
181
+ console.log(` ✗ Browser bridge not running (start with: npx local-browser-bridge serve)`);
182
+ }
183
+ } catch {
184
+ console.log(` ✗ Browser bridge not running (start with: npx local-browser-bridge serve)`);
185
+ }
186
+
164
187
  console.log("");
165
188
 
166
189
  if (!provider?.key) {
@@ -175,6 +198,146 @@ if (command === "doctor") {
175
198
  process.exit(0);
176
199
  }
177
200
 
201
+ // ── Browser ───────────────────────────────────────────────────────────────────
202
+
203
+ if (command === "browser") {
204
+ try {
205
+ const { BrowserBridge } = await import(join(rootDir, "core/browser.mjs"));
206
+ const bridge = new BrowserBridge();
207
+ const sub = args[1];
208
+
209
+ if (!sub || sub === "status") {
210
+ // wispy browser — show status
211
+ const h = await bridge.health();
212
+ const status = bridge.status();
213
+ const running = h?.ok || (!h?.error && h !== null);
214
+ console.log(`\n Browser Bridge`);
215
+ console.log(` URL: ${bridge.baseUrl}`);
216
+ console.log(` Status: ${running ? "✓ running" : "✗ not running"}`);
217
+ if (!running) {
218
+ console.log(` Hint: npx local-browser-bridge serve`);
219
+ } else {
220
+ const caps = await bridge.capabilities().catch(() => ({}));
221
+ if (caps?.browsers?.length) {
222
+ console.log(` Browsers: ${caps.browsers.join(", ")}`);
223
+ }
224
+ }
225
+ if (status.session) {
226
+ console.log(` Session: ${status.session.id} (${status.session.browser ?? "unknown"})`);
227
+ } else {
228
+ console.log(` Session: none`);
229
+ }
230
+ console.log("");
231
+ } else if (sub === "tabs") {
232
+ // wispy browser tabs
233
+ const browser = args[2];
234
+ const result = await bridge.listTabs(browser);
235
+ if (result?.error) {
236
+ console.error(` ✗ ${result.error}`);
237
+ } else {
238
+ const tabs = result?.tabs ?? result;
239
+ console.log(`\n Open tabs:`);
240
+ if (Array.isArray(tabs)) {
241
+ for (const t of tabs) {
242
+ const title = t.title ?? t.name ?? "(no title)";
243
+ const url = t.url ?? "";
244
+ console.log(` • ${title}${url ? ` — ${url}` : ""}`);
245
+ }
246
+ } else {
247
+ console.log(JSON.stringify(tabs, null, 2));
248
+ }
249
+ console.log("");
250
+ }
251
+ } else if (sub === "attach") {
252
+ // wispy browser attach [browser]
253
+ const browser = args[2];
254
+ console.log(`\n Attaching to ${browser ?? "best available browser"}...`);
255
+ let result;
256
+ if (browser) {
257
+ result = await bridge.attach(browser);
258
+ } else {
259
+ result = await bridge.autoAttach();
260
+ }
261
+ if (result?.error) {
262
+ console.error(` ✗ ${result.error}`);
263
+ } else {
264
+ const id = result?.id ?? result?.sessionId ?? "?";
265
+ const br = result?.browser ?? browser ?? "unknown";
266
+ console.log(` ✓ Attached (${br}, session: ${id})`);
267
+ }
268
+ console.log("");
269
+ } else if (sub === "navigate") {
270
+ // wispy browser navigate <url>
271
+ const url = args[2];
272
+ if (!url) { console.error(" Usage: wispy browser navigate <url>"); process.exit(1); }
273
+ console.log(`\n Navigating to ${url}...`);
274
+ const result = await bridge.navigate(url);
275
+ if (result?.error) {
276
+ console.error(` ✗ ${result.error}`);
277
+ } else {
278
+ console.log(` ✓ Navigated`);
279
+ }
280
+ console.log("");
281
+ } else if (sub === "screenshot") {
282
+ // wispy browser screenshot
283
+ const result = await bridge.screenshot();
284
+ if (result?.error) {
285
+ console.error(` ✗ ${result.error}`);
286
+ } else {
287
+ const data = result?.screenshot ?? result?.data;
288
+ if (data) {
289
+ console.log(`\n Screenshot captured (${data.length} base64 chars)`);
290
+ // Optionally save to file
291
+ const outFile = args[2] ?? `wispy-screenshot-${Date.now()}.png`;
292
+ const buf = Buffer.from(data, "base64");
293
+ const { writeFile: wf } = await import("node:fs/promises");
294
+ await wf(outFile, buf);
295
+ console.log(` Saved to: ${outFile}`);
296
+ } else {
297
+ console.log(` Screenshot result:`, JSON.stringify(result, null, 2));
298
+ }
299
+ console.log("");
300
+ }
301
+ } else if (sub === "doctor") {
302
+ // wispy browser doctor — full diagnostics
303
+ console.log(`\n Browser Bridge Diagnostics`);
304
+ console.log(` URL: ${bridge.baseUrl}`);
305
+
306
+ const h = await bridge.health();
307
+ const running = h?.ok || (!h?.error && h !== null);
308
+ console.log(` Health: ${running ? "✓ ok" : "✗ not running"}`);
309
+
310
+ if (running) {
311
+ const caps = await bridge.capabilities();
312
+ console.log(`\n Capabilities:`);
313
+ console.log(JSON.stringify(caps, null, 2).split("\n").map(l => " " + l).join("\n"));
314
+
315
+ const browsers = Array.isArray(caps?.browsers) ? caps.browsers : [];
316
+ for (const br of browsers) {
317
+ const diag = await bridge.diagnostics(br);
318
+ console.log(`\n Diagnostics (${br}):`);
319
+ console.log(JSON.stringify(diag, null, 2).split("\n").map(l => " " + l).join("\n"));
320
+ }
321
+
322
+ const sessions = await bridge.listSessions();
323
+ console.log(`\n Sessions:`);
324
+ console.log(JSON.stringify(sessions, null, 2).split("\n").map(l => " " + l).join("\n"));
325
+ } else {
326
+ console.log(` Start with: npx local-browser-bridge serve`);
327
+ }
328
+ console.log("");
329
+ } else {
330
+ console.error(` Unknown subcommand: ${sub}`);
331
+ console.log(` Usage: wispy browser [status|tabs|attach|navigate <url>|screenshot|doctor]`);
332
+ process.exit(1);
333
+ }
334
+ } catch (err) {
335
+ console.error("Browser command error:", err.message);
336
+ process.exit(1);
337
+ }
338
+ process.exit(0);
339
+ }
340
+
178
341
  // ── Trust ─────────────────────────────────────────────────────────────────────
179
342
 
180
343
  if (command === "trust") {
package/core/memory.mjs CHANGED
@@ -6,6 +6,12 @@
6
6
  * - daily/YYYY-MM-DD.md — daily logs
7
7
  * - projects/<name>.md — project-specific memory
8
8
  * - user.md — user preferences/info
9
+ *
10
+ * v2.8+ enhancements:
11
+ * - getRelevantMemories() — keyword + recency scoring for context injection
12
+ * - autoExtractFacts() — auto-flush important facts from conversation
13
+ * - Fuzzy search with recency + frequency weighting
14
+ * - Access frequency tracking
9
15
  */
10
16
 
11
17
  import path from "node:path";
@@ -13,10 +19,14 @@ import { readFile, writeFile, appendFile, mkdir, readdir, unlink, stat } from "n
13
19
 
14
20
  const MAX_SEARCH_RESULTS = 20;
15
21
  const MAX_SNIPPET_CHARS = 200;
22
+ // How many messages must accumulate before autoExtractFacts triggers
23
+ const AUTO_EXTRACT_INTERVAL = 10;
16
24
 
17
25
  export class MemoryManager {
18
26
  constructor(wispyDir) {
19
27
  this.memoryDir = path.join(wispyDir, "memory");
28
+ // Frequency tracking: key → access count (in-memory only, resets per session)
29
+ this._accessFrequency = new Map();
20
30
  }
21
31
 
22
32
  /**
@@ -88,6 +98,8 @@ export class MemoryManager {
88
98
  const filePath = this._keyToPath(key);
89
99
  try {
90
100
  const content = await readFile(filePath, "utf8");
101
+ // Track access frequency
102
+ this._accessFrequency.set(key, (this._accessFrequency.get(key) ?? 0) + 1);
91
103
  return { key, content, path: filePath };
92
104
  } catch {
93
105
  return null;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * core/secrets.mjs — Secrets Manager for Wispy
3
+ *
4
+ * Resolution chain: env var → macOS Keychain → encrypted secrets.json → null
5
+ * Encryption: AES-256-GCM with machine-derived key (hostname + username)
6
+ * Scrubbing: never logs actual secret values
7
+ */
8
+
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
12
+ import { existsSync } from "node:fs";
13
+ import { createHash, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
14
+
15
+ const ALGORITHM = "aes-256-gcm";
16
+ const KEY_LENGTH = 32;
17
+ const IV_LENGTH = 12;
18
+ const TAG_LENGTH = 16;
19
+
20
+ /**
21
+ * Derive a deterministic encryption key from machine identity.
22
+ * Uses hostname + username hash — not cryptographically perfect but
23
+ * provides at-rest protection from casual disk reads.
24
+ */
25
+ function deriveMachineKey() {
26
+ const machineId = `${os.hostname()}::${os.userInfo().username}`;
27
+ return createHash("sha256").update(machineId).digest(); // 32 bytes
28
+ }
29
+
30
+ export class SecretsManager {
31
+ constructor(options = {}) {
32
+ this.wispyDir = options.wispyDir ?? path.join(os.homedir(), ".wispy");
33
+ this.secretsFile = path.join(this.wispyDir, "secrets.json");
34
+ this._cache = new Map();
35
+ this._machineKey = deriveMachineKey();
36
+ }
37
+
38
+ /**
39
+ * Resolution chain: env var → macOS Keychain → encrypted secrets.json → null
40
+ *
41
+ * @param {string} key - e.g. "OPENAI_API_KEY"
42
+ * @returns {Promise<string|null>}
43
+ */
44
+ async resolve(key) {
45
+ if (!key) return null;
46
+
47
+ // 1. In-memory cache
48
+ if (this._cache.has(key)) return this._cache.get(key);
49
+
50
+ // 2. Environment variable
51
+ if (process.env[key]) {
52
+ this._cache.set(key, process.env[key]);
53
+ return process.env[key];
54
+ }
55
+
56
+ // 3. macOS Keychain — try common service name patterns
57
+ const keychainValue = await this._fromKeychain(key, null);
58
+ if (keychainValue) {
59
+ this._cache.set(key, keychainValue);
60
+ return keychainValue;
61
+ }
62
+
63
+ // 4. Encrypted secrets.json
64
+ const stored = await this._fromSecretsFile(key);
65
+ if (stored) {
66
+ this._cache.set(key, stored);
67
+ return stored;
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Store a secret (encrypts before writing to secrets.json)
75
+ */
76
+ async set(key, value) {
77
+ if (!key || value === undefined || value === null) {
78
+ throw new Error("key and value are required");
79
+ }
80
+
81
+ // Update cache
82
+ this._cache.set(key, value);
83
+
84
+ // Load existing secrets
85
+ const secrets = await this._loadSecretsFile();
86
+
87
+ // Encrypt and store
88
+ secrets[key] = this._encrypt(value);
89
+
90
+ await this._saveSecretsFile(secrets);
91
+ return { key, stored: true };
92
+ }
93
+
94
+ /**
95
+ * Delete a secret from cache + secrets.json
96
+ */
97
+ async delete(key) {
98
+ this._cache.delete(key);
99
+
100
+ const secrets = await this._loadSecretsFile();
101
+ if (!(key in secrets)) {
102
+ return { success: false, key, error: "Key not found" };
103
+ }
104
+
105
+ delete secrets[key];
106
+ await this._saveSecretsFile(secrets);
107
+ return { success: true, key };
108
+ }
109
+
110
+ /**
111
+ * List stored secret keys (not values)
112
+ */
113
+ async list() {
114
+ const secrets = await this._loadSecretsFile();
115
+ return Object.keys(secrets);
116
+ }
117
+
118
+ /**
119
+ * macOS Keychain integration
120
+ * Tries common wispy service names, then the key itself as service name
121
+ */
122
+ async _fromKeychain(service, account) {
123
+ if (process.platform !== "darwin") return null;
124
+
125
+ try {
126
+ const { execFile } = await import("node:child_process");
127
+ const { promisify } = await import("node:util");
128
+ const exec = promisify(execFile);
129
+
130
+ // Map common env var names to keychain service names
131
+ const serviceMap = {
132
+ OPENAI_API_KEY: "openai-api-key",
133
+ ANTHROPIC_API_KEY: "anthropic-api-key",
134
+ GOOGLE_AI_KEY: "google-ai-key",
135
+ GOOGLE_GENERATIVE_AI_KEY: "google-ai-key",
136
+ GEMINI_API_KEY: "google-ai-key",
137
+ GROQ_API_KEY: "groq-api-key",
138
+ DEEPSEEK_API_KEY: "deepseek-api-key",
139
+ };
140
+
141
+ const keychainService = serviceMap[service] ?? service.toLowerCase().replace(/_/g, "-");
142
+ const accounts = account ? [account] : ["wispy", "poropo"];
143
+
144
+ for (const acc of accounts) {
145
+ try {
146
+ const { stdout } = await exec(
147
+ "security",
148
+ ["find-generic-password", "-s", keychainService, "-a", acc, "-w"],
149
+ { timeout: 2000 }
150
+ );
151
+ const val = stdout.trim();
152
+ if (val) return val;
153
+ } catch { /* try next */ }
154
+ }
155
+
156
+ // Fallback: no account filter
157
+ try {
158
+ const { stdout } = await exec(
159
+ "security",
160
+ ["find-generic-password", "-s", keychainService, "-w"],
161
+ { timeout: 2000 }
162
+ );
163
+ const val = stdout.trim();
164
+ if (val) return val;
165
+ } catch { /* not found */ }
166
+ } catch { /* keychain unavailable */ }
167
+
168
+ return null;
169
+ }
170
+
171
+ // ── Encryption helpers ───────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Encrypt plaintext → base64 string (iv:tag:ciphertext)
175
+ */
176
+ _encrypt(plaintext) {
177
+ const iv = randomBytes(IV_LENGTH);
178
+ const cipher = createCipheriv(ALGORITHM, this._machineKey, iv);
179
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
180
+ const tag = cipher.getAuthTag();
181
+ // Encode as hex parts joined by ":"
182
+ return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`;
183
+ }
184
+
185
+ /**
186
+ * Decrypt iv:tag:ciphertext → plaintext string
187
+ */
188
+ _decrypt(ciphertext) {
189
+ const [ivHex, tagHex, encHex] = ciphertext.split(":");
190
+ if (!ivHex || !tagHex || !encHex) throw new Error("Invalid ciphertext format");
191
+
192
+ const iv = Buffer.from(ivHex, "hex");
193
+ const tag = Buffer.from(tagHex, "hex");
194
+ const enc = Buffer.from(encHex, "hex");
195
+
196
+ const decipher = createDecipheriv(ALGORITHM, this._machineKey, iv);
197
+ decipher.setAuthTag(tag);
198
+ return decipher.update(enc) + decipher.final("utf8");
199
+ }
200
+
201
+ // ── File helpers ─────────────────────────────────────────────────────────────
202
+
203
+ async _loadSecretsFile() {
204
+ try {
205
+ const raw = await readFile(this.secretsFile, "utf8");
206
+ return JSON.parse(raw);
207
+ } catch {
208
+ return {};
209
+ }
210
+ }
211
+
212
+ async _saveSecretsFile(secrets) {
213
+ await mkdir(this.wispyDir, { recursive: true });
214
+ await writeFile(this.secretsFile, JSON.stringify(secrets, null, 2) + "\n", "utf8");
215
+ }
216
+
217
+ async _fromSecretsFile(key) {
218
+ const secrets = await this._loadSecretsFile();
219
+ if (!(key in secrets)) return null;
220
+ try {
221
+ return this._decrypt(secrets[key]);
222
+ } catch {
223
+ return null; // Corrupted entry — skip
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Scrub secret values from an object (for logging/audit)
229
+ * Replaces any value that looks like a key we know about with "***"
230
+ */
231
+ scrub(obj) {
232
+ if (typeof obj !== "object" || obj === null) return obj;
233
+ const knownKeys = new Set([...this._cache.keys()]);
234
+ const result = {};
235
+ for (const [k, v] of Object.entries(obj)) {
236
+ if (knownKeys.has(k) || /key|token|secret|password|credential/i.test(k)) {
237
+ result[k] = "***";
238
+ } else {
239
+ result[k] = v;
240
+ }
241
+ }
242
+ return result;
243
+ }
244
+ }
245
+
246
+ /** Singleton factory — reuse across modules */
247
+ let _instance = null;
248
+ export function getSecretsManager(options = {}) {
249
+ if (!_instance) _instance = new SecretsManager(options);
250
+ return _instance;
251
+ }
package/core/tts.mjs ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * core/tts.mjs — Text-to-Speech Manager for Wispy
3
+ *
4
+ * Auto-detects available TTS provider:
5
+ * 1. OpenAI TTS API (best quality, requires OPENAI_API_KEY)
6
+ * 2. macOS native `say` command (free, always available on macOS)
7
+ *
8
+ * Usage:
9
+ * const tts = new TTSManager(secretsManager);
10
+ * const result = await tts.speak("Hello world");
11
+ * // result.path → audio file path
12
+ */
13
+
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { writeFile } from "node:fs/promises";
17
+
18
+ export class TTSManager {
19
+ constructor(secretsManager) {
20
+ this.secrets = secretsManager;
21
+ this._provider = null; // cached auto-detected provider
22
+ }
23
+
24
+ /**
25
+ * Auto-detect available TTS provider.
26
+ * Order: OpenAI (best quality) → macOS say (free) → null
27
+ */
28
+ async detectProvider() {
29
+ if (this._provider) return this._provider;
30
+
31
+ const openaiKey = await this.secrets.resolve("OPENAI_API_KEY");
32
+ if (openaiKey) {
33
+ this._provider = "openai";
34
+ return "openai";
35
+ }
36
+
37
+ if (process.platform === "darwin") {
38
+ this._provider = "macos";
39
+ return "macos";
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Generate speech from text.
47
+ *
48
+ * @param {string} text - Text to speak
49
+ * @param {object} options
50
+ * @param {string} [options.provider] - "openai" | "macos" | "auto"
51
+ * @param {string} [options.voice] - Voice name
52
+ * @param {string} [options.model] - OpenAI TTS model
53
+ * @param {string} [options.format] - Output format (openai: mp3/opus/aac/flac, macos: aiff)
54
+ * @param {number} [options.rate] - Speech rate (macOS only)
55
+ * @returns {Promise<{provider, path, format, voice}|{error}>}
56
+ */
57
+ async speak(text, options = {}) {
58
+ const providerOpt = options.provider ?? "auto";
59
+ const provider = providerOpt === "auto"
60
+ ? await this.detectProvider()
61
+ : providerOpt;
62
+
63
+ switch (provider) {
64
+ case "openai":
65
+ return this._openaiTTS(text, options);
66
+ case "macos":
67
+ return this._macosTTS(text, options);
68
+ default:
69
+ return { error: "No TTS provider available. Set OPENAI_API_KEY or use macOS." };
70
+ }
71
+ }
72
+
73
+ /**
74
+ * OpenAI TTS API
75
+ * https://platform.openai.com/docs/api-reference/audio/createSpeech
76
+ */
77
+ async _openaiTTS(text, {
78
+ voice = "alloy",
79
+ model = "tts-1",
80
+ format = "mp3",
81
+ } = {}) {
82
+ const apiKey = await this.secrets.resolve("OPENAI_API_KEY");
83
+ if (!apiKey) {
84
+ return { error: "OPENAI_API_KEY not found" };
85
+ }
86
+
87
+ const response = await fetch("https://api.openai.com/v1/audio/speech", {
88
+ method: "POST",
89
+ headers: {
90
+ "Authorization": `Bearer ${apiKey}`,
91
+ "Content-Type": "application/json",
92
+ },
93
+ body: JSON.stringify({
94
+ model,
95
+ input: text,
96
+ voice,
97
+ response_format: format,
98
+ }),
99
+ });
100
+
101
+ if (!response.ok) {
102
+ const errText = await response.text().catch(() => "unknown error");
103
+ return { error: `OpenAI TTS failed: ${response.status} ${errText}` };
104
+ }
105
+
106
+ const buffer = Buffer.from(await response.arrayBuffer());
107
+ const outputPath = path.join(os.tmpdir(), `wispy-tts-${Date.now()}.${format}`);
108
+ await writeFile(outputPath, buffer);
109
+
110
+ return { provider: "openai", path: outputPath, format, voice };
111
+ }
112
+
113
+ /**
114
+ * macOS native TTS using `say` command
115
+ */
116
+ async _macosTTS(text, {
117
+ voice = "Samantha",
118
+ rate = 200,
119
+ } = {}) {
120
+ if (process.platform !== "darwin") {
121
+ return { error: "macOS TTS is only available on macOS" };
122
+ }
123
+
124
+ const outputPath = path.join(os.tmpdir(), `wispy-tts-${Date.now()}.aiff`);
125
+
126
+ const { execFile } = await import("node:child_process");
127
+ const { promisify } = await import("node:util");
128
+ const exec = promisify(execFile);
129
+
130
+ try {
131
+ await exec("say", ["-v", voice, "-r", String(rate), "-o", outputPath, text], {
132
+ timeout: 30000,
133
+ });
134
+ } catch (err) {
135
+ return { error: `macOS TTS failed: ${err.message}` };
136
+ }
137
+
138
+ return { provider: "macos", path: outputPath, format: "aiff", voice };
139
+ }
140
+
141
+ /**
142
+ * List available macOS voices
143
+ */
144
+ async listMacOSVoices() {
145
+ if (process.platform !== "darwin") return [];
146
+ try {
147
+ const { execFile } = await import("node:child_process");
148
+ const { promisify } = await import("node:util");
149
+ const exec = promisify(execFile);
150
+ const { stdout } = await exec("say", ["-v", "?"], { timeout: 5000 });
151
+ return stdout.trim().split("\n").map(line => {
152
+ const parts = line.trim().split(/\s+/);
153
+ return { name: parts[0], locale: parts[1] };
154
+ });
155
+ } catch {
156
+ return [];
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Tool definition for ToolRegistry integration
163
+ */
164
+ export const TTS_TOOL_DEFINITION = {
165
+ name: "text_to_speech",
166
+ description: "Convert text to speech audio file. Returns the path to the generated audio file.",
167
+ parameters: {
168
+ type: "object",
169
+ properties: {
170
+ text: {
171
+ type: "string",
172
+ description: "Text to convert to speech",
173
+ },
174
+ voice: {
175
+ type: "string",
176
+ description: "Voice name (openai: alloy/echo/fable/onyx/nova/shimmer, macos: Samantha/Alex/Victoria/etc)",
177
+ },
178
+ provider: {
179
+ type: "string",
180
+ enum: ["openai", "macos", "auto"],
181
+ description: "TTS provider to use (default: auto-detect)",
182
+ },
183
+ model: {
184
+ type: "string",
185
+ description: "TTS model (OpenAI only: tts-1 or tts-1-hd)",
186
+ },
187
+ rate: {
188
+ type: "number",
189
+ description: "Speech rate in words per minute (macOS only, default: 200)",
190
+ },
191
+ },
192
+ required: ["text"],
193
+ },
194
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.9",
3
+ "version": "2.7.10",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",