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.
@@ -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();