wispy-cli 2.7.9 → 2.7.11

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,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. Environment variable (always takes priority — can be overridden at runtime)
48
+ if (process.env[key]) {
49
+ this._cache.set(key, process.env[key]);
50
+ return process.env[key];
51
+ }
52
+
53
+ // 2. In-memory cache (from previous keychain/secrets.json lookup)
54
+ if (this._cache.has(key)) return this._cache.get(key);
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/session.mjs CHANGED
@@ -5,6 +5,9 @@
5
5
  * - create({ workstream?, channel?, chatId? }) → Session
6
6
  * - get(id) → Session
7
7
  * - list(filter?) → Session[]
8
+ * - listSessions(options?) → metadata array (from disk)
9
+ * - loadSession(id) → full session with messages (alias for load)
10
+ * - forkSession(id) → new session copied from existing
8
11
  * - addMessage(id, message) → void
9
12
  * - clear(id) → void
10
13
  * - save(id) → void
@@ -16,7 +19,7 @@
16
19
 
17
20
  import os from "node:os";
18
21
  import path from "node:path";
19
- import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
22
+ import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
20
23
  import { SESSIONS_DIR } from "./config.mjs";
21
24
 
22
25
  export class Session {
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
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * lib/jsonl-emitter.mjs — Structured JSONL event emitter for CI/pipeline integration
3
+ *
4
+ * When --json flag is used, all events are emitted as newline-delimited JSON
5
+ * to stdout. Event types: start, user_message, assistant_message,
6
+ * tool_call, tool_result, error, done.
7
+ */
8
+
9
+ export class JsonlEmitter {
10
+ /**
11
+ * Emit a raw event object as a JSON line to stdout.
12
+ * @param {object} event
13
+ */
14
+ emit(event) {
15
+ process.stdout.write(JSON.stringify(event) + "\n");
16
+ }
17
+
18
+ /**
19
+ * Emit a "start" event with session metadata.
20
+ * @param {{ model?: string, session_id?: string, [key: string]: any }} meta
21
+ */
22
+ start(meta) {
23
+ this.emit({ type: "start", ...meta, timestamp: new Date().toISOString() });
24
+ }
25
+
26
+ /**
27
+ * Emit a "tool_call" event.
28
+ * @param {string} name - Tool name
29
+ * @param {object} args - Tool arguments
30
+ */
31
+ toolCall(name, args) {
32
+ this.emit({ type: "tool_call", name, args, timestamp: new Date().toISOString() });
33
+ }
34
+
35
+ /**
36
+ * Emit a "tool_result" event.
37
+ * @param {string} name - Tool name
38
+ * @param {string|object} result - Tool result
39
+ * @param {number} durationMs - Duration in milliseconds
40
+ */
41
+ toolResult(name, result, durationMs) {
42
+ this.emit({
43
+ type: "tool_result",
44
+ name,
45
+ result: typeof result === "string" ? result.slice(0, 1000) : result,
46
+ duration_ms: durationMs,
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Emit a user_message or assistant_message event.
52
+ * @param {"user"|"assistant"} role
53
+ * @param {string} content
54
+ */
55
+ message(role, content) {
56
+ this.emit({ type: `${role}_message`, content, timestamp: new Date().toISOString() });
57
+ }
58
+
59
+ /**
60
+ * Emit an "error" event.
61
+ * @param {Error|string} err
62
+ */
63
+ error(err) {
64
+ this.emit({
65
+ type: "error",
66
+ message: err?.message ?? String(err),
67
+ timestamp: new Date().toISOString(),
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Emit a "done" event with final stats.
73
+ * @param {{ tokens?: { input: number, output: number }, duration_ms?: number, [key: string]: any }} stats
74
+ */
75
+ done(stats) {
76
+ this.emit({ type: "done", ...stats, timestamp: new Date().toISOString() });
77
+ }
78
+ }
79
+
80
+ /**
81
+ * A no-op emitter used when --json flag is not set.
82
+ * All methods are silent.
83
+ */
84
+ export class NullEmitter {
85
+ emit() {}
86
+ start() {}
87
+ toolCall() {}
88
+ toolResult() {}
89
+ message() {}
90
+ error() {}
91
+ done() {}
92
+ }
93
+
94
+ /**
95
+ * Create the appropriate emitter based on whether JSON mode is active.
96
+ * @param {boolean} jsonMode
97
+ * @returns {JsonlEmitter|NullEmitter}
98
+ */
99
+ export function createEmitter(jsonMode) {
100
+ return jsonMode ? new JsonlEmitter() : new NullEmitter();
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.9",
3
+ "version": "2.7.11",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",