@lovenyberg/ove 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { ChatAdapter, IncomingMessage } from "./types";
1
+ import type { ChatAdapter, IncomingMessage, AdapterStatus } from "./types";
2
2
  import { createInterface } from "node:readline";
3
3
 
4
4
  const DIM = "\x1b[2m";
@@ -12,6 +12,8 @@ export class CliAdapter implements ChatAdapter {
12
12
  private userId: string;
13
13
  private statusLines: string[] = [];
14
14
  private statusLinesShown = 0;
15
+ private started = false;
16
+ private startedAt?: string;
15
17
 
16
18
  constructor(userId: string = "cli:local") {
17
19
  this.userId = userId;
@@ -42,6 +44,9 @@ export class CliAdapter implements ChatAdapter {
42
44
  prompt: "\nove> ",
43
45
  });
44
46
 
47
+ this.started = true;
48
+ this.startedAt = new Date().toISOString();
49
+
45
50
  console.log("\n--- Ove ---");
46
51
  console.log("Ja. What do you want? Type 'help' if you need it.\n");
47
52
 
@@ -87,7 +92,17 @@ export class CliAdapter implements ChatAdapter {
87
92
  });
88
93
  }
89
94
 
95
+ getStatus(): AdapterStatus {
96
+ return {
97
+ name: "cli",
98
+ type: "chat",
99
+ status: this.started ? "connected" : "disconnected",
100
+ startedAt: this.startedAt,
101
+ };
102
+ }
103
+
90
104
  async stop(): Promise<void> {
105
+ this.started = false;
91
106
  this.rl?.close();
92
107
  }
93
108
  }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { debounce } from "./debounce";
3
+
4
+ describe("debounce", () => {
5
+ it("delays execution", async () => {
6
+ let called = 0;
7
+ const fn = debounce(() => { called++; }, 50);
8
+ fn();
9
+ expect(called).toBe(0);
10
+ await Bun.sleep(80);
11
+ expect(called).toBe(1);
12
+ });
13
+
14
+ it("collapses rapid calls", async () => {
15
+ let lastArg: string | undefined;
16
+ const fn = debounce((v: string) => { lastArg = v; }, 50);
17
+ fn("a");
18
+ fn("b");
19
+ fn("c");
20
+ await Bun.sleep(80);
21
+ expect(lastArg).toBe("c");
22
+ });
23
+
24
+ it("flush fires immediately", () => {
25
+ let called = 0;
26
+ const fn = debounce(() => { called++; }, 5000);
27
+ fn();
28
+ expect(called).toBe(0);
29
+ fn.flush();
30
+ expect(called).toBe(1);
31
+ });
32
+
33
+ it("flush with no pending is a no-op", () => {
34
+ let called = 0;
35
+ const fn = debounce(() => { called++; }, 5000);
36
+ fn.flush();
37
+ expect(called).toBe(0);
38
+ });
39
+
40
+ it("cancel discards pending", async () => {
41
+ let called = 0;
42
+ const fn = debounce(() => { called++; }, 50);
43
+ fn();
44
+ fn.cancel();
45
+ await Bun.sleep(80);
46
+ expect(called).toBe(0);
47
+ });
48
+
49
+ it("flush uses latest args", () => {
50
+ let lastArg: string | undefined;
51
+ const fn = debounce((v: string) => { lastArg = v; }, 5000);
52
+ fn("first");
53
+ fn("second");
54
+ fn.flush();
55
+ expect(lastArg).toBe("second");
56
+ });
57
+ });
@@ -1,10 +1,42 @@
1
- export function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
1
+ export type DebouncedFunction<T extends (...args: any[]) => any> = T & {
2
+ flush(): void;
3
+ cancel(): void;
4
+ };
5
+
6
+ export function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): DebouncedFunction<T> {
2
7
  let timer: ReturnType<typeof setTimeout> | null = null;
3
- return ((...args: any[]) => {
8
+ let lastArgs: any[] | null = null;
9
+
10
+ const debounced = ((...args: any[]) => {
11
+ lastArgs = args;
4
12
  if (timer) clearTimeout(timer);
5
13
  timer = setTimeout(() => {
6
14
  timer = null;
7
- fn(...args);
15
+ const pending = lastArgs;
16
+ lastArgs = null;
17
+ if (pending) fn(...pending);
8
18
  }, ms);
9
- }) as any as T;
19
+ }) as DebouncedFunction<T>;
20
+
21
+ debounced.flush = () => {
22
+ if (timer) {
23
+ clearTimeout(timer);
24
+ timer = null;
25
+ }
26
+ if (lastArgs) {
27
+ const args = lastArgs;
28
+ lastArgs = null;
29
+ fn(...args);
30
+ }
31
+ };
32
+
33
+ debounced.cancel = () => {
34
+ if (timer) {
35
+ clearTimeout(timer);
36
+ timer = null;
37
+ }
38
+ lastArgs = null;
39
+ };
40
+
41
+ return debounced;
10
42
  }
