@lovenyberg/ove 0.2.2 → 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
+ });
@@ -0,0 +1,42 @@
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> {
7
+ let timer: ReturnType<typeof setTimeout> | null = null;
8
+ let lastArgs: any[] | null = null;
9
+
10
+ const debounced = ((...args: any[]) => {
11
+ lastArgs = args;
12
+ if (timer) clearTimeout(timer);
13
+ timer = setTimeout(() => {
14
+ timer = null;
15
+ const pending = lastArgs;
16
+ lastArgs = null;
17
+ if (pending) fn(...pending);
18
+ }, ms);
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;
42
+ }
@@ -1,22 +1,14 @@
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
-
5
- function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
6
- let timer: ReturnType<typeof setTimeout> | null = null;
7
- return ((...args: any[]) => {
8
- if (timer) clearTimeout(timer);
9
- timer = setTimeout(() => {
10
- timer = null;
11
- fn(...args);
12
- }, ms);
13
- }) as any as T;
14
- }
4
+ import { debounce } from "./debounce";
15
5
 
16
6
  export class DiscordAdapter implements ChatAdapter {
17
7
  private client: Client;
18
8
  private token: string;
19
9
  private onMessage?: (msg: IncomingMessage) => void;
10
+ private started = false;
11
+ private startedAt?: string;
20
12
 
21
13
  constructor(token: string) {
22
14
  if (!token) throw new Error("Discord bot token is required");
@@ -58,7 +50,8 @@ export class DiscordAdapter implements ChatAdapter {
58
50
  } else {
59
51
  statusMsg = await discordMsg.channel.send(statusText);
60
52
  }
61
- } catch {
53
+ } catch (err) {
54
+ logger.warn("discord status update failed", { error: String(err) });
62
55
  statusMsg = await discordMsg.channel.send(statusText);
63
56
  }
64
57
  }, 3000);
@@ -78,10 +71,22 @@ export class DiscordAdapter implements ChatAdapter {
78
71
  });
79
72
 
80
73
  await this.client.login(this.token);
74
+ this.started = true;
75
+ this.startedAt = new Date().toISOString();
81
76
  logger.info("discord adapter started");
82
77
  }
83
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
+
84
88
  async stop(): Promise<void> {
89
+ this.started = false;
85
90
  this.client.destroy();
86
91
  logger.info("discord adapter stopped");
87
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> {
@@ -115,6 +140,18 @@ export class GitHubAdapter implements EventAdapter {
115
140
  logger.info("github mention detected", { repo, user: comment.user.login, number });
116
141
  this.onEvent?.(event);
117
142
  }
143
+
144
+ this.pruneSeenIds();
145
+ }
146
+
147
+ private pruneSeenIds() {
148
+ if (this.seenCommentIds.size > 1000) {
149
+ const iter = this.seenCommentIds.values();
150
+ while (this.seenCommentIds.size > 800) {
151
+ const val = iter.next().value;
152
+ if (val !== undefined) this.seenCommentIds.delete(val);
153
+ }
154
+ }
118
155
  }
119
156
 
120
157
  private async fetchRecentComments(repo: string): Promise<GitHubComment[]> {
@@ -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,48 +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";
61
161
 
62
- self.events.set(eventId, { status: "pending", sseControllers: [] });
162
+ const chat: PendingChat = { status: "pending", replies: [], sseControllers: [] };
163
+ self.chats.set(chatId, chat);
164
+
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
- // Clean up on disconnect
237
+ const idx = chat.sseControllers.indexOf(sseController);
238
+ if (idx >= 0) chat.sseControllers.splice(idx, 1);
98
239
  },
99
240
  });
100
241
 
@@ -110,21 +251,53 @@ export class HttpApiAdapter implements EventAdapter {
110
251
  // GET /api/message/:id — poll status
111
252
  const getMatch = path.match(/^\/api\/message\/([^/]+)$/);
112
253
  if (getMatch && req.method === "GET") {
113
- const eventId = getMatch[1];
114
- const pending = self.events.get(eventId);
115
- if (!pending) {
254
+ const chatId = getMatch[1];
255
+ const chat = self.chats.get(chatId);
256
+ if (!chat) {
116
257
  return Response.json({ error: "Not found" }, { status: 404 });
117
258
  }
118
259
  return Response.json({
119
- status: pending.status,
120
- result: pending.result,
260
+ status: chat.status,
261
+ result: chat.replies.length > 0 ? chat.replies.join("\n\n") : undefined,
121
262
  });
122
263
  }
123
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
+
124
296
  return Response.json({ error: "Not found" }, { status: 404 });
125
297
  },
126
298
  });
127
299
 
300
+ this.startedAt = new Date().toISOString();
128
301
  logger.info("http api adapter started", { port: this.port });
129
302
  }
130
303
 
@@ -134,34 +307,41 @@ export class HttpApiAdapter implements EventAdapter {
134
307
  }
135
308
 
136
309
  async respondToEvent(eventId: string, text: string): Promise<void> {
137
- const pending = this.events.get(eventId);
138
- if (!pending) return;
310
+ const chat = this.chats.get(eventId);
311
+ if (!chat) return;
139
312
 
140
- pending.status = "completed";
141
- pending.result = text;
313
+ chat.status = "completed";
314
+ chat.replies.push(text);
142
315
 
143
316
  // Notify SSE listeners
144
- const data = JSON.stringify({ status: "completed", result: text });
145
- 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) {
146
319
  try {
147
320
  controller.enqueue(`data: ${data}\n\n`);
148
321
  controller.close();
149
- } catch {}
322
+ } catch (err) {
323
+ logger.debug("sse enqueue failed", { eventId, error: String(err) });
324
+ }
150
325
  }
151
- pending.sseControllers = [];
326
+ chat.sseControllers = [];
327
+
328
+ // Clean up after 5 minutes
329
+ setTimeout(() => this.chats.delete(eventId), 5 * 60 * 1000);
152
330
  }
153
331
 
154
332
  /** Called by index.ts to push status updates to SSE clients */
155
333
  updateEventStatus(eventId: string, statusText: string): void {
156
- const pending = this.events.get(eventId);
157
- if (!pending) return;
158
- pending.statusText = statusText;
334
+ const chat = this.chats.get(eventId);
335
+ if (!chat) return;
336
+ chat.statusText = statusText;
159
337
 
160
338
  const data = JSON.stringify({ status: "pending", statusText });
161
- for (const controller of pending.sseControllers) {
339
+ for (const controller of chat.sseControllers) {
162
340
  try {
163
341
  controller.enqueue(`data: ${data}\n\n`);
164
- } catch {}
342
+ } catch (err) {
343
+ logger.debug("sse status update failed", { eventId, error: String(err) });
344
+ }
165
345
  }
166
346
  }
167
347
  }