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.
- package/bin/wispy.mjs +293 -0
- package/core/config.mjs +29 -11
- package/core/features.mjs +225 -0
- package/core/loop-detector.mjs +183 -0
- package/core/memory.mjs +255 -0
- package/core/secrets.mjs +251 -0
- package/core/session.mjs +4 -1
- package/core/tts.mjs +194 -0
- package/lib/jsonl-emitter.mjs +101 -0
- package/package.json +1 -1
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. 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
|
+
}
|