@@ -1,5 +1,5 @@
1
1
  import { Client, GatewayIntentBits, type Message } from "discord.js";
2
- import type { ChatAdapter, IncomingMessage } from "./types";
2
+ import type { ChatAdapter, IncomingMessage, AdapterStatus } from "./types";
3
3
  import { logger } from "../logger";
4
4
  import { debounce } from "./debounce";
5
5
 
@@ -7,6 +7,8 @@ export class DiscordAdapter implements ChatAdapter {
7
7
  private client: Client;
8
8
  private token: string;
9
9
  private onMessage?: (msg: IncomingMessage) => void;
10
+ private started = false;
11
+ private startedAt?: string;
10
12
 
11
13
  constructor(token: string) {
12
14
  if (!token) throw new Error("Discord bot token is required");
@@ -48,7 +50,8 @@ export class DiscordAdapter implements ChatAdapter {
48
50
  } else {
49
51
  statusMsg = await discordMsg.channel.send(statusText);
50
52
  }
51
- } catch {
53
+ } catch (err) {
54
+ logger.warn("discord status update failed", { error: String(err) });
52
55
  statusMsg = await discordMsg.channel.send(statusText);
53
56
  }
54
57
  }, 3000);
@@ -68,10 +71,22 @@ export class DiscordAdapter implements ChatAdapter {
68
71
  });
69
72
 
70
73
  await this.client.login(this.token);
74
+ this.started = true;
75
+ this.startedAt = new Date().toISOString();
71
76
  logger.info("discord adapter started");
72
77
  }
73
78
 
79
+ getStatus(): AdapterStatus {
80
+ return {
81
+ name: "discord",
82
+ type: "chat",
83
+ status: this.started ? "connected" : "disconnected",
84
+ startedAt: this.startedAt,
85
+ };
86
+ }
87
+
74
88
  async stop(): Promise<void> {
89
+ this.started = false;
75
90
  this.client.destroy();
76
91
  logger.info("discord adapter stopped");
77
92
  }
@@ -1,4 +1,4 @@
1
- import type { EventAdapter, IncomingEvent, EventSource } from "./types";
1
+ import type { EventAdapter, IncomingEvent, EventSource, AdapterStatus } from "./types";
2
2
  import { logger } from "../logger";
3
3
 
