wispy-cli 0.6.1 → 0.7.0

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.
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Channel Manager — loads config, starts/stops adapters, routes messages
3
+ * v0.7: uses core/engine.mjs and core/session.mjs for AI and session management
3
4
  *
4
5
  * Config: ~/.wispy/channels.json
5
6
  * {
@@ -7,19 +8,22 @@
7
8
  * "discord": { "enabled": true, "token": "..." },
8
9
  * "slack": { "enabled": true, "botToken": "...", "appToken": "..." }
9
10
  * }
10
- *
11
- * Per-chat conversation history is stored in ~/.wispy/channel-sessions/<channel>/<chatId>.json
12
11
  */
13
12
 
14
13
  import os from "node:os";
15
14
  import path from "node:path";
16
15
  import { mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { createInterface } from "node:readline";
17
+
18
+ import {
19
+ WispyEngine,
20
+ sessionManager,
21
+ WISPY_DIR,
22
+ } from "../../core/index.mjs";
17
23
 
18
24
  // ── Paths ─────────────────────────────────────────────────────────────────────
19
25
 
20
- const WISPY_DIR = path.join(os.homedir(), ".wispy");
21
26
  const CHANNELS_CONFIG = path.join(WISPY_DIR, "channels.json");
22
- const SESSIONS_DIR = path.join(WISPY_DIR, "channel-sessions");
23
27
 
24
28
  // ── Config helpers ────────────────────────────────────────────────────────────
25
29
 
@@ -37,245 +41,59 @@ export async function saveChannelsConfig(cfg) {
37
41
  await writeFile(CHANNELS_CONFIG, JSON.stringify(cfg, null, 2) + "\n", "utf8");
38
42
  }
39
43
 
40
- // ── Per-chat session storage ──────────────────────────────────────────────────
44
+ // ── Shared AI engine ──────────────────────────────────────────────────────────
41
45
 
42
- async function sessionPath(channelName, chatId) {
43
- const dir = path.join(SESSIONS_DIR, channelName);
44
- await mkdir(dir, { recursive: true });
45
- // Sanitize chatId to be filename-safe
46
- const safe = String(chatId).replace(/[^a-zA-Z0-9_\-]/g, "_");
47
- return path.join(dir, `${safe}.json`);
48
- }
46
+ let _sharedEngine = null;
49
47
 
50
- async function loadSession(channelName, chatId) {
51
- try {
52
- const raw = await readFile(await sessionPath(channelName, chatId), "utf8");
53
- return JSON.parse(raw);
54
- } catch {
55
- return [];
56
- }
57
- }
48
+ async function getEngine() {
49
+ if (_sharedEngine) return _sharedEngine;
58
50
 
59
- async function saveSession(channelName, chatId, messages) {
60
- const p = await sessionPath(channelName, chatId);
61
- const trimmed = messages.slice(-50);
62
- await writeFile(p, JSON.stringify(trimmed, null, 2) + "\n", "utf8");
63
- }
51
+ const engine = new WispyEngine({ workstream: "channels" });
52
+ const result = await engine.init();
53
+ if (!result) return null;
64
54
 
65
- async function clearSession(channelName, chatId) {
66
- await saveSession(channelName, chatId, []);
55
+ _sharedEngine = engine;
56
+ return engine;
67
57
  }
68
58
 
69
- // ── AI engine (minimal inline, reuses provider detection from wispy-repl logic)─
70
-
71
- import { createInterface } from "node:readline";
72
- import { appendFile } from "node:fs/promises";
73
-
74
- const PROVIDERS = {
75
- google: { envKeys: ["GOOGLE_AI_KEY", "GEMINI_API_KEY"], defaultModel: "gemini-2.5-flash" },
76
- anthropic: { envKeys: ["ANTHROPIC_API_KEY"], defaultModel: "claude-sonnet-4-20250514" },
77
- openai: { envKeys: ["OPENAI_API_KEY"], defaultModel: "gpt-4o" },
78
- openrouter: { envKeys: ["OPENROUTER_API_KEY"], defaultModel: "anthropic/claude-sonnet-4-20250514" },
79
- groq: { envKeys: ["GROQ_API_KEY"], defaultModel: "llama-3.3-70b-versatile" },
80
- deepseek: { envKeys: ["DEEPSEEK_API_KEY"], defaultModel: "deepseek-chat" },
81
- ollama: { envKeys: ["OLLAMA_HOST"], defaultModel: "llama3.2", local: true },
82
- };
83
-
84
- function getEnvKey(envKeys) {
85
- for (const k of envKeys) { if (process.env[k]) return process.env[k]; }
86
- return null;
87
- }
88
-
89
- async function tryKeychainKey(service) {
90
- try {
91
- const { execFile } = await import("node:child_process");
92
- const { promisify } = await import("node:util");
93
- const exec = promisify(execFile);
94
- const { stdout } = await exec("security", ["find-generic-password", "-s", service, "-a", "poropo", "-w"], { timeout: 3000 });
95
- return stdout.trim() || null;
96
- } catch { return null; }
97
- }
98
-
99
- let _detectedProvider = null;
100
-
101
- async function getProvider() {
102
- if (_detectedProvider) return _detectedProvider;
103
-
104
- // WISPY_PROVIDER override
105
- const forced = process.env.WISPY_PROVIDER;
106
- if (forced && PROVIDERS[forced]) {
107
- const key = getEnvKey(PROVIDERS[forced].envKeys);
108
- if (key || PROVIDERS[forced].local) {
109
- _detectedProvider = { provider: forced, key, model: process.env.WISPY_MODEL ?? PROVIDERS[forced].defaultModel };
110
- return _detectedProvider;
111
- }
112
- }
113
-
114
- // Config file
115
- try {
116
- const cfg = JSON.parse(await readFile(path.join(WISPY_DIR, "config.json"), "utf8"));
117
- if (cfg.provider && PROVIDERS[cfg.provider]) {
118
- const key = getEnvKey(PROVIDERS[cfg.provider].envKeys) ?? cfg.apiKey;
119
- if (key || PROVIDERS[cfg.provider].local) {
120
- _detectedProvider = { provider: cfg.provider, key, model: cfg.model ?? PROVIDERS[cfg.provider].defaultModel };
121
- return _detectedProvider;
122
- }
123
- }
124
- } catch {}
125
-
126
- // Auto-detect env
127
- for (const p of ["google","anthropic","openai","openrouter","groq","deepseek","ollama"]) {
128
- const key = getEnvKey(PROVIDERS[p].envKeys);
129
- if (key || (p === "ollama" && process.env.OLLAMA_HOST)) {
130
- _detectedProvider = { provider: p, key, model: process.env.WISPY_MODEL ?? PROVIDERS[p].defaultModel };
131
- return _detectedProvider;
132
- }
133
- }
134
-
135
- // macOS Keychain
136
- for (const [service, provider] of [["google-ai-key","google"],["anthropic-api-key","anthropic"],["openai-api-key","openai"]]) {
137
- const key = await tryKeychainKey(service);
138
- if (key) {
139
- process.env[PROVIDERS[provider].envKeys[0]] = key;
140
- _detectedProvider = { provider, key, model: process.env.WISPY_MODEL ?? PROVIDERS[provider].defaultModel };
141
- return _detectedProvider;
142
- }
143
- }
144
-
145
- return null;
146
- }
147
-
148
- const OPENAI_COMPAT = {
149
- openai: "https://api.openai.com/v1/chat/completions",
150
- openrouter: "https://openrouter.ai/api/v1/chat/completions",
151
- groq: "https://api.groq.com/openai/v1/chat/completions",
152
- deepseek: "https://api.deepseek.com/v1/chat/completions",
153
- ollama: `${process.env.OLLAMA_HOST ?? "http://localhost:11434"}/v1/chat/completions`,
154
- };
155
-
156
- async function callLLM(messages, providerInfo) {
157
- const { provider, key, model } = providerInfo;
158
-
159
- if (provider === "google") {
160
- return callGemini(messages, key, model);
161
- }
162
- if (provider === "anthropic") {
163
- return callAnthropic(messages, key, model);
164
- }
165
- return callOpenAICompat(messages, key, model, provider);
166
- }
167
-
168
- async function callGemini(messages, key, model) {
169
- const systemInstruction = messages.find(m => m.role === "system")?.content ?? "";
170
- const contents = messages
171
- .filter(m => m.role !== "system")
172
- .map(m => ({
173
- role: m.role === "assistant" ? "model" : "user",
174
- parts: [{ text: m.content }],
175
- }));
176
-
177
- const resp = await fetch(
178
- `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`,
179
- {
180
- method: "POST",
181
- headers: { "Content-Type": "application/json" },
182
- body: JSON.stringify({
183
- system_instruction: systemInstruction ? { parts: [{ text: systemInstruction }] } : undefined,
184
- contents,
185
- generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
186
- }),
187
- }
188
- );
189
- if (!resp.ok) throw new Error(`Gemini API error ${resp.status}: ${await resp.text().then(t => t.slice(0,200))}`);
190
- const data = await resp.json();
191
- return data.candidates?.[0]?.content?.parts?.map(p => p.text ?? "").join("") ?? "";
192
- }
193
-
194
- async function callAnthropic(messages, key, model) {
195
- const systemPrompt = messages.find(m => m.role === "system")?.content ?? "";
196
- const anthropicMessages = messages.filter(m => m.role !== "system").map(m => ({
197
- role: m.role === "assistant" ? "assistant" : "user",
198
- content: m.content,
199
- }));
200
-
201
- const resp = await fetch("https://api.anthropic.com/v1/messages", {
202
- method: "POST",
203
- headers: { "Content-Type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
204
- body: JSON.stringify({ model, max_tokens: 4096, system: systemPrompt, messages: anthropicMessages }),
205
- });
206
- if (!resp.ok) throw new Error(`Anthropic API error ${resp.status}: ${await resp.text().then(t => t.slice(0,200))}`);
207
- const data = await resp.json();
208
- return data.content?.map(c => c.text ?? "").join("") ?? "";
209
- }
210
-
211
- async function callOpenAICompat(messages, key, model, provider) {
212
- const endpoint = OPENAI_COMPAT[provider] ?? OPENAI_COMPAT.openai;
213
- const headers = { "Content-Type": "application/json" };
214
- if (key) headers["Authorization"] = `Bearer ${key}`;
215
- if (provider === "openrouter") headers["HTTP-Referer"] = "https://wispy.dev";
216
-
217
- const oaiMessages = messages.filter(m => m.role !== "tool_result").map(m => ({
218
- role: m.role === "assistant" ? "assistant" : m.role,
219
- content: m.content,
220
- }));
221
-
222
- const resp = await fetch(endpoint, {
223
- method: "POST",
224
- headers,
225
- body: JSON.stringify({ model, messages: oaiMessages, temperature: 0.7, max_tokens: 4096 }),
226
- });
227
- if (!resp.ok) throw new Error(`OpenAI-compat API error ${resp.status}: ${await resp.text().then(t => t.slice(0,200))}`);
228
- const data = await resp.json();
229
- return data.choices?.[0]?.message?.content ?? "";
230
- }
231
-
232
- const SYSTEM_PROMPT = `You are Wispy 🌿 — a small ghost AI assistant living in chat platforms.
59
+ // System prompt for channel context
60
+ const CHANNEL_SYSTEM_PROMPT = `You are Wispy 🌿 — a small ghost AI assistant living in chat platforms.
233
61
  You're helpful, concise, and friendly. You can answer questions, write code, help with tasks.
234
62
  Keep responses concise and conversational. End each message with 🌿.
235
63
  Respond in the same language the user writes in.`;
236
64
 
237
65
  /**
238
66
  * Process a user message and return Wispy's response.
239
- * Maintains per-chat conversation history.
67
+ * Uses core/engine.mjs for AI and core/session.mjs for per-chat sessions.
240
68
  */
241
69
  export async function processUserMessage(channelName, chatId, userText) {
242
70
  // Handle __CLEAR__ signal
243
71
  if (userText === "__CLEAR__") {
244
- await clearSession(channelName, chatId);
72
+ const key = `${channelName}:${chatId}`;
73
+ const session = await sessionManager.getOrCreate(key, { channel: channelName });
74
+ sessionManager.clear(session.id);
75
+ await sessionManager.save(session.id);
245
76
  return null; // Caller should send confirmation
246
77
  }
247
78
 
248
- const providerInfo = await getProvider();
249
- if (!providerInfo) {
79
+ const engine = await getEngine();
80
+ if (!engine) {
250
81
  return "⚠️ No AI provider configured. Run `wispy` first to set up your API key. 🌿";
251
82
  }
252
83
 
253
- // Load conversation history
254
- const history = await loadSession(channelName, chatId);
255
-
256
- // Build messages array
257
- const messages = [
258
- { role: "system", content: SYSTEM_PROMPT },
259
- ...history,
260
- { role: "user", content: userText },
261
- ];
84
+ // Get or create per-chat session
85
+ const key = `${channelName}:${chatId}`;
86
+ const session = await sessionManager.getOrCreate(key, { channel: channelName, chatId: String(chatId) });
262
87
 
263
- let response;
264
88
  try {
265
- response = await callLLM(messages, providerInfo);
89
+ const result = await engine.processMessage(session.id, userText, {
90
+ systemPrompt: CHANNEL_SYSTEM_PROMPT,
91
+ noSave: false,
92
+ });
93
+ return result.content;
266
94
  } catch (err) {
267
95
  return `❌ Error: ${err.message.slice(0, 200)} 🌿`;
268
96
  }
269
-
270
- // Save history (keep last 40 turns)
271
- const updatedHistory = [
272
- ...history,
273
- { role: "user", content: userText },
274
- { role: "assistant", content: response },
275
- ].slice(-40);
276
- await saveSession(channelName, chatId, updatedHistory);
277
-
278
- return response;
279
97
  }
280
98
 
281
99
  // ── ChannelManager ────────────────────────────────────────────────────────────
@@ -307,7 +125,6 @@ export class ChannelManager {
307
125
  continue;
308
126
  }
309
127
 
310
- // Check if at least one token is present
311
128
  const hasToken = this._hasToken(name, channelCfg);
312
129
  if (!hasToken) {
313
130
  console.log(`⚠ ${name}: no token configured — skipping (run \`wispy channel setup ${name}\`)`);
@@ -361,11 +178,12 @@ export class ChannelManager {
361
178
  }
362
179
  }
363
180
  this._adapters.clear();
181
+ if (_sharedEngine) { _sharedEngine.destroy(); _sharedEngine = null; }
364
182
  }
365
183
 
366
184
  _hasToken(name, cfg) {
367
185
  if (name === "telegram") return !!(process.env.WISPY_TELEGRAM_TOKEN ?? cfg.token);
368
- if (name === "discord") return !!(process.env.WISPY_DISCORD_TOKEN ?? cfg.token);
186
+ if (name === "discord") return !!(process.env.WISPY_DISCORD_TOKEN ?? cfg.token);
369
187
  if (name === "slack") return !!(
370
188
  (process.env.WISPY_SLACK_BOT_TOKEN ?? cfg.botToken) &&
371
189
  (process.env.WISPY_SLACK_APP_TOKEN ?? cfg.appToken)
@@ -381,7 +199,6 @@ export async function channelSetup(channelName) {
381
199
  const ask = (q) => new Promise(r => rl.question(q, r));
382
200
 
383
201
  const cfg = await loadChannelsConfig();
384
-
385
202
  console.log(`\n🌿 Setting up ${channelName} channel\n`);
386
203
 
387
204
  if (channelName === "telegram") {
@@ -409,9 +226,7 @@ export async function channelSetup(channelName) {
409
226
  else if (channelName === "slack") {
410
227
  console.log("1. Go to https://api.slack.com/apps → Create New App → From scratch");
411
228
  console.log("2. Enable Socket Mode → copy App-Level Token (starts with xapp-)");
412
- console.log("3. OAuth & Permissions → copy Bot User OAuth Token (starts with xoxb-)");
413
- console.log("4. Subscribe to: app_mention, message.im events");
414
- console.log("5. Add /wispy slash command\n");
229
+ console.log("3. OAuth & Permissions → copy Bot User OAuth Token (starts with xoxb-)\n");
415
230
  const botToken = (await ask(" Paste bot token (xoxb-...): ")).trim();
416
231
  const appToken = (await ask(" Paste app token (xapp-...): ")).trim();
417
232
  if (!botToken || !appToken) { rl.close(); return; }
@@ -434,14 +249,9 @@ export async function channelList() {
434
249
  console.log("\n🌿 Configured channels:\n");
435
250
  for (const name of channels) {
436
251
  const c = cfg[name];
437
- if (!c) {
438
- console.log(` ○ ${name.padEnd(10)} not configured`);
439
- continue;
440
- }
252
+ if (!c) { console.log(` ○ ${name.padEnd(10)} not configured`); continue; }
441
253
  const enabled = c.enabled !== false;
442
- const hasToken = name === "slack"
443
- ? !!(c.botToken && c.appToken)
444
- : !!c.token;
254
+ const hasToken = name === "slack" ? !!(c.botToken && c.appToken) : !!c.token;
445
255
  const status = !hasToken ? "no token" : enabled ? "✅ enabled" : "disabled";
446
256
  console.log(` ${enabled && hasToken ? "●" : "○"} ${name.padEnd(10)} ${status}`);
447
257
  }
@@ -463,9 +273,7 @@ export async function channelTest(channelName) {
463
273
  const me = await bot.api.getMe();
464
274
  console.log(`✅ Connected as @${me.username} (${me.first_name})`);
465
275
  await bot.stop().catch(() => {});
466
- } catch (err) {
467
- console.log(`❌ ${err.message}`);
468
- }
276
+ } catch (err) { console.log(`❌ ${err.message}`); }
469
277
  }
470
278
 
471
279
  else if (channelName === "discord") {
@@ -477,9 +285,7 @@ export async function channelTest(channelName) {
477
285
  await client.login(token);
478
286
  console.log(`✅ Connected as ${client.user?.tag}`);
479
287
  await client.destroy();
480
- } catch (err) {
481
- console.log(`❌ ${err.message}`);
482
- }
288
+ } catch (err) { console.log(`❌ ${err.message}`); }
483
289
  }
484
290
 
485
291
  else if (channelName === "slack") {
@@ -490,18 +296,11 @@ export async function channelTest(channelName) {
490
296
  headers: { Authorization: `Bearer ${botToken}` },
491
297
  });
492
298
  const data = await resp.json();
493
- if (data.ok) {
494
- console.log(`✅ Connected as @${data.user} on team ${data.team}`);
495
- } else {
496
- console.log(`❌ Slack error: ${data.error}`);
497
- }
498
- } catch (err) {
499
- console.log(`❌ ${err.message}`);
500
- }
299
+ if (data.ok) console.log(`✅ Connected as @${data.user} on team ${data.team}`);
300
+ else console.log(`❌ Slack error: ${data.error}`);
301
+ } catch (err) { console.log(`❌ ${err.message}`); }
501
302
  }
502
303
 
503
- else {
504
- console.log(`Unknown channel: ${channelName}`);
505
- }
304
+ else { console.log(`Unknown channel: ${channelName}`); }
506
305
  console.log("");
507
306
  }