echospace 0.0.1 → 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +225 -0
- package/dist/cli/index.js +916 -0
- package/dist/client/assets/index-C3HpfhnB.js +168 -0
- package/dist/client/assets/index-kxAuiugv.css +1 -0
- package/dist/client/echospace-logo.svg +13 -0
- package/dist/client/index.html +20 -0
- package/dist/core/chunk-U4VEJP3N.js +110 -0
- package/dist/core/echo/index.d.ts +37 -0
- package/dist/core/echo/index.js +20 -0
- package/dist/core/providers/index.d.ts +45 -0
- package/dist/core/providers/index.js +384 -0
- package/dist/core/smart-paste/index.d.ts +15 -0
- package/dist/core/smart-paste/index.js +405 -0
- package/dist/core/types-CYpEmXbp.d.ts +89 -0
- package/package.json +94 -5
- package/index.js +0 -1
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import getPort, { portNumbers } from "get-port";
|
|
6
|
+
import fs3 from "fs";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import path4 from "path";
|
|
9
|
+
import open from "open";
|
|
10
|
+
|
|
11
|
+
// src/server/services/config.ts
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import yaml from "js-yaml";
|
|
15
|
+
var DEFAULT_CONFIG = {
|
|
16
|
+
providers: [
|
|
17
|
+
{
|
|
18
|
+
name: "openai",
|
|
19
|
+
type: "openai",
|
|
20
|
+
api_key: "",
|
|
21
|
+
models: ["gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", "o3", "o3-mini", "o4-mini"]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "anthropic",
|
|
25
|
+
type: "anthropic",
|
|
26
|
+
api_key: "",
|
|
27
|
+
models: ["claude-sonnet-4-6", "claude-haiku-4-5"]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "google",
|
|
31
|
+
type: "google",
|
|
32
|
+
api_key: "",
|
|
33
|
+
models: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
};
|
|
37
|
+
function getConfigPath(configDir) {
|
|
38
|
+
return path.join(configDir, "config.yaml");
|
|
39
|
+
}
|
|
40
|
+
function loadConfig(configDir) {
|
|
41
|
+
const configPath = getConfigPath(configDir);
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
44
|
+
return yaml.load(raw) ?? DEFAULT_CONFIG;
|
|
45
|
+
} catch {
|
|
46
|
+
return DEFAULT_CONFIG;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function saveConfig(configDir, config) {
|
|
50
|
+
const configPath = getConfigPath(configDir);
|
|
51
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(configPath, yaml.dump(config), "utf-8");
|
|
53
|
+
}
|
|
54
|
+
function ensureConfig(configDir) {
|
|
55
|
+
const oldDir = configDir.replace(/\.echospace$/, ".echo-space");
|
|
56
|
+
if (oldDir !== configDir && fs.existsSync(oldDir) && !fs.existsSync(configDir)) {
|
|
57
|
+
fs.renameSync(oldDir, configDir);
|
|
58
|
+
}
|
|
59
|
+
const configPath = getConfigPath(configDir);
|
|
60
|
+
if (!fs.existsSync(configPath)) {
|
|
61
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
62
|
+
const header = [
|
|
63
|
+
"# EchoSpace configuration",
|
|
64
|
+
"# Fill in the api_key for the providers you need. Unconfigured providers are ignored.",
|
|
65
|
+
"# Run /echospace:init for interactive setup.",
|
|
66
|
+
""
|
|
67
|
+
].join("\n");
|
|
68
|
+
fs.writeFileSync(configPath, header + yaml.dump(DEFAULT_CONFIG), "utf-8");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/server/index.ts
|
|
73
|
+
import { serve } from "@hono/node-server";
|
|
74
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
75
|
+
import path3 from "path";
|
|
76
|
+
import { fileURLToPath } from "url";
|
|
77
|
+
import { Hono as Hono4 } from "hono";
|
|
78
|
+
import { cors } from "hono/cors";
|
|
79
|
+
import { logger } from "hono/logger";
|
|
80
|
+
|
|
81
|
+
// src/server/routes/chat.ts
|
|
82
|
+
import { Hono } from "hono";
|
|
83
|
+
import { stream } from "hono/streaming";
|
|
84
|
+
|
|
85
|
+
// src/core/providers/anthropic.ts
|
|
86
|
+
function toAnthropicContent(msg) {
|
|
87
|
+
const blocks = [];
|
|
88
|
+
for (const part of msg.parts) {
|
|
89
|
+
switch (part.type) {
|
|
90
|
+
case "text":
|
|
91
|
+
blocks.push({ type: "text", text: part.text });
|
|
92
|
+
break;
|
|
93
|
+
case "thinking":
|
|
94
|
+
blocks.push({ type: "thinking", thinking: part.text });
|
|
95
|
+
break;
|
|
96
|
+
case "tool_call":
|
|
97
|
+
blocks.push({
|
|
98
|
+
type: "tool_use",
|
|
99
|
+
id: part.id,
|
|
100
|
+
name: part.name,
|
|
101
|
+
input: part.input
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
case "tool_result":
|
|
105
|
+
blocks.push({
|
|
106
|
+
type: "tool_result",
|
|
107
|
+
tool_use_id: part.id,
|
|
108
|
+
content: typeof part.output === "string" ? part.output : JSON.stringify(part.output),
|
|
109
|
+
is_error: part.is_error
|
|
110
|
+
});
|
|
111
|
+
break;
|
|
112
|
+
case "image":
|
|
113
|
+
if (part.base64) {
|
|
114
|
+
blocks.push({
|
|
115
|
+
type: "image",
|
|
116
|
+
source: {
|
|
117
|
+
type: "base64",
|
|
118
|
+
media_type: part.media_type ?? "image/png",
|
|
119
|
+
data: part.base64
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
|
|
127
|
+
}
|
|
128
|
+
var anthropicAdapter = {
|
|
129
|
+
type: "anthropic",
|
|
130
|
+
buildRequest(messages, settings, config) {
|
|
131
|
+
const baseUrl = config.base_url ?? "https://api.anthropic.com";
|
|
132
|
+
const url = `${baseUrl.replace(/\/$/, "")}/v1/messages`;
|
|
133
|
+
const systemMessages = messages.filter((m) => m.role === "system");
|
|
134
|
+
const nonSystemMessages = messages.filter((m) => m.role !== "system");
|
|
135
|
+
const system = systemMessages.flatMap((m) => m.parts.filter((p) => p.type === "text")).map((p) => p.text).join("\n\n");
|
|
136
|
+
const body = {
|
|
137
|
+
model: settings.model ?? config.models[0],
|
|
138
|
+
messages: nonSystemMessages.map((msg) => ({
|
|
139
|
+
role: msg.role === "tool" ? "user" : msg.role,
|
|
140
|
+
content: toAnthropicContent(msg)
|
|
141
|
+
})),
|
|
142
|
+
max_tokens: settings.max_tokens ?? 4096,
|
|
143
|
+
stream: true
|
|
144
|
+
};
|
|
145
|
+
if (system) body.system = system;
|
|
146
|
+
if (settings.temperature != null) body.temperature = settings.temperature;
|
|
147
|
+
if (settings.top_p != null) body.top_p = settings.top_p;
|
|
148
|
+
if (settings.tools && settings.tools.length > 0) {
|
|
149
|
+
body.tools = settings.tools.map((t) => ({
|
|
150
|
+
name: t.name,
|
|
151
|
+
description: t.description,
|
|
152
|
+
input_schema: t.parameters
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
const headers = {
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
"anthropic-version": "2023-06-01"
|
|
158
|
+
};
|
|
159
|
+
if (config.api_key) {
|
|
160
|
+
headers["x-api-key"] = config.api_key;
|
|
161
|
+
}
|
|
162
|
+
return { url, headers, body };
|
|
163
|
+
},
|
|
164
|
+
parseChunk(chunk) {
|
|
165
|
+
try {
|
|
166
|
+
const event = JSON.parse(chunk);
|
|
167
|
+
const parts = [];
|
|
168
|
+
switch (event.type) {
|
|
169
|
+
case "content_block_delta": {
|
|
170
|
+
const delta = event.delta;
|
|
171
|
+
if (delta?.type === "text_delta" && delta.text) {
|
|
172
|
+
parts.push({ type: "text", text: delta.text });
|
|
173
|
+
}
|
|
174
|
+
if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
175
|
+
parts.push({ type: "thinking", text: delta.thinking });
|
|
176
|
+
}
|
|
177
|
+
if (delta?.type === "input_json_delta" && delta.partial_json) {
|
|
178
|
+
parts.push({
|
|
179
|
+
type: "tool_call",
|
|
180
|
+
id: "",
|
|
181
|
+
name: "",
|
|
182
|
+
input: delta.partial_json
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "content_block_start": {
|
|
188
|
+
const block = event.content_block;
|
|
189
|
+
if (block?.type === "tool_use") {
|
|
190
|
+
parts.push({
|
|
191
|
+
type: "tool_call",
|
|
192
|
+
id: block.id ?? "",
|
|
193
|
+
name: block.name ?? "",
|
|
194
|
+
input: ""
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return parts;
|
|
201
|
+
} catch {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
isDone(chunk) {
|
|
206
|
+
try {
|
|
207
|
+
const event = JSON.parse(chunk);
|
|
208
|
+
return event.type === "message_stop";
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// src/core/providers/google.ts
|
|
216
|
+
function toGeminiContent(msg) {
|
|
217
|
+
const parts = [];
|
|
218
|
+
for (const part of msg.parts) {
|
|
219
|
+
switch (part.type) {
|
|
220
|
+
case "text":
|
|
221
|
+
case "thinking":
|
|
222
|
+
parts.push({ text: part.text });
|
|
223
|
+
break;
|
|
224
|
+
case "tool_call":
|
|
225
|
+
parts.push({
|
|
226
|
+
functionCall: {
|
|
227
|
+
name: part.name,
|
|
228
|
+
args: typeof part.input === "string" ? JSON.parse(part.input) : part.input
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
break;
|
|
232
|
+
case "tool_result":
|
|
233
|
+
parts.push({
|
|
234
|
+
functionResponse: {
|
|
235
|
+
name: part.id,
|
|
236
|
+
response: { result: part.output }
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
break;
|
|
240
|
+
case "image":
|
|
241
|
+
if (part.base64) {
|
|
242
|
+
parts.push({
|
|
243
|
+
inlineData: {
|
|
244
|
+
mimeType: part.media_type ?? "image/png",
|
|
245
|
+
data: part.base64
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const role = msg.role === "assistant" ? "model" : "user";
|
|
253
|
+
return { role, parts };
|
|
254
|
+
}
|
|
255
|
+
var googleAdapter = {
|
|
256
|
+
type: "google",
|
|
257
|
+
buildRequest(messages, settings, config) {
|
|
258
|
+
const model = settings.model ?? config.models[0];
|
|
259
|
+
const baseUrl = config.base_url ?? "https://generativelanguage.googleapis.com/v1beta";
|
|
260
|
+
const url = `${baseUrl}/models/${model}:streamGenerateContent?alt=sse&key=${config.api_key ?? ""}`;
|
|
261
|
+
const systemMessages = messages.filter((m) => m.role === "system");
|
|
262
|
+
const nonSystemMessages = messages.filter((m) => m.role !== "system");
|
|
263
|
+
const systemInstruction = systemMessages.length > 0 ? {
|
|
264
|
+
parts: systemMessages.flatMap(
|
|
265
|
+
(m) => m.parts.filter((p) => p.type === "text").map((p) => ({ text: p.text }))
|
|
266
|
+
)
|
|
267
|
+
} : void 0;
|
|
268
|
+
const body = {
|
|
269
|
+
contents: nonSystemMessages.map(toGeminiContent),
|
|
270
|
+
generationConfig: {
|
|
271
|
+
...settings.temperature != null && {
|
|
272
|
+
temperature: settings.temperature
|
|
273
|
+
},
|
|
274
|
+
...settings.max_tokens != null && {
|
|
275
|
+
maxOutputTokens: settings.max_tokens
|
|
276
|
+
},
|
|
277
|
+
...settings.top_p != null && { topP: settings.top_p }
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
if (systemInstruction) body.systemInstruction = systemInstruction;
|
|
281
|
+
if (settings.tools && settings.tools.length > 0) {
|
|
282
|
+
body.tools = [
|
|
283
|
+
{
|
|
284
|
+
functionDeclarations: settings.tools.map((t) => ({
|
|
285
|
+
name: t.name,
|
|
286
|
+
description: t.description,
|
|
287
|
+
parameters: t.parameters
|
|
288
|
+
}))
|
|
289
|
+
}
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
const headers = {
|
|
293
|
+
"Content-Type": "application/json"
|
|
294
|
+
};
|
|
295
|
+
return { url, headers, body };
|
|
296
|
+
},
|
|
297
|
+
parseChunk(chunk) {
|
|
298
|
+
try {
|
|
299
|
+
const data = JSON.parse(chunk);
|
|
300
|
+
const candidate = data?.candidates?.[0];
|
|
301
|
+
if (!candidate?.content?.parts) return [];
|
|
302
|
+
const parts = [];
|
|
303
|
+
for (const part of candidate.content.parts) {
|
|
304
|
+
if (part.text) {
|
|
305
|
+
parts.push({ type: "text", text: part.text });
|
|
306
|
+
}
|
|
307
|
+
if (part.functionCall) {
|
|
308
|
+
parts.push({
|
|
309
|
+
type: "tool_call",
|
|
310
|
+
id: part.functionCall.name ?? "",
|
|
311
|
+
name: part.functionCall.name ?? "",
|
|
312
|
+
input: part.functionCall.args ?? {}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return parts;
|
|
317
|
+
} catch {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
isDone(chunk) {
|
|
322
|
+
try {
|
|
323
|
+
const data = JSON.parse(chunk);
|
|
324
|
+
const candidate = data?.candidates?.[0];
|
|
325
|
+
return candidate?.finishReason === "STOP";
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// src/core/providers/openai.ts
|
|
333
|
+
function toOpenAIMessage(msg) {
|
|
334
|
+
const result = { role: msg.role };
|
|
335
|
+
const textParts = msg.parts.filter((p) => p.type === "text");
|
|
336
|
+
const toolCalls = msg.parts.filter((p) => p.type === "tool_call");
|
|
337
|
+
const toolResults = msg.parts.filter((p) => p.type === "tool_result");
|
|
338
|
+
const thinkingParts = msg.parts.filter((p) => p.type === "thinking");
|
|
339
|
+
if (msg.role === "tool" && toolResults.length > 0) {
|
|
340
|
+
const tr = toolResults[0];
|
|
341
|
+
result.content = typeof tr.output === "string" ? tr.output : JSON.stringify(tr.output);
|
|
342
|
+
result.tool_call_id = tr.id;
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
if (msg.role === "assistant") {
|
|
346
|
+
result.content = textParts.map((p) => p.text).join("") || null;
|
|
347
|
+
if (thinkingParts.length > 0) {
|
|
348
|
+
result.reasoning_content = thinkingParts.map((p) => p.text).join("");
|
|
349
|
+
}
|
|
350
|
+
if (toolCalls.length > 0) {
|
|
351
|
+
result.tool_calls = toolCalls.map((tc) => ({
|
|
352
|
+
id: tc.id,
|
|
353
|
+
type: "function",
|
|
354
|
+
function: {
|
|
355
|
+
name: tc.name,
|
|
356
|
+
arguments: typeof tc.input === "string" ? tc.input : JSON.stringify(tc.input)
|
|
357
|
+
}
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
result.content = textParts.map((p) => p.text).join("");
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
var openaiAdapter = {
|
|
366
|
+
type: "openai",
|
|
367
|
+
buildRequest(messages, settings, config) {
|
|
368
|
+
const baseUrl = config.base_url ?? "https://api.openai.com/v1";
|
|
369
|
+
const url = `${baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
370
|
+
const body = {
|
|
371
|
+
model: settings.model ?? config.models[0],
|
|
372
|
+
messages: messages.map(toOpenAIMessage),
|
|
373
|
+
stream: true
|
|
374
|
+
};
|
|
375
|
+
if (settings.temperature != null) body.temperature = settings.temperature;
|
|
376
|
+
if (settings.max_tokens != null) body.max_tokens = settings.max_tokens;
|
|
377
|
+
if (settings.top_p != null) body.top_p = settings.top_p;
|
|
378
|
+
if (settings.response_format && settings.response_format !== "text") {
|
|
379
|
+
if (settings.response_format === "json_schema" && settings.json_schema) {
|
|
380
|
+
body.response_format = {
|
|
381
|
+
type: "json_schema",
|
|
382
|
+
json_schema: settings.json_schema
|
|
383
|
+
};
|
|
384
|
+
} else {
|
|
385
|
+
body.response_format = { type: settings.response_format };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (settings.tools && settings.tools.length > 0) {
|
|
389
|
+
body.tools = settings.tools.map((t) => ({
|
|
390
|
+
type: "function",
|
|
391
|
+
function: {
|
|
392
|
+
name: t.name,
|
|
393
|
+
description: t.description,
|
|
394
|
+
parameters: t.parameters,
|
|
395
|
+
...t.strict != null && { strict: t.strict }
|
|
396
|
+
}
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
const headers = {
|
|
400
|
+
"Content-Type": "application/json"
|
|
401
|
+
};
|
|
402
|
+
if (config.api_key) {
|
|
403
|
+
headers["Authorization"] = `Bearer ${config.api_key}`;
|
|
404
|
+
}
|
|
405
|
+
return { url, headers, body };
|
|
406
|
+
},
|
|
407
|
+
parseChunk(chunk) {
|
|
408
|
+
if (chunk === "[DONE]") return [];
|
|
409
|
+
try {
|
|
410
|
+
const data = JSON.parse(chunk);
|
|
411
|
+
const delta = data?.choices?.[0]?.delta;
|
|
412
|
+
if (!delta) return [];
|
|
413
|
+
const parts = [];
|
|
414
|
+
if (delta.content) {
|
|
415
|
+
parts.push({ type: "text", text: delta.content });
|
|
416
|
+
}
|
|
417
|
+
if (delta.reasoning_content) {
|
|
418
|
+
parts.push({ type: "thinking", text: delta.reasoning_content });
|
|
419
|
+
}
|
|
420
|
+
if (delta.tool_calls) {
|
|
421
|
+
for (const tc of delta.tool_calls) {
|
|
422
|
+
if (tc.function) {
|
|
423
|
+
parts.push({
|
|
424
|
+
type: "tool_call",
|
|
425
|
+
id: tc.id ?? "",
|
|
426
|
+
name: tc.function.name ?? "",
|
|
427
|
+
input: tc.function.arguments ?? ""
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return parts;
|
|
433
|
+
} catch {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
isDone(chunk) {
|
|
438
|
+
return chunk === "[DONE]";
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// src/core/providers/registry.ts
|
|
443
|
+
var DefaultProviderRegistry = class {
|
|
444
|
+
adapters = /* @__PURE__ */ new Map();
|
|
445
|
+
get(type) {
|
|
446
|
+
const adapter = this.adapters.get(type);
|
|
447
|
+
if (!adapter) {
|
|
448
|
+
throw new Error(`Unknown provider type: ${type}`);
|
|
449
|
+
}
|
|
450
|
+
return adapter;
|
|
451
|
+
}
|
|
452
|
+
register(adapter) {
|
|
453
|
+
this.adapters.set(adapter.type, adapter);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
function createProviderRegistry() {
|
|
457
|
+
const registry = new DefaultProviderRegistry();
|
|
458
|
+
registry.register(openaiAdapter);
|
|
459
|
+
registry.register(anthropicAdapter);
|
|
460
|
+
registry.register(googleAdapter);
|
|
461
|
+
return registry;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/server/routes/chat.ts
|
|
465
|
+
function chatRoutes(options) {
|
|
466
|
+
const app = new Hono();
|
|
467
|
+
const registry = createProviderRegistry();
|
|
468
|
+
app.post("/completions", async (c) => {
|
|
469
|
+
const body = await c.req.json();
|
|
470
|
+
const { messages, settings, provider: providerName } = body;
|
|
471
|
+
const config = loadConfig(options.configDir);
|
|
472
|
+
const providerList = config.providers ?? [];
|
|
473
|
+
const providerConfig = providerList.find(
|
|
474
|
+
(p) => p.name === providerName
|
|
475
|
+
);
|
|
476
|
+
if (!providerConfig) {
|
|
477
|
+
return c.json(
|
|
478
|
+
{ error: `Provider "${providerName}" not found in config` },
|
|
479
|
+
400
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const resolvedConfig = {
|
|
483
|
+
...providerConfig,
|
|
484
|
+
api_key: resolveEnvVar(providerConfig.api_key)
|
|
485
|
+
};
|
|
486
|
+
const adapter = registry.get(resolvedConfig.type);
|
|
487
|
+
const { url, headers, body: requestBody } = adapter.buildRequest(
|
|
488
|
+
messages,
|
|
489
|
+
settings,
|
|
490
|
+
resolvedConfig
|
|
491
|
+
);
|
|
492
|
+
try {
|
|
493
|
+
const response = await fetch(url, {
|
|
494
|
+
method: "POST",
|
|
495
|
+
headers,
|
|
496
|
+
body: JSON.stringify(requestBody)
|
|
497
|
+
});
|
|
498
|
+
if (!response.ok) {
|
|
499
|
+
const errorText = await response.text();
|
|
500
|
+
return c.json(
|
|
501
|
+
{
|
|
502
|
+
error: `Provider returned ${response.status}: ${errorText}`
|
|
503
|
+
},
|
|
504
|
+
response.status
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return stream(c, async (s) => {
|
|
508
|
+
const reader = response.body?.getReader();
|
|
509
|
+
if (!reader) return;
|
|
510
|
+
const decoder = new TextDecoder();
|
|
511
|
+
let buffer = "";
|
|
512
|
+
while (true) {
|
|
513
|
+
const { done, value } = await reader.read();
|
|
514
|
+
if (done) break;
|
|
515
|
+
buffer += decoder.decode(value, { stream: true });
|
|
516
|
+
const lines = buffer.split("\n");
|
|
517
|
+
buffer = lines.pop() ?? "";
|
|
518
|
+
for (const line of lines) {
|
|
519
|
+
const trimmed = line.trim();
|
|
520
|
+
if (!trimmed || trimmed === ":") continue;
|
|
521
|
+
if (trimmed.startsWith("data: ")) {
|
|
522
|
+
const data = trimmed.slice(6);
|
|
523
|
+
const parts = adapter.parseChunk(data);
|
|
524
|
+
if (parts.length > 0) {
|
|
525
|
+
await s.write(
|
|
526
|
+
`data: ${JSON.stringify({ parts })}
|
|
527
|
+
|
|
528
|
+
`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
if (adapter.isDone(data)) {
|
|
532
|
+
await s.write("data: [DONE]\n\n");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
await s.write("data: [DONE]\n\n");
|
|
539
|
+
});
|
|
540
|
+
} catch (err) {
|
|
541
|
+
return c.json(
|
|
542
|
+
{ error: `Request failed: ${err.message}` },
|
|
543
|
+
500
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
return app;
|
|
548
|
+
}
|
|
549
|
+
function resolveEnvVar(value) {
|
|
550
|
+
if (!value) return value;
|
|
551
|
+
return value.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/server/routes/files.ts
|
|
555
|
+
import { Hono as Hono2 } from "hono";
|
|
556
|
+
import fs2 from "fs/promises";
|
|
557
|
+
import path2 from "path";
|
|
558
|
+
|
|
559
|
+
// src/core/echo/parser.ts
|
|
560
|
+
import { nanoid } from "nanoid";
|
|
561
|
+
function parseEcho(raw) {
|
|
562
|
+
const lines = raw.split("\n").filter((line) => line.trim().length > 0);
|
|
563
|
+
if (lines.length === 0) {
|
|
564
|
+
throw new Error("Empty .echo file");
|
|
565
|
+
}
|
|
566
|
+
const records = lines.map((line, i) => {
|
|
567
|
+
try {
|
|
568
|
+
return JSON.parse(line);
|
|
569
|
+
} catch {
|
|
570
|
+
throw new Error(`Invalid JSON on line ${i + 1}`);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
const metaRecord = records[0];
|
|
574
|
+
if (!metaRecord || metaRecord.kind !== "meta") {
|
|
575
|
+
throw new Error("First line must be a meta record");
|
|
576
|
+
}
|
|
577
|
+
const messages = records.filter((r) => r.kind === "message").map(normalizeMessage);
|
|
578
|
+
return {
|
|
579
|
+
meta: metaRecord,
|
|
580
|
+
messages
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
function normalizeMessage(message) {
|
|
584
|
+
const raw = message;
|
|
585
|
+
let msg = message;
|
|
586
|
+
if (!msg.id) {
|
|
587
|
+
msg = { ...msg, id: nanoid(8) };
|
|
588
|
+
}
|
|
589
|
+
if (!msg.created_at && (raw.ts || raw.timestamp)) {
|
|
590
|
+
msg = { ...msg, created_at: raw.ts ?? raw.timestamp };
|
|
591
|
+
}
|
|
592
|
+
const needsPartNormalization = msg.parts.some((p) => {
|
|
593
|
+
if (p.type !== "tool_result") return false;
|
|
594
|
+
const r = p;
|
|
595
|
+
return !r.id && (r.tool_call_id || r.tool_use_id);
|
|
596
|
+
});
|
|
597
|
+
if (needsPartNormalization) {
|
|
598
|
+
msg = {
|
|
599
|
+
...msg,
|
|
600
|
+
parts: msg.parts.map((p) => {
|
|
601
|
+
if (p.type !== "tool_result") return p;
|
|
602
|
+
const r = p;
|
|
603
|
+
if (!r.id && (r.tool_call_id || r.tool_use_id)) {
|
|
604
|
+
const { tool_call_id, tool_use_id, ...rest } = r;
|
|
605
|
+
return { ...rest, id: tool_call_id ?? tool_use_id };
|
|
606
|
+
}
|
|
607
|
+
return p;
|
|
608
|
+
})
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
return msg;
|
|
612
|
+
}
|
|
613
|
+
function serializeEcho(conversation) {
|
|
614
|
+
const records = [conversation.meta, ...conversation.messages];
|
|
615
|
+
return records.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/core/history/parser.ts
|
|
619
|
+
function parseHistory(raw) {
|
|
620
|
+
const lines = raw.split("\n").filter((line) => line.trim().length > 0);
|
|
621
|
+
const parsed = lines.map((line, i) => {
|
|
622
|
+
try {
|
|
623
|
+
return JSON.parse(line);
|
|
624
|
+
} catch {
|
|
625
|
+
throw new Error(`Invalid JSON in history on line ${i + 1}`);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
const eventMap = /* @__PURE__ */ new Map();
|
|
629
|
+
for (const e of parsed) {
|
|
630
|
+
eventMap.set(e.id, e);
|
|
631
|
+
}
|
|
632
|
+
const events = [...eventMap.values()];
|
|
633
|
+
return { events, eventMap };
|
|
634
|
+
}
|
|
635
|
+
function serializeHistory(history) {
|
|
636
|
+
return history.events.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
637
|
+
}
|
|
638
|
+
function serializeEvent(event) {
|
|
639
|
+
return JSON.stringify(event) + "\n";
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/server/routes/files.ts
|
|
643
|
+
function fileRoutes(options) {
|
|
644
|
+
const app = new Hono2();
|
|
645
|
+
app.get("/", async (c) => {
|
|
646
|
+
try {
|
|
647
|
+
const entries = await fs2.readdir(options.workspaceDir, {
|
|
648
|
+
withFileTypes: true,
|
|
649
|
+
recursive: false
|
|
650
|
+
});
|
|
651
|
+
const files = await Promise.all(
|
|
652
|
+
entries.filter((e) => e.isFile() && e.name.endsWith(".echo")).map(async (e) => {
|
|
653
|
+
const filePath = path2.join(options.workspaceDir, e.name);
|
|
654
|
+
const stat = await fs2.stat(filePath);
|
|
655
|
+
return {
|
|
656
|
+
name: e.name,
|
|
657
|
+
path: filePath,
|
|
658
|
+
modified_at: stat.mtime.toISOString(),
|
|
659
|
+
size: stat.size
|
|
660
|
+
};
|
|
661
|
+
})
|
|
662
|
+
);
|
|
663
|
+
files.sort(
|
|
664
|
+
(a, b) => new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime()
|
|
665
|
+
);
|
|
666
|
+
return c.json({ files });
|
|
667
|
+
} catch (err) {
|
|
668
|
+
return c.json({ files: [] });
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
app.get("/:filename", async (c) => {
|
|
672
|
+
const filename = c.req.param("filename");
|
|
673
|
+
const filePath = path2.join(options.workspaceDir, filename);
|
|
674
|
+
try {
|
|
675
|
+
const raw = await fs2.readFile(filePath, "utf-8");
|
|
676
|
+
const conversation = parseEcho(raw);
|
|
677
|
+
return c.json({ conversation, raw });
|
|
678
|
+
} catch (err) {
|
|
679
|
+
return c.json(
|
|
680
|
+
{ error: `Failed to read ${filename}: ${err.message}` },
|
|
681
|
+
404
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
app.put("/:filename", async (c) => {
|
|
686
|
+
const filename = c.req.param("filename");
|
|
687
|
+
const filePath = path2.join(options.workspaceDir, filename);
|
|
688
|
+
const body = await c.req.json();
|
|
689
|
+
try {
|
|
690
|
+
const content = body.raw ?? serializeEcho(body.conversation);
|
|
691
|
+
await fs2.writeFile(filePath, content, "utf-8");
|
|
692
|
+
return c.json({ ok: true });
|
|
693
|
+
} catch (err) {
|
|
694
|
+
return c.json(
|
|
695
|
+
{ error: `Failed to write ${filename}: ${err.message}` },
|
|
696
|
+
500
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
app.post("/", async (c) => {
|
|
701
|
+
const body = await c.req.json();
|
|
702
|
+
const filename = body.filename;
|
|
703
|
+
const filePath = path2.join(options.workspaceDir, filename);
|
|
704
|
+
try {
|
|
705
|
+
await fs2.access(filePath);
|
|
706
|
+
return c.json({ error: `${filename} already exists` }, 409);
|
|
707
|
+
} catch {
|
|
708
|
+
}
|
|
709
|
+
try {
|
|
710
|
+
const content = body.raw ?? serializeEcho(body.conversation);
|
|
711
|
+
await fs2.writeFile(filePath, content, "utf-8");
|
|
712
|
+
return c.json({ ok: true, path: filePath });
|
|
713
|
+
} catch (err) {
|
|
714
|
+
return c.json(
|
|
715
|
+
{ error: `Failed to create ${filename}: ${err.message}` },
|
|
716
|
+
500
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
app.delete("/:filename", async (c) => {
|
|
721
|
+
const filename = c.req.param("filename");
|
|
722
|
+
const filePath = path2.join(options.workspaceDir, filename);
|
|
723
|
+
try {
|
|
724
|
+
await fs2.unlink(filePath);
|
|
725
|
+
const historyPath = filePath + "-history";
|
|
726
|
+
try {
|
|
727
|
+
await fs2.unlink(historyPath);
|
|
728
|
+
} catch {
|
|
729
|
+
}
|
|
730
|
+
return c.json({ ok: true });
|
|
731
|
+
} catch (err) {
|
|
732
|
+
return c.json(
|
|
733
|
+
{ error: `Failed to delete ${filename}: ${err.message}` },
|
|
734
|
+
500
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
app.get("/:filename/history", async (c) => {
|
|
739
|
+
const filename = c.req.param("filename");
|
|
740
|
+
const historyPath = path2.join(
|
|
741
|
+
options.workspaceDir,
|
|
742
|
+
filename + "-history"
|
|
743
|
+
);
|
|
744
|
+
try {
|
|
745
|
+
const raw = await fs2.readFile(historyPath, "utf-8");
|
|
746
|
+
const history = parseHistory(raw);
|
|
747
|
+
return c.json({ events: history.events });
|
|
748
|
+
} catch {
|
|
749
|
+
return c.json({ events: [] });
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
app.put("/:filename/history", async (c) => {
|
|
753
|
+
const filename = c.req.param("filename");
|
|
754
|
+
const historyPath = path2.join(
|
|
755
|
+
options.workspaceDir,
|
|
756
|
+
filename + "-history"
|
|
757
|
+
);
|
|
758
|
+
const { events } = await c.req.json();
|
|
759
|
+
try {
|
|
760
|
+
const history = { events, eventMap: new Map(events.map((e) => [e.id, e])) };
|
|
761
|
+
await fs2.writeFile(historyPath, serializeHistory(history), "utf-8");
|
|
762
|
+
return c.json({ ok: true });
|
|
763
|
+
} catch (err) {
|
|
764
|
+
return c.json(
|
|
765
|
+
{ error: `Failed to compact history: ${err.message}` },
|
|
766
|
+
500
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
app.post("/:filename/history", async (c) => {
|
|
771
|
+
const filename = c.req.param("filename");
|
|
772
|
+
const historyPath = path2.join(
|
|
773
|
+
options.workspaceDir,
|
|
774
|
+
filename + "-history"
|
|
775
|
+
);
|
|
776
|
+
const event = await c.req.json();
|
|
777
|
+
try {
|
|
778
|
+
const line = serializeEvent(event);
|
|
779
|
+
await fs2.appendFile(historyPath, line, "utf-8");
|
|
780
|
+
return c.json({ ok: true });
|
|
781
|
+
} catch (err) {
|
|
782
|
+
return c.json(
|
|
783
|
+
{ error: `Failed to append history: ${err.message}` },
|
|
784
|
+
500
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
return app;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// src/server/routes/config.ts
|
|
792
|
+
import { Hono as Hono3 } from "hono";
|
|
793
|
+
function configRoutes(options) {
|
|
794
|
+
const app = new Hono3();
|
|
795
|
+
app.get("/", async (c) => {
|
|
796
|
+
const config = loadConfig(options.configDir);
|
|
797
|
+
return c.json(config);
|
|
798
|
+
});
|
|
799
|
+
app.put("/", async (c) => {
|
|
800
|
+
const body = await c.req.json();
|
|
801
|
+
saveConfig(options.configDir, body);
|
|
802
|
+
return c.json({ ok: true });
|
|
803
|
+
});
|
|
804
|
+
app.get("/providers", async (c) => {
|
|
805
|
+
const config = loadConfig(options.configDir);
|
|
806
|
+
const providerList = config.providers ?? [];
|
|
807
|
+
const providers = providerList.filter((p) => {
|
|
808
|
+
if (p.api_key == null) return true;
|
|
809
|
+
const resolved = p.api_key.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "");
|
|
810
|
+
return resolved.length > 0;
|
|
811
|
+
}).map((p) => ({
|
|
812
|
+
name: p.name,
|
|
813
|
+
type: p.type,
|
|
814
|
+
models: p.models ?? []
|
|
815
|
+
}));
|
|
816
|
+
return c.json({ providers });
|
|
817
|
+
});
|
|
818
|
+
return app;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/server/index.ts
|
|
822
|
+
var __dirname = path3.dirname(fileURLToPath(import.meta.url));
|
|
823
|
+
var clientDir = path3.resolve(__dirname, "../client");
|
|
824
|
+
function createServer(options) {
|
|
825
|
+
const app = new Hono4();
|
|
826
|
+
app.use("*", logger());
|
|
827
|
+
app.use("*", cors());
|
|
828
|
+
app.route("/api/files", fileRoutes(options));
|
|
829
|
+
app.route("/api/chat", chatRoutes(options));
|
|
830
|
+
app.route("/api/config", configRoutes(options));
|
|
831
|
+
if (options.dev) {
|
|
832
|
+
const viteUrl = "http://localhost:5173";
|
|
833
|
+
app.all("*", async (c) => {
|
|
834
|
+
const url = new URL(c.req.url);
|
|
835
|
+
const target = `${viteUrl}${url.pathname}${url.search}`;
|
|
836
|
+
const res = await fetch(target, {
|
|
837
|
+
method: c.req.method,
|
|
838
|
+
headers: c.req.raw.headers,
|
|
839
|
+
body: c.req.method === "GET" || c.req.method === "HEAD" ? void 0 : c.req.raw.body,
|
|
840
|
+
// @ts-expect-error -- Node fetch supports duplex for streaming bodies
|
|
841
|
+
duplex: "half"
|
|
842
|
+
});
|
|
843
|
+
return new Response(res.body, {
|
|
844
|
+
status: res.status,
|
|
845
|
+
headers: res.headers
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
} else {
|
|
849
|
+
app.use("/*", serveStatic({ root: clientDir }));
|
|
850
|
+
app.get("*", serveStatic({ root: clientDir, path: "index.html" }));
|
|
851
|
+
}
|
|
852
|
+
return app;
|
|
853
|
+
}
|
|
854
|
+
function startServer(options) {
|
|
855
|
+
const app = createServer(options);
|
|
856
|
+
serve(
|
|
857
|
+
{ fetch: app.fetch, port: options.port },
|
|
858
|
+
(info) => {
|
|
859
|
+
console.log(`EchoSpace running at http://localhost:${info.port}`);
|
|
860
|
+
}
|
|
861
|
+
);
|
|
862
|
+
return app;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// src/cli/index.ts
|
|
866
|
+
var VERSION = "0.1.0-alpha.1";
|
|
867
|
+
var CONFIG_DIR = path4.join(homedir(), ".echospace");
|
|
868
|
+
function loadEnvFile(dir) {
|
|
869
|
+
const envPath = path4.join(dir, ".env");
|
|
870
|
+
if (!fs3.existsSync(envPath)) return;
|
|
871
|
+
const lines = fs3.readFileSync(envPath, "utf-8").split("\n");
|
|
872
|
+
for (const line of lines) {
|
|
873
|
+
const trimmed = line.trim();
|
|
874
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
875
|
+
const eqIdx = trimmed.indexOf("=");
|
|
876
|
+
if (eqIdx === -1) continue;
|
|
877
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
878
|
+
const value = trimmed.slice(eqIdx + 1).trim();
|
|
879
|
+
if (!process.env[key]) {
|
|
880
|
+
process.env[key] = value;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
var program = new Command();
|
|
885
|
+
program.name("echospace").description("The best open-source local prompt debugging tool").version(VERSION).argument("[workdir]", "Workspace directory", ".").option("-p, --port <port>", "Port to serve on").option("--no-open", "Don't open browser automatically").action(async (workdir, options) => {
|
|
886
|
+
const baseDir = path4.resolve(process.cwd(), workdir);
|
|
887
|
+
const workspaceDir = path4.join(baseDir, ".echo");
|
|
888
|
+
loadEnvFile(baseDir);
|
|
889
|
+
loadEnvFile(process.cwd());
|
|
890
|
+
if (!fs3.existsSync(workspaceDir)) {
|
|
891
|
+
fs3.mkdirSync(workspaceDir, { recursive: true });
|
|
892
|
+
}
|
|
893
|
+
fs3.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
894
|
+
ensureConfig(CONFIG_DIR);
|
|
895
|
+
const port = options.port ? parseInt(options.port, 10) : await getPort({ port: portNumbers(7788, 7799) });
|
|
896
|
+
console.log(`
|
|
897
|
+
\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
|
|
898
|
+
\u2551 EchoSpace v${VERSION.padEnd(12)}\u2551
|
|
899
|
+
\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563
|
|
900
|
+
\u2551 Workspace: ${workspaceDir.padEnd(24)}\u2551
|
|
901
|
+
\u2551 URL: http://localhost:${String(port).padEnd(8)}\u2551
|
|
902
|
+
\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
|
|
903
|
+
`);
|
|
904
|
+
const isDev = process.env.NODE_ENV !== "production" && import.meta.url.includes("/src/");
|
|
905
|
+
startServer({ port, workspaceDir, configDir: CONFIG_DIR, dev: isDev });
|
|
906
|
+
if (options.open) {
|
|
907
|
+
await open(`http://localhost:${port}`);
|
|
908
|
+
}
|
|
909
|
+
const shutdown = () => {
|
|
910
|
+
console.log("\nShutting down EchoSpace...");
|
|
911
|
+
process.exit(0);
|
|
912
|
+
};
|
|
913
|
+
process.on("SIGINT", shutdown);
|
|
914
|
+
process.on("SIGTERM", shutdown);
|
|
915
|
+
});
|
|
916
|
+
program.parse();
|