wispy-cli 0.6.1 → 0.8.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.
- package/bin/wispy.mjs +172 -2
- package/core/config.mjs +104 -0
- package/core/cron.mjs +346 -0
- package/core/engine.mjs +705 -0
- package/core/index.mjs +14 -0
- package/core/mcp.mjs +8 -0
- package/core/memory.mjs +275 -0
- package/core/providers.mjs +410 -0
- package/core/session.mjs +196 -0
- package/core/tools.mjs +526 -0
- package/lib/channels/index.mjs +45 -246
- package/lib/wispy-repl.mjs +396 -2452
- package/lib/wispy-tui.mjs +105 -588
- package/package.json +7 -4
package/core/index.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/index.mjs — Public API for wispy-core
|
|
3
|
+
*
|
|
4
|
+
* All surfaces (CLI, TUI, channels) import from here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { WispyEngine, getDefaultEngine } from "./engine.mjs";
|
|
8
|
+
export { SessionManager, Session, sessionManager } from "./session.mjs";
|
|
9
|
+
export { ProviderRegistry } from "./providers.mjs";
|
|
10
|
+
export { ToolRegistry } from "./tools.mjs";
|
|
11
|
+
export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "./mcp.mjs";
|
|
12
|
+
export { loadConfig, saveConfig, detectProvider, PROVIDERS, WISPY_DIR, MCP_CONFIG_PATH, SESSIONS_DIR, CONVERSATIONS_DIR, MEMORY_DIR } from "./config.mjs";
|
|
13
|
+
export { MemoryManager } from "./memory.mjs";
|
|
14
|
+
export { CronManager } from "./cron.mjs";
|
package/core/mcp.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/mcp.mjs — MCP client (re-export from lib/mcp-client.mjs)
|
|
3
|
+
*
|
|
4
|
+
* MCPClient and MCPManager are moved here as the canonical location.
|
|
5
|
+
* lib/mcp-client.mjs remains as a backward-compatible re-export.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { MCPClient, MCPManager, ensureDefaultMcpConfig } from "../lib/mcp-client.mjs";
|
package/core/memory.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/memory.mjs — Long-term memory system for Wispy
|
|
3
|
+
*
|
|
4
|
+
* File-based memory stored in ~/.wispy/memory/
|
|
5
|
+
* - MEMORY.md — main persistent memory
|
|
6
|
+
* - daily/YYYY-MM-DD.md — daily logs
|
|
7
|
+
* - projects/<name>.md — project-specific memory
|
|
8
|
+
* - user.md — user preferences/info
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { readFile, writeFile, appendFile, mkdir, readdir, unlink, stat } from "node:fs/promises";
|
|
13
|
+
|
|
14
|
+
const MAX_SEARCH_RESULTS = 20;
|
|
15
|
+
const MAX_SNIPPET_CHARS = 200;
|
|
16
|
+
|
|
17
|
+
export class MemoryManager {
|
|
18
|
+
constructor(wispyDir) {
|
|
19
|
+
this.memoryDir = path.join(wispyDir, "memory");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ensure memory directory exists
|
|
24
|
+
*/
|
|
25
|
+
async _ensureDir(subDir = "") {
|
|
26
|
+
const dir = subDir ? path.join(this.memoryDir, subDir) : this.memoryDir;
|
|
27
|
+
await mkdir(dir, { recursive: true });
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve key to file path
|
|
33
|
+
* Keys can be: "user", "MEMORY", "daily/2025-01-01", "projects/myproject"
|
|
34
|
+
*/
|
|
35
|
+
_keyToPath(key) {
|
|
36
|
+
// Normalize: strip .md extension if present
|
|
37
|
+
const clean = key.replace(/\.md$/, "");
|
|
38
|
+
return path.join(this.memoryDir, `${clean}.md`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Save (overwrite) a memory file
|
|
43
|
+
*/
|
|
44
|
+
async save(key, content, metadata = {}) {
|
|
45
|
+
await this._ensureDir();
|
|
46
|
+
|
|
47
|
+
// Ensure subdirectory exists
|
|
48
|
+
const filePath = this._keyToPath(key);
|
|
49
|
+
const dir = path.dirname(filePath);
|
|
50
|
+
await mkdir(dir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
const ts = new Date().toISOString();
|
|
53
|
+
const header = metadata.title
|
|
54
|
+
? `# ${metadata.title}\n\n_Last updated: ${ts}_\n\n`
|
|
55
|
+
: `_Last updated: ${ts}_\n\n`;
|
|
56
|
+
|
|
57
|
+
await writeFile(filePath, header + content, "utf8");
|
|
58
|
+
return { key, path: filePath };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Append to an existing memory file (creates if doesn't exist)
|
|
63
|
+
*/
|
|
64
|
+
async append(key, content) {
|
|
65
|
+
await this._ensureDir();
|
|
66
|
+
const filePath = this._keyToPath(key);
|
|
67
|
+
const dir = path.dirname(filePath);
|
|
68
|
+
await mkdir(dir, { recursive: true });
|
|
69
|
+
|
|
70
|
+
const ts = new Date().toISOString().slice(0, 16);
|
|
71
|
+
await appendFile(filePath, `\n- [${ts}] ${content}\n`, "utf8");
|
|
72
|
+
return { key, path: filePath };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get a specific memory file
|
|
77
|
+
*/
|
|
78
|
+
async get(key) {
|
|
79
|
+
const filePath = this._keyToPath(key);
|
|
80
|
+
try {
|
|
81
|
+
const content = await readFile(filePath, "utf8");
|
|
82
|
+
return { key, content, path: filePath };
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* List all memory keys (relative paths without .md)
|
|
90
|
+
*/
|
|
91
|
+
async list() {
|
|
92
|
+
await this._ensureDir();
|
|
93
|
+
const keys = [];
|
|
94
|
+
await this._collectKeys(this.memoryDir, this.memoryDir, keys);
|
|
95
|
+
return keys;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async _collectKeys(baseDir, dir, keys) {
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
102
|
+
} catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
const fullPath = path.join(dir, entry.name);
|
|
107
|
+
if (entry.isDirectory()) {
|
|
108
|
+
await this._collectKeys(baseDir, fullPath, keys);
|
|
109
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
110
|
+
const rel = path.relative(baseDir, fullPath).replace(/\.md$/, "");
|
|
111
|
+
const fileStat = await stat(fullPath).catch(() => null);
|
|
112
|
+
const content = await readFile(fullPath, "utf8").catch(() => "");
|
|
113
|
+
keys.push({
|
|
114
|
+
key: rel,
|
|
115
|
+
path: fullPath,
|
|
116
|
+
size: content.trim().length,
|
|
117
|
+
preview: content.trim().slice(0, 80).replace(/\n/g, " "),
|
|
118
|
+
updatedAt: fileStat?.mtime?.toISOString() ?? null,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Full-text search across all memory files
|
|
126
|
+
* Returns matching snippets with file path and line numbers
|
|
127
|
+
*/
|
|
128
|
+
async search(query, opts = {}) {
|
|
129
|
+
if (!query?.trim()) return [];
|
|
130
|
+
await this._ensureDir();
|
|
131
|
+
|
|
132
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
133
|
+
const results = [];
|
|
134
|
+
await this._searchDir(this.memoryDir, this.memoryDir, terms, results);
|
|
135
|
+
|
|
136
|
+
// Sort by relevance (number of term matches)
|
|
137
|
+
results.sort((a, b) => b.matchCount - a.matchCount);
|
|
138
|
+
|
|
139
|
+
return results.slice(0, opts.limit ?? MAX_SEARCH_RESULTS);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async _searchDir(baseDir, dir, terms, results) {
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
146
|
+
} catch {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const fullPath = path.join(dir, entry.name);
|
|
151
|
+
if (entry.isDirectory()) {
|
|
152
|
+
await this._searchDir(baseDir, fullPath, terms, results);
|
|
153
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
154
|
+
const content = await readFile(fullPath, "utf8").catch(() => "");
|
|
155
|
+
const lines = content.split("\n");
|
|
156
|
+
const key = path.relative(baseDir, fullPath).replace(/\.md$/, "");
|
|
157
|
+
|
|
158
|
+
let matchCount = 0;
|
|
159
|
+
const matchingLines = [];
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < lines.length; i++) {
|
|
162
|
+
const line = lines[i];
|
|
163
|
+
const lower = line.toLowerCase();
|
|
164
|
+
const lineMatches = terms.filter(t => lower.includes(t)).length;
|
|
165
|
+
if (lineMatches > 0) {
|
|
166
|
+
matchCount += lineMatches;
|
|
167
|
+
matchingLines.push({
|
|
168
|
+
lineNumber: i + 1,
|
|
169
|
+
text: line.slice(0, MAX_SNIPPET_CHARS),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (matchCount > 0) {
|
|
175
|
+
results.push({
|
|
176
|
+
key,
|
|
177
|
+
path: fullPath,
|
|
178
|
+
matchCount,
|
|
179
|
+
snippets: matchingLines.slice(0, 5),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Delete a memory file
|
|
188
|
+
*/
|
|
189
|
+
async delete(key) {
|
|
190
|
+
const filePath = this._keyToPath(key);
|
|
191
|
+
try {
|
|
192
|
+
await unlink(filePath);
|
|
193
|
+
return { success: true, key };
|
|
194
|
+
} catch {
|
|
195
|
+
return { success: false, key, error: "Not found" };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Auto-capture important info from conversation messages
|
|
201
|
+
* Uses AI to extract key facts (called from engine)
|
|
202
|
+
*/
|
|
203
|
+
async autoCapture(messages, aiCall) {
|
|
204
|
+
// Extract user messages for analysis
|
|
205
|
+
const recentUserMsgs = messages
|
|
206
|
+
.filter(m => m.role === "user")
|
|
207
|
+
.slice(-5)
|
|
208
|
+
.map(m => m.content)
|
|
209
|
+
.join("\n\n");
|
|
210
|
+
|
|
211
|
+
if (!recentUserMsgs.trim()) return null;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const prompt = `Extract any important facts, preferences, or information from this conversation that should be remembered for future sessions. Be concise. If nothing important, reply "nothing".
|
|
215
|
+
|
|
216
|
+
Conversation:
|
|
217
|
+
${recentUserMsgs.slice(0, 2000)}`;
|
|
218
|
+
|
|
219
|
+
const result = await aiCall(prompt);
|
|
220
|
+
if (result && result.toLowerCase() !== "nothing") {
|
|
221
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
222
|
+
await this.append(`daily/${today}`, result.slice(0, 500));
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
// Silently fail — auto-capture is optional
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get today's daily log key
|
|
233
|
+
*/
|
|
234
|
+
getDailyKey() {
|
|
235
|
+
return `daily/${new Date().toISOString().slice(0, 10)}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get formatted memory context for injection into system prompt
|
|
240
|
+
*/
|
|
241
|
+
async getContextForPrompt(query = "", maxChars = 3000) {
|
|
242
|
+
try {
|
|
243
|
+
const results = [];
|
|
244
|
+
|
|
245
|
+
// Always include MEMORY.md if it exists
|
|
246
|
+
const mainMemory = await this.get("MEMORY");
|
|
247
|
+
if (mainMemory?.content?.trim()) {
|
|
248
|
+
results.push(`### MEMORY.md\n${mainMemory.content.trim()}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Include user.md if it exists
|
|
252
|
+
const userMemory = await this.get("user");
|
|
253
|
+
if (userMemory?.content?.trim()) {
|
|
254
|
+
results.push(`### user.md\n${userMemory.content.trim()}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Search for relevant memories if query provided
|
|
258
|
+
if (query.trim()) {
|
|
259
|
+
const searchResults = await this.search(query, { limit: 5 });
|
|
260
|
+
for (const r of searchResults) {
|
|
261
|
+
if (r.key === "MEMORY" || r.key === "user") continue; // already included
|
|
262
|
+
const mem = await this.get(r.key);
|
|
263
|
+
if (mem?.content?.trim()) {
|
|
264
|
+
results.push(`### ${r.key}.md\n${mem.content.trim()}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const joined = results.join("\n\n");
|
|
270
|
+
return joined.slice(0, maxChars) || null;
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/providers.mjs — Provider abstraction for Wispy
|
|
3
|
+
*
|
|
4
|
+
* Class ProviderRegistry:
|
|
5
|
+
* - detect() → available providers list
|
|
6
|
+
* - get(name) → Provider
|
|
7
|
+
* - getDefault() → Provider (first available)
|
|
8
|
+
* - chat(messages, tools, opts?) → response
|
|
9
|
+
* - setModel(model) → void
|
|
10
|
+
*
|
|
11
|
+
* Each provider: { name, label, models, chat(messages, tools, opts) }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { PROVIDERS, detectProvider } from "./config.mjs";
|
|
15
|
+
|
|
16
|
+
const OPENAI_COMPAT_ENDPOINTS = {
|
|
17
|
+
openai: "https://api.openai.com/v1/chat/completions",
|
|
18
|
+
openrouter: "https://openrouter.ai/api/v1/chat/completions",
|
|
19
|
+
groq: "https://api.groq.com/openai/v1/chat/completions",
|
|
20
|
+
deepseek: "https://api.deepseek.com/v1/chat/completions",
|
|
21
|
+
ollama: `${process.env.OLLAMA_HOST ?? "http://localhost:11434"}/v1/chat/completions`,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class ProviderRegistry {
|
|
25
|
+
constructor() {
|
|
26
|
+
this._detected = null;
|
|
27
|
+
this._provider = null;
|
|
28
|
+
this._apiKey = null;
|
|
29
|
+
this._model = null;
|
|
30
|
+
this._sessionTokens = { input: 0, output: 0 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Initialize by detecting available provider.
|
|
35
|
+
*/
|
|
36
|
+
async init(overrides = {}) {
|
|
37
|
+
const detected = await detectProvider();
|
|
38
|
+
if (!detected && !overrides.provider) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
this._provider = overrides.provider ?? detected?.provider;
|
|
42
|
+
this._apiKey = overrides.key ?? detected?.key;
|
|
43
|
+
this._model = overrides.model ?? detected?.model;
|
|
44
|
+
this._detected = detected;
|
|
45
|
+
return { provider: this._provider, key: this._apiKey, model: this._model };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get provider() { return this._provider; }
|
|
49
|
+
get apiKey() { return this._apiKey; }
|
|
50
|
+
get model() { return this._model; }
|
|
51
|
+
get sessionTokens() { return this._sessionTokens; }
|
|
52
|
+
|
|
53
|
+
setModel(model) {
|
|
54
|
+
this._model = model;
|
|
55
|
+
process.env.WISPY_MODEL = model;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
detect() {
|
|
59
|
+
return Object.entries(PROVIDERS)
|
|
60
|
+
.filter(([id, p]) => {
|
|
61
|
+
for (const k of p.envKeys) { if (process.env[k]) return true; }
|
|
62
|
+
return p.local && process.env.OLLAMA_HOST;
|
|
63
|
+
})
|
|
64
|
+
.map(([id, p]) => ({ id, label: p.label, defaultModel: p.defaultModel }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get(name) {
|
|
68
|
+
return PROVIDERS[name] ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getDefault() {
|
|
72
|
+
return PROVIDERS[this._provider] ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_estimateTokens(text) {
|
|
76
|
+
return Math.ceil((text?.length ?? 0) / 4);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Main chat entrypoint. Routes to the correct provider.
|
|
81
|
+
* messages: array of { role, content, toolCalls?, ... }
|
|
82
|
+
* tools: array of tool definitions
|
|
83
|
+
* opts: { onChunk?(chunk), model? }
|
|
84
|
+
* Returns: { type: "text"|"tool_calls", text?, calls? }
|
|
85
|
+
*/
|
|
86
|
+
async chat(messages, tools = [], opts = {}) {
|
|
87
|
+
const model = opts.model ?? this._model;
|
|
88
|
+
if (this._provider === "google") {
|
|
89
|
+
return this._chatGemini(messages, tools, opts, model);
|
|
90
|
+
}
|
|
91
|
+
if (this._provider === "anthropic") {
|
|
92
|
+
return this._chatAnthropic(messages, tools, opts, model);
|
|
93
|
+
}
|
|
94
|
+
return this._chatOpenAICompat(messages, tools, opts, model);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async _chatGemini(messages, tools, opts, model) {
|
|
98
|
+
const systemInstruction = messages.find(m => m.role === "system")?.content ?? "";
|
|
99
|
+
const contents = [];
|
|
100
|
+
|
|
101
|
+
for (const m of messages) {
|
|
102
|
+
if (m.role === "system") continue;
|
|
103
|
+
if (m.role === "tool_result") {
|
|
104
|
+
contents.push({
|
|
105
|
+
role: "user",
|
|
106
|
+
parts: [{ functionResponse: { name: m.toolName, response: m.result } }],
|
|
107
|
+
});
|
|
108
|
+
} else if (m.role === "assistant" && m.toolCalls) {
|
|
109
|
+
contents.push({
|
|
110
|
+
role: "model",
|
|
111
|
+
parts: m.toolCalls.map(tc => ({
|
|
112
|
+
functionCall: { name: tc.name, args: tc.args },
|
|
113
|
+
})),
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
contents.push({
|
|
117
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
118
|
+
parts: [{ text: m.content }],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const inputText = contents.map(c => c.parts?.map(p => p.text ?? JSON.stringify(p)).join("")).join("");
|
|
124
|
+
this._sessionTokens.input += this._estimateTokens(systemInstruction + inputText);
|
|
125
|
+
|
|
126
|
+
const geminiTools = tools.length > 0 ? [{
|
|
127
|
+
functionDeclarations: tools.map(t => ({
|
|
128
|
+
name: t.name,
|
|
129
|
+
description: t.description,
|
|
130
|
+
parameters: t.parameters,
|
|
131
|
+
})),
|
|
132
|
+
}] : [];
|
|
133
|
+
|
|
134
|
+
const hasToolResults = messages.some(m => m.role === "tool_result");
|
|
135
|
+
const useStreaming = !hasToolResults;
|
|
136
|
+
const endpoint = useStreaming ? "streamGenerateContent" : "generateContent";
|
|
137
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:${endpoint}?${useStreaming ? "alt=sse&" : ""}key=${this._apiKey}`;
|
|
138
|
+
|
|
139
|
+
const body = {
|
|
140
|
+
system_instruction: systemInstruction ? { parts: [{ text: systemInstruction }] } : undefined,
|
|
141
|
+
contents,
|
|
142
|
+
generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
|
|
143
|
+
};
|
|
144
|
+
if (geminiTools.length > 0) body.tools = geminiTools;
|
|
145
|
+
|
|
146
|
+
const response = await fetch(url, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "Content-Type": "application/json" },
|
|
149
|
+
body: JSON.stringify(body),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
const err = await response.text();
|
|
154
|
+
throw new Error(`Gemini API error ${response.status}: ${err.slice(0, 300)}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (useStreaming) {
|
|
158
|
+
const reader = response.body.getReader();
|
|
159
|
+
const decoder = new TextDecoder();
|
|
160
|
+
let fullText = "";
|
|
161
|
+
let sseBuffer = "";
|
|
162
|
+
|
|
163
|
+
while (true) {
|
|
164
|
+
const { done, value } = await reader.read();
|
|
165
|
+
if (done) break;
|
|
166
|
+
sseBuffer += decoder.decode(value, { stream: true });
|
|
167
|
+
const sseLines = sseBuffer.split("\n");
|
|
168
|
+
sseBuffer = sseLines.pop() ?? "";
|
|
169
|
+
|
|
170
|
+
for (const line of sseLines) {
|
|
171
|
+
if (!line.startsWith("data: ")) continue;
|
|
172
|
+
const ld = line.slice(6).trim();
|
|
173
|
+
if (!ld || ld === "[DONE]") continue;
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(ld);
|
|
176
|
+
const streamParts = parsed.candidates?.[0]?.content?.parts ?? [];
|
|
177
|
+
const streamFC = streamParts.filter(p => p.functionCall);
|
|
178
|
+
if (streamFC.length > 0) {
|
|
179
|
+
this._sessionTokens.output += this._estimateTokens(JSON.stringify(streamFC));
|
|
180
|
+
return { type: "tool_calls", calls: streamFC.map(p => ({ name: p.functionCall.name, args: p.functionCall.args })) };
|
|
181
|
+
}
|
|
182
|
+
const t = streamParts.map(p => p.text ?? "").join("");
|
|
183
|
+
if (t) { fullText += t; opts.onChunk?.(t); }
|
|
184
|
+
} catch { /* skip */ }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
this._sessionTokens.output += this._estimateTokens(fullText);
|
|
188
|
+
return { type: "text", text: fullText };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const data = await response.json();
|
|
192
|
+
const candidate = data.candidates?.[0];
|
|
193
|
+
if (!candidate) throw new Error("No response from Gemini");
|
|
194
|
+
|
|
195
|
+
const parts = candidate.content?.parts ?? [];
|
|
196
|
+
const functionCalls = parts.filter(p => p.functionCall);
|
|
197
|
+
if (functionCalls.length > 0) {
|
|
198
|
+
this._sessionTokens.output += this._estimateTokens(JSON.stringify(functionCalls));
|
|
199
|
+
return { type: "tool_calls", calls: functionCalls.map(p => ({ name: p.functionCall.name, args: p.functionCall.args })) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const text = parts.map(p => p.text ?? "").join("");
|
|
203
|
+
this._sessionTokens.output += this._estimateTokens(text);
|
|
204
|
+
opts.onChunk?.(text);
|
|
205
|
+
return { type: "text", text };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async _chatAnthropic(messages, tools, opts, model) {
|
|
209
|
+
const systemPrompt = messages.find(m => m.role === "system")?.content ?? "";
|
|
210
|
+
const anthropicMessages = [];
|
|
211
|
+
|
|
212
|
+
for (const m of messages) {
|
|
213
|
+
if (m.role === "system") continue;
|
|
214
|
+
if (m.role === "tool_result") {
|
|
215
|
+
anthropicMessages.push({
|
|
216
|
+
role: "user",
|
|
217
|
+
content: [{ type: "tool_result", tool_use_id: m.toolUseId ?? m.toolName, content: JSON.stringify(m.result) }],
|
|
218
|
+
});
|
|
219
|
+
} else if (m.role === "assistant" && m.toolCalls) {
|
|
220
|
+
anthropicMessages.push({
|
|
221
|
+
role: "assistant",
|
|
222
|
+
content: m.toolCalls.map(tc => ({
|
|
223
|
+
type: "tool_use", id: tc.id ?? tc.name, name: tc.name, input: tc.args,
|
|
224
|
+
})),
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
anthropicMessages.push({
|
|
228
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
229
|
+
content: m.content,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const inputText = anthropicMessages.map(m => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join("");
|
|
235
|
+
this._sessionTokens.input += this._estimateTokens(systemPrompt + inputText);
|
|
236
|
+
|
|
237
|
+
const anthropicTools = tools.map(t => ({
|
|
238
|
+
name: t.name,
|
|
239
|
+
description: t.description,
|
|
240
|
+
input_schema: t.parameters,
|
|
241
|
+
}));
|
|
242
|
+
|
|
243
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
"x-api-key": this._apiKey,
|
|
248
|
+
"anthropic-version": "2023-06-01",
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify({
|
|
251
|
+
model,
|
|
252
|
+
max_tokens: 4096,
|
|
253
|
+
system: systemPrompt,
|
|
254
|
+
messages: anthropicMessages,
|
|
255
|
+
tools: anthropicTools,
|
|
256
|
+
stream: true,
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
const err = await response.text();
|
|
262
|
+
throw new Error(`Anthropic API error ${response.status}: ${err.slice(0, 300)}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const reader = response.body.getReader();
|
|
266
|
+
const decoder = new TextDecoder();
|
|
267
|
+
let buffer = "";
|
|
268
|
+
let fullText = "";
|
|
269
|
+
const toolCalls = [];
|
|
270
|
+
let currentToolCall = null;
|
|
271
|
+
let currentToolInput = "";
|
|
272
|
+
|
|
273
|
+
while (true) {
|
|
274
|
+
const { done, value } = await reader.read();
|
|
275
|
+
if (done) break;
|
|
276
|
+
|
|
277
|
+
buffer += decoder.decode(value, { stream: true });
|
|
278
|
+
const lines = buffer.split("\n");
|
|
279
|
+
buffer = lines.pop() ?? "";
|
|
280
|
+
|
|
281
|
+
for (const line of lines) {
|
|
282
|
+
if (!line.startsWith("data: ")) continue;
|
|
283
|
+
const data = line.slice(6).trim();
|
|
284
|
+
if (!data) continue;
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const event = JSON.parse(data);
|
|
288
|
+
if (event.type === "content_block_start") {
|
|
289
|
+
if (event.content_block?.type === "tool_use") {
|
|
290
|
+
currentToolCall = { id: event.content_block.id, name: event.content_block.name, args: {} };
|
|
291
|
+
currentToolInput = "";
|
|
292
|
+
}
|
|
293
|
+
} else if (event.type === "content_block_delta") {
|
|
294
|
+
if (event.delta?.type === "text_delta") {
|
|
295
|
+
fullText += event.delta.text;
|
|
296
|
+
opts.onChunk?.(event.delta.text);
|
|
297
|
+
} else if (event.delta?.type === "input_json_delta") {
|
|
298
|
+
currentToolInput += event.delta.partial_json ?? "";
|
|
299
|
+
}
|
|
300
|
+
} else if (event.type === "content_block_stop") {
|
|
301
|
+
if (currentToolCall) {
|
|
302
|
+
try { currentToolCall.args = JSON.parse(currentToolInput); } catch { currentToolCall.args = {}; }
|
|
303
|
+
toolCalls.push(currentToolCall);
|
|
304
|
+
currentToolCall = null;
|
|
305
|
+
currentToolInput = "";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch { /* skip */ }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this._sessionTokens.output += this._estimateTokens(fullText + JSON.stringify(toolCalls));
|
|
313
|
+
|
|
314
|
+
if (toolCalls.length > 0) {
|
|
315
|
+
return { type: "tool_calls", calls: toolCalls };
|
|
316
|
+
}
|
|
317
|
+
return { type: "text", text: fullText };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async _chatOpenAICompat(messages, tools, opts, model) {
|
|
321
|
+
const openaiMessages = messages.map(m => {
|
|
322
|
+
if (m.role === "tool_result") {
|
|
323
|
+
return { role: "tool", tool_call_id: m.toolCallId ?? m.toolName, content: JSON.stringify(m.result) };
|
|
324
|
+
}
|
|
325
|
+
if (m.role === "assistant" && m.toolCalls) {
|
|
326
|
+
return {
|
|
327
|
+
role: "assistant",
|
|
328
|
+
content: null,
|
|
329
|
+
tool_calls: m.toolCalls.map((tc, i) => ({
|
|
330
|
+
id: tc.id ?? `call_${i}`,
|
|
331
|
+
type: "function",
|
|
332
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
|
333
|
+
})),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return { role: m.role, content: m.content };
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const inputText = openaiMessages.map(m => m.content ?? "").join("");
|
|
340
|
+
this._sessionTokens.input += this._estimateTokens(inputText);
|
|
341
|
+
|
|
342
|
+
const endpoint = OPENAI_COMPAT_ENDPOINTS[this._provider] ?? OPENAI_COMPAT_ENDPOINTS.openai;
|
|
343
|
+
const headers = { "Content-Type": "application/json" };
|
|
344
|
+
if (this._apiKey) headers["Authorization"] = `Bearer ${this._apiKey}`;
|
|
345
|
+
if (this._provider === "openrouter") headers["HTTP-Referer"] = "https://wispy.dev";
|
|
346
|
+
|
|
347
|
+
const supportsTools = !["ollama"].includes(this._provider);
|
|
348
|
+
const body = { model, messages: openaiMessages, temperature: 0.7, max_tokens: 4096 };
|
|
349
|
+
if (supportsTools && tools.length > 0) {
|
|
350
|
+
body.tools = tools.map(t => ({
|
|
351
|
+
type: "function",
|
|
352
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const response = await fetch(endpoint, {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers,
|
|
359
|
+
body: JSON.stringify(body),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
const err = await response.text();
|
|
364
|
+
throw new Error(`OpenAI API error ${response.status}: ${err.slice(0, 300)}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const data = await response.json();
|
|
368
|
+
const choice = data.choices?.[0];
|
|
369
|
+
if (!choice) throw new Error("No response from OpenAI");
|
|
370
|
+
|
|
371
|
+
if (choice.message?.tool_calls?.length > 0) {
|
|
372
|
+
const calls = choice.message.tool_calls.map(tc => ({
|
|
373
|
+
id: tc.id,
|
|
374
|
+
name: tc.function.name,
|
|
375
|
+
args: JSON.parse(tc.function.arguments),
|
|
376
|
+
}));
|
|
377
|
+
this._sessionTokens.output += this._estimateTokens(JSON.stringify(calls));
|
|
378
|
+
return { type: "tool_calls", calls };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const text = choice.message?.content ?? "";
|
|
382
|
+
this._sessionTokens.output += this._estimateTokens(text);
|
|
383
|
+
opts.onChunk?.(text);
|
|
384
|
+
return { type: "text", text };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
formatCost() {
|
|
388
|
+
const MODEL_PRICING = {
|
|
389
|
+
"gemini-2.5-flash": { input: 0.15, output: 0.60 },
|
|
390
|
+
"gemini-2.5-pro": { input: 1.25, output: 10.0 },
|
|
391
|
+
"gemini-2.0-flash": { input: 0.10, output: 0.40 },
|
|
392
|
+
"claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
|
|
393
|
+
"claude-opus-4-6": { input: 15.0, output: 75.0 },
|
|
394
|
+
"claude-haiku-3.5": { input: 0.80, output: 4.0 },
|
|
395
|
+
"gpt-4o": { input: 2.50, output: 10.0 },
|
|
396
|
+
"gpt-4o-mini": { input: 0.15, output: 0.60 },
|
|
397
|
+
"gpt-4.1": { input: 2.0, output: 8.0 },
|
|
398
|
+
"gpt-4.1-mini": { input: 0.40, output: 1.60 },
|
|
399
|
+
"gpt-4.1-nano": { input: 0.10, output: 0.40 },
|
|
400
|
+
"o4-mini": { input: 1.10, output: 4.40 },
|
|
401
|
+
"llama-3.3-70b-versatile": { input: 0.59, output: 0.79 },
|
|
402
|
+
"deepseek-chat": { input: 0.27, output: 1.10 },
|
|
403
|
+
"llama3.2": { input: 0, output: 0 },
|
|
404
|
+
};
|
|
405
|
+
const pricing = MODEL_PRICING[this._model] ?? { input: 1.0, output: 3.0 };
|
|
406
|
+
const cost = (this._sessionTokens.input * pricing.input + this._sessionTokens.output * pricing.output) / 1_000_000;
|
|
407
|
+
const total = this._sessionTokens.input + this._sessionTokens.output;
|
|
408
|
+
return `${total} tokens (~$${cost.toFixed(4)})`;
|
|
409
|
+
}
|
|
410
|
+
}
|