4
4
  export function parseMention(body: string, botName: string): string | null {
@@ -21,6 +21,9 @@ export class GitHubAdapter implements EventAdapter {
21
21
  private onEvent?: (event: IncomingEvent) => void;
22
22
  private seenCommentIds = new Set<number>();
23
23
  private pollTimer?: ReturnType<typeof setInterval>;
24
+ private started = false;
25
+ private startedAt?: string;
26
+ private lastPollError?: string;
24
27
 
25
28
  constructor(repos: string[], botName: string, pollIntervalMs: number = 30_000) {
26
29
  if (!repos.length) throw new Error("GitHub adapter requires at least one repo");
@@ -38,10 +41,28 @@ export class GitHubAdapter implements EventAdapter {
38
41
  }
39
42
 
40
43
  this.pollTimer = setInterval(() => this.pollAll(), this.pollIntervalMs);
44
+ this.started = true;
45
+ this.startedAt = new Date().toISOString();
41
46
  logger.info("github adapter started", { repos: this.repos, pollMs: this.pollIntervalMs });
42
47
  }
43
48
 
49
+ getStatus(): AdapterStatus {
50
+ let status: AdapterStatus["status"] = "disconnected";
51
+ if (this.started && !this.lastPollError) status = "connected";
52
+ else if (this.started && this.lastPollError) status = "degraded";
53
+
54
+ return {
55
+ name: "github",
56
+ type: "event",
57
+ status,
58
+ error: this.lastPollError,
59
+ details: { repos: this.repos, pollIntervalMs: this.pollIntervalMs },
60
+ startedAt: this.startedAt,
61
+ };
62
+ }
63
+
44
64
  async stop(): Promise<void> {
65
+ this.started = false;
45
66
  if (this.pollTimer) clearInterval(this.pollTimer);
46
67
  logger.info("github adapter stopped");
47
68
  }
@@ -72,13 +93,17 @@ export class GitHubAdapter implements EventAdapter {
72
93
  }
73
94
 
74
95
  private async pollAll(): Promise<void> {
96
+ let hadError = false;
75
97
  for (const repo of this.repos) {
76
98
  try {
77
99
  await this.pollRepo(repo);
78
100
  } catch (err) {
101
+ hadError = true;
102
+ this.lastPollError = String(err);
79
103
  logger.error("github poll error", { repo, error: String(err) });
80
104
  }
81
105
  }
106
+ if (!hadError) this.lastPollError = undefined;
82
107
  }
83
108
 
84
109
  private async pollRepo(repo: string): Promise<void> {
@@ -1,4 +1,7 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
3
+ import { TraceStore } from "../trace";
4
+ import { TaskQueue } from "../queue";
2
5
  import type { IncomingEvent } from "./types";
3
6
 
4
7
  let adapter: any;
@@ -10,7 +13,10 @@ describe("HttpApiAdapter", () => {
10
13
  beforeAll(async () => {
11
14
  const { HttpApiAdapter } = await import("./http");
12
15
  receivedEvents = [];
13
- adapter = new HttpApiAdapter(TEST_PORT, API_KEY);
16
+ const db = new Database(":memory:");
17
+ const trace = new TraceStore(db);
18
+ const queue = new TaskQueue(db);
19
+ adapter = new HttpApiAdapter(TEST_PORT, API_KEY, trace, queue);
14
20
  await adapter.start((event: IncomingEvent) => {
15
21
  receivedEvents.push(event);
16
22
  });
@@ -1,11 +1,14 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import type { EventAdapter, IncomingEvent } from "./types";
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join, extname } from "node:path";
3
+ import type { EventAdapter, IncomingEvent, IncomingMessage, ChatAdapter, AdapterStatus } from "./types";
4
+ import type { TraceStore } from "../trace";
5
+ import type { TaskQueue } from "../queue";
6
+ import type { SessionStore } from "../sessions";
4
7
  import { logger } from "../logger";
5
8
 
6
- interface PendingEvent {
9
+ interface PendingChat {
7
10
  status: "pending" | "completed";
8
- result?: string;
11
+ replies: string[];
9
12
  statusText?: string;
10
13
  sseControllers: ReadableStreamDefaultController[];
11
14
  }
@@ -13,19 +16,65 @@ interface PendingEvent {
13
16
  export class HttpApiAdapter implements EventAdapter {
14
17
  private port: number;
15
18
  private apiKey: string;
19
+ private trace: TraceStore;
20
+ private queue: TaskQueue | null;
21
+ private sessions: SessionStore | null;
16
22
  private server?: ReturnType<typeof Bun.serve>;
17
23
  private onEvent?: (event: IncomingEvent) => void;
18
- private events = new Map<string, PendingEvent>();
24
+ private onMessage?: (msg: IncomingMessage) => void;
25
+ private chats = new Map<string, PendingChat>();
19
26
  private webUiHtml: string;
27
+ private traceUiHtml: string;
28
+ private statusUiHtml: string;
29
+ private publicDir: string;
30
+ private chatAdapters: ChatAdapter[] = [];
31
+ private eventAdapters: EventAdapter[] = [];
32
+ private startedAt?: string;
20
33
 
21
- constructor(port: number, apiKey: string) {
34
+ constructor(port: number, apiKey: string, trace: TraceStore, queue?: TaskQueue, sessions?: SessionStore) {
22
35
  this.port = port;
23
36
  this.apiKey = apiKey;
37
+ this.trace = trace;
38
+ this.queue = queue || null;
39
+ this.sessions = sessions || null;
40
+ const publicDir = join(import.meta.dir, "../../public");
41
+ this.publicDir = publicDir;
24
42
  try {
25
- this.webUiHtml = readFileSync(join(import.meta.dir, "../../public/index.html"), "utf-8");
43
+ this.webUiHtml = readFileSync(join(publicDir, "index.html"), "utf-8");
26
44
  } catch {
27
45
  this.webUiHtml = "<html><body><p>Web UI not found. Place public/index.html in project root.</p></body></html>";
28
46
  }
47
+ try {
48
+ this.traceUiHtml = readFileSync(join(publicDir, "trace.html"), "utf-8");
49
+ } catch {
50
+ this.traceUiHtml = "<html><body><p>Trace viewer not found. Place public/trace.html in project root.</p></body></html>";
51
+ }
52
+ try {
53
+ this.statusUiHtml = readFileSync(join(publicDir, "status.html"), "utf-8");
54
+ } catch {
55
+ this.statusUiHtml = "<html><body><p>Status page not found. Place public/status.html in project root.</p></body></html>";
56
+ }
57
+ }
58
+
59
+ /** Set the chat message handler so web UI messages go through the full chat pipeline */
60
+ setMessageHandler(handler: (msg: IncomingMessage) => void): void {
61
+ this.onMessage = handler;
62
+ }
63
+
64
+ /** Register all adapters so the status page can query them */
65
+ setAdapters(chat: ChatAdapter[], event: EventAdapter[]): void {
66
+ this.chatAdapters = chat;
67
+ this.eventAdapters = event;
68
+ }
69
+
70
+ getStatus(): AdapterStatus {
71
+ return {
72
+ name: "http",
73
+ type: "event",
74
+ status: this.server ? "connected" : "disconnected",
75
+ startedAt: this.startedAt,
76
+ details: { port: this.port },
77
+ };
29
78
  }
30
79
 
31
80
  async start(onEvent: (event: IncomingEvent) => void): Promise<void> {
@@ -34,6 +83,7 @@ export class HttpApiAdapter implements EventAdapter {
34
83
 
35
84
  this.server = Bun.serve({
36
85
  port: this.port,
86
+ idleTimeout: 255, // SSE connections need to stay open for long-running tasks
37
87
  async fetch(req) {
38
88
  const url = new URL(req.url);
39
89
  const path = url.pathname;
@@ -45,6 +95,18 @@ export class HttpApiAdapter implements EventAdapter {
45
95
  });
46
96
  }
47
97
 
98
+ if (path === "/trace" || path === "/trace.html") {
99
+ return new Response(self.traceUiHtml, {
100
+ headers: { "Content-Type": "text/html" },
101
+ });
102
+ }
103
+
104
+ if (path === "/status" || path === "/status.html") {
105
+ return new Response(self.statusUiHtml, {
106
+ headers: { "Content-Type": "text/html" },
107
+ });
108
+ }
109
+
48
110
  // Auth check for API routes
49
111
  if (path.startsWith("/api/")) {
50
112
  const key = req.headers.get("X-API-Key") || url.searchParams.get("key");
@@ -53,49 +115,127 @@ export class HttpApiAdapter implements EventAdapter {
53
115
  }
54
116
  }
55
117
 
56
- // POST /api/messagesubmit a task
118
+ // GET /api/statusadapter health + queue stats
119
+ if (path === "/api/status" && req.method === "GET") {
120
+ const adapterStatuses: AdapterStatus[] = [];
121
+ for (const a of self.chatAdapters) {
122
+ adapterStatuses.push(a.getStatus?.() ?? { name: a.constructor.name, type: "chat", status: "unknown" });
123
+ }
124
+ for (const a of self.eventAdapters) {
125
+ adapterStatuses.push(a.getStatus?.() ?? { name: a.constructor.name, type: "event", status: "unknown" });
126
+ }
127
+ const queueStats = self.queue?.stats() ?? { pending: 0, running: 0, completed: 0, failed: 0 };
128
+ return Response.json({
129
+ adapters: adapterStatuses,
130
+ queue: queueStats,
131
+ uptime: process.uptime(),
132
+ timestamp: new Date().toISOString(),
133
+ });
134
+ }
135
+
136
+ // GET /api/tasks — list recent tasks
137
+ if (path === "/api/tasks" && req.method === "GET") {
138
+ if (!self.queue) {
139
+ return Response.json({ error: "Task queue not available" }, { status: 503 });
140
+ }
141
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20") || 20, 100);
142
+ const status = url.searchParams.get("status") || undefined;
143
+ const tasks = self.queue.listRecent(limit, status);
144
+ return Response.json(tasks.map((t) => ({
145
+ id: t.id,
146
+ userId: t.userId,
147
+ repo: t.repo,
148
+ prompt: t.prompt,
149
+ status: t.status,
150
+ result: t.result && t.result.length > 300 ? t.result.slice(0, 300) + "..." : t.result,
151
+ createdAt: t.createdAt,
152
+ completedAt: t.completedAt,
153
+ })));
154
+ }
155
+
156
+ // POST /api/message — submit a chat message (full chat pipeline)
57
157
  if (path === "/api/message" && req.method === "POST") {
58
158
  const body = await req.json() as { text: string; userId?: string };
59
- const eventId = crypto.randomUUID();
60
- const userId = body.userId || "http:anon";
159
+ const chatId = crypto.randomUUID();
160
+ const userId = body.userId || "http:web";
161
+
162
+ const chat: PendingChat = { status: "pending", replies: [], sseControllers: [] };
163
+ self.chats.set(chatId, chat);
61
164
 
62
- self.events.set(eventId, { status: "pending", sseControllers: [] });
165
+ function notifySSE(data: object) {
166
+ const payload = JSON.stringify(data);
167
+ for (const ctrl of chat.sseControllers) {
168
+ try { ctrl.enqueue(`data: ${payload}\n\n`); } catch {}
169
+ }
170
+ }
63
171
 
64
- const event: IncomingEvent = {
65
- eventId,
66
- userId,
67
- platform: "http",
68
- source: { type: "http", requestId: eventId },
69
- text: body.text,
70
- };
172
+ if (self.onMessage) {
173
+ // Route through full chat handler (commands, session, repo resolution, etc.)
174
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
175
+ const msg: IncomingMessage = {
176
+ userId,
177
+ platform: "http",
178
+ text: body.text,
179
+ reply: async (text: string) => {
180
+ chat.replies.push(text);
181
+ chat.status = "completed";
182
+ notifySSE({ status: "completed", result: chat.replies.join("\n\n") });
183
+ // Delay closing SSE to allow multiple split replies to arrive
184
+ if (closeTimer) clearTimeout(closeTimer);
185
+ closeTimer = setTimeout(() => {
186
+ for (const ctrl of chat.sseControllers) {
187
+ try { ctrl.close(); } catch {}
188
+ }
189
+ chat.sseControllers = [];
190
+ setTimeout(() => self.chats.delete(chatId), 5 * 60 * 1000);
191
+ }, 500);
192
+ },
193
+ updateStatus: async (text: string) => {
194
+ chat.statusText = text;
195
+ notifySSE({ status: "pending", statusText: text });
196
+ },
197
+ };
198
+ self.onMessage(msg);
199
+ } else {
200
+ // Fallback to event handler
201
+ const event: IncomingEvent = {
202
+ eventId: chatId,
203
+ userId,
204
+ platform: "http",
205
+ source: { type: "http", requestId: chatId },
206
+ text: body.text,
207
+ };
208
+ self.onEvent?.(event);
209
+ }
71
210
 
72
- self.onEvent?.(event);
73
- return Response.json({ eventId }, { status: 202 });
211
+ return Response.json({ eventId: chatId }, { status: 202 });
74
212
  }
75
213
 
76
214
  // GET /api/message/:id/stream — SSE stream
77
215
  const streamMatch = path.match(/^\/api\/message\/([^/]+)\/stream$/);
78
216
  if (streamMatch && req.method === "GET") {
79
- const eventId = streamMatch[1];
80
- const pending = self.events.get(eventId);
81
- if (!pending) {
217
+ const chatId = streamMatch[1];
218
+ const chat = self.chats.get(chatId);
219
+ if (!chat) {
82
220
  return Response.json({ error: "Not found" }, { status: 404 });
83
221
  }
84
222
 
223
+ let sseController: ReadableStreamDefaultController;
85
224
  const stream = new ReadableStream({
86
225
  start(controller) {
87
- pending.sseControllers.push(controller);
226
+ sseController = controller;
227
+ chat.sseControllers.push(controller);
88
228
  // Send current state immediately
89
229
  const data = JSON.stringify({
90
- status: pending.status,
91
- result: pending.result,
92
- statusText: pending.statusText,
230
+ status: chat.status,
231
+ result: chat.replies.length > 0 ? chat.replies.join("\n\n") : undefined,
232
+ statusText: chat.statusText,
93
233
  });
94
234
  controller.enqueue(`data: ${data}\n\n`);
95
235
  },
96
236
  cancel() {
97
- const idx = pending.sseControllers.indexOf(controller);
98
- if (idx >= 0) pending.sseControllers.splice(idx, 1);
237
+ const idx = chat.sseControllers.indexOf(sseController);
238
+ if (idx >= 0) chat.sseControllers.splice(idx, 1);
99
239
  },
100
240
  });
101
241
 
@@ -111,21 +251,53 @@ export class HttpApiAdapter implements EventAdapter {
111
251
  // GET /api/message/:id — poll status
112
252
  const getMatch = path.match(/^\/api\/message\/([^/]+)$/);
113
253
  if (getMatch && req.method === "GET") {
114
- const eventId = getMatch[1];
115
- const pending = self.events.get(eventId);
116
- if (!pending) {
254
+ const chatId = getMatch[1];
255
+ const chat = self.chats.get(chatId);
256
+ if (!chat) {
117
257
  return Response.json({ error: "Not found" }, { status: 404 });
118
258
  }
119
259
  return Response.json({
120
- status: pending.status,
121
- result: pending.result,
260
+ status: chat.status,
261
+ result: chat.replies.length > 0 ? chat.replies.join("\n\n") : undefined,
122
262
  });
123
263
  }
124
264
 
265
+ // GET /api/trace/:taskId — trace events for a task
266
+ const traceMatch = path.match(/^\/api\/trace\/([^/]+)$/);
267
+ if (traceMatch && req.method === "GET") {
268
+ const taskId = traceMatch[1];
269
+ const events = self.trace.getByTask(taskId);
270
+ return Response.json(events);
271
+ }
272
+
273
+ // GET /api/history/:userId — chat history for a user
274
+ const historyMatch = path.match(/^\/api\/history\/([^/]+)$/);
275
+ if (historyMatch && req.method === "GET") {
276
+ if (!self.sessions) {
277
+ return Response.json({ error: "Sessions not available" }, { status: 503 });
278
+ }
279
+ const userId = decodeURIComponent(historyMatch[1]);
280
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "50") || 50, 200);
281
+ const history = self.sessions.getHistory(userId, limit);
282
+ return Response.json(history);
283
+ }
284
+
285
+ // Static files from public/
286
+ const MIME: Record<string, string> = { ".png": "image/png", ".ico": "image/x-icon", ".svg": "image/svg+xml", ".jpg": "image/jpeg", ".css": "text/css", ".js": "application/javascript" };
287
+ const ext = extname(path);
288
+ if (ext && MIME[ext]) {
289
+ const filePath = join(self.publicDir, path);
290
+ if (existsSync(filePath)) {
291
+ const data = readFileSync(filePath);
292
+ return new Response(data, { headers: { "Content-Type": MIME[ext], "Cache-Control": "public, max-age=3600" } });
293
+ }
294
+ }
295
+
125
296
  return Response.json({ error: "Not found" }, { status: 404 });
126
297
  },
127
298
  });
128
299
 
300
+ this.startedAt = new Date().toISOString();
129
301
  logger.info("http api adapter started", { port: this.port });
130
302
  }
131
303
 
@@ -135,15 +307,15 @@ export class HttpApiAdapter implements EventAdapter {
135
307
  }
136
308
 
137
309
  async respondToEvent(eventId: string, text: string): Promise<void> {
138
- const pending = this.events.get(eventId);
139
- if (!pending) return;
310
+ const chat = this.chats.get(eventId);
311
+ if (!chat) return;
140
312
 
141
- pending.status = "completed";
142
- pending.result = text;
313
+ chat.status = "completed";
314
+ chat.replies.push(text);
143
315
 
144
316
  // Notify SSE listeners
145
- const data = JSON.stringify({ status: "completed", result: text });
146
- for (const controller of pending.sseControllers) {
317
+ const data = JSON.stringify({ status: "completed", result: chat.replies.join("\n\n") });
318
+ for (const controller of chat.sseControllers) {
147
319
  try {
148
320
  controller.enqueue(`data: ${data}\n\n`);
149
321
  controller.close();
@@ -151,20 +323,20 @@ export class HttpApiAdapter implements EventAdapter {
151
323
  logger.debug("sse enqueue failed", { eventId, error: String(err) });
152
324
  }
153
325
  }
154
- pending.sseControllers = [];
326
+ chat.sseControllers = [];
155
327
 
156
- // Clean up event after 5 minutes
157
- setTimeout(() => this.events.delete(eventId), 5 * 60 * 1000);
328
+ // Clean up after 5 minutes
329
+ setTimeout(() => this.chats.delete(eventId), 5 * 60 * 1000);
158
330
  }
159
331
 
160
332
  /** Called by index.ts to push status updates to SSE clients */
161
333
  updateEventStatus(eventId: string, statusText: string): void {
162
- const pending = this.events.get(eventId);
163
- if (!pending) return;
164
- pending.statusText = statusText;
334
+ const chat = this.chats.get(eventId);
335
+ if (!chat) return;
336
+ chat.statusText = statusText;
165
337
 
166
338
  const data = JSON.stringify({ status: "pending", statusText });
167
- for (const controller of pending.sseControllers) {
339
+ for (const controller of chat.sseControllers) {
168
340
  try {
169
341
  controller.enqueue(`data: ${data}\n\n`);
170
342
  } catch (err) {