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 +163 -0
- package/core/memory.mjs +12 -0
- package/core/secrets.mjs +251 -0
- package/core/tts.mjs +194 -0
- package/package.json +1 -1
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;
|
package/core/secrets.mjs
ADDED
|
@@ -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
|
+
};
|