@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.
- package/.claude/skills/create-issue/SKILL.md +24 -0
- package/.claude/skills/review-pr/SKILL.md +37 -0
- package/.claude/skills/ship/SKILL.md +22 -0
- package/.env.example +5 -0
- package/CLAUDE.md +20 -0
- package/README.md +93 -17
- package/docs/examples.md +11 -52
- package/docs/index.html +40 -2
- package/docs/plans/2026-02-23-conversation-repo-memory.md +272 -0
- package/package.json +1 -1
- package/public/favicon.ico +0 -0
- package/public/index.html +424 -36
- package/public/logo.png +0 -0
- package/public/status.html +519 -0
- package/public/trace.html +973 -0
- package/src/adapters/cli.ts +16 -1
- package/src/adapters/debounce.test.ts +57 -0
- package/src/adapters/debounce.ts +36 -4
- package/src/adapters/discord.ts +17 -2
- package/src/adapters/github.ts +26 -1
- package/src/adapters/http.test.ts +7 -1
- package/src/adapters/http.ts +220 -48
- package/src/adapters/slack.ts +17 -2
- package/src/adapters/telegram.ts +21 -9
- package/src/adapters/types.ts +11 -0
- package/src/adapters/whatsapp.ts +40 -2
- package/src/flows.test.ts +126 -0
- package/src/handlers.ts +76 -17
- package/src/index.ts +35 -2
- package/src/queue.ts +22 -0
- package/src/router.ts +8 -2
- package/src/runners/claude.ts +24 -5
- package/src/runners/codex.ts +20 -1
- package/src/trace.ts +71 -0
- package/src/worker.ts +68 -6
package/src/adapters/cli.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/adapters/debounce.ts
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
15
|
+
const pending = lastArgs;
|
|
16
|
+
lastArgs = null;
|
|
17
|
+
if (pending) fn(...pending);
|
|
8
18
|
}, ms);
|
|
9
|
-
}) as
|
|
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
|
}
|
package/src/adapters/discord.ts
CHANGED
|
@@ -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
|
}
|
package/src/adapters/github.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/src/adapters/http.ts
CHANGED
|
@@ -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
|
|
9
|
+
interface PendingChat {
|
|
7
10
|
status: "pending" | "completed";
|
|
8
|
-
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
118
|
+
// GET /api/status — adapter 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
|
|
60
|
-
const userId = body.userId || "http:
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
const
|
|
81
|
-
if (!
|
|
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
|
-
|
|
226
|
+
sseController = controller;
|
|
227
|
+
chat.sseControllers.push(controller);
|
|
88
228
|
// Send current state immediately
|
|
89
229
|
const data = JSON.stringify({
|
|
90
|
-
status:
|
|
91
|
-
result:
|
|
92
|
-
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 =
|
|
98
|
-
if (idx >= 0)
|
|
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
|
|
115
|
-
const
|
|
116
|
-
if (!
|
|
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:
|
|
121
|
-
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
|
|
139
|
-
if (!
|
|
310
|
+
const chat = this.chats.get(eventId);
|
|
311
|
+
if (!chat) return;
|
|
140
312
|
|
|
141
|
-
|
|
142
|
-
|
|
313
|
+
chat.status = "completed";
|
|
314
|
+
chat.replies.push(text);
|
|
143
315
|
|
|
144
316
|
// Notify SSE listeners
|
|
145
|
-
const data = JSON.stringify({ status: "completed", result:
|
|
146
|
-
for (const controller of
|
|
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
|
-
|
|
326
|
+
chat.sseControllers = [];
|
|
155
327
|
|
|
156
|
-
// Clean up
|
|
157
|
-
setTimeout(() => this.
|
|
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
|
|
163
|
-
if (!
|
|
164
|
-
|
|
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
|
|
339
|
+
for (const controller of chat.sseControllers) {
|
|
168
340
|
try {
|
|
169
341
|
controller.enqueue(`data: ${data}\n\n`);
|
|
170
342
|
} catch (err) {
|