@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/slack.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// src/adapters/slack.ts
|
|
2
2
|
import { App } from "@slack/bolt";
|
|
3
|
-
import type { ChatAdapter, IncomingMessage } from "./types";
|
|
3
|
+
import type { ChatAdapter, IncomingMessage, AdapterStatus } from "./types";
|
|
4
4
|
import { logger } from "../logger";
|
|
5
5
|
import { debounce } from "./debounce";
|
|
6
6
|
|
|
7
7
|
export class SlackAdapter implements ChatAdapter {
|
|
8
8
|
private app: App;
|
|
9
9
|
private onMessage?: (msg: IncomingMessage) => void;
|
|
10
|
+
private started = false;
|
|
11
|
+
private startedAt?: string;
|
|
10
12
|
|
|
11
13
|
constructor() {
|
|
12
14
|
this.app = new App({
|
|
@@ -32,7 +34,8 @@ export class SlackAdapter implements ChatAdapter {
|
|
|
32
34
|
ts: statusMsgTs,
|
|
33
35
|
text: statusText,
|
|
34
36
|
});
|
|
35
|
-
} catch {
|
|
37
|
+
} catch (err) {
|
|
38
|
+
logger.warn("slack status update failed", { error: String(err) });
|
|
36
39
|
const result = await say(statusText);
|
|
37
40
|
if (result && "ts" in result) statusMsgTs = result.ts;
|
|
38
41
|
}
|
|
@@ -86,10 +89,22 @@ export class SlackAdapter implements ChatAdapter {
|
|
|
86
89
|
});
|
|
87
90
|
|
|
88
91
|
await this.app.start();
|
|
92
|
+
this.started = true;
|
|
93
|
+
this.startedAt = new Date().toISOString();
|
|
89
94
|
logger.info("slack adapter started");
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
getStatus(): AdapterStatus {
|
|
98
|
+
return {
|
|
99
|
+
name: "slack",
|
|
100
|
+
type: "chat",
|
|
101
|
+
status: this.started ? "connected" : "disconnected",
|
|
102
|
+
startedAt: this.startedAt,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
async stop(): Promise<void> {
|
|
107
|
+
this.started = false;
|
|
93
108
|
await this.app.stop();
|
|
94
109
|
logger.info("slack adapter stopped");
|
|
95
110
|
}
|
package/src/adapters/telegram.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Bot } from "grammy";
|
|
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
|
|
|
@@ -24,6 +24,8 @@ function mdToHtml(text: string): string {
|
|
|
24
24
|
export class TelegramAdapter implements ChatAdapter {
|
|
25
25
|
private bot: Bot;
|
|
26
26
|
private onMessage?: (msg: IncomingMessage) => void;
|
|
27
|
+
private started = false;
|
|
28
|
+
private startedAt?: string;
|
|
27
29
|
|
|
28
30
|
constructor(token: string) {
|
|
29
31
|
if (!token) throw new Error("Telegram bot token is required");
|
|
@@ -51,8 +53,8 @@ export class TelegramAdapter implements ChatAdapter {
|
|
|
51
53
|
const sent = await ctx.reply(html, { parse_mode: "HTML" });
|
|
52
54
|
statusMsgId = sent.message_id;
|
|
53
55
|
}
|
|
54
|
-
} catch {
|
|
55
|
-
|
|
56
|
+
} catch (err) {
|
|
57
|
+
logger.warn("telegram status update failed", { error: String(err) });
|
|
56
58
|
}
|
|
57
59
|
}, 3000);
|
|
58
60
|
|
|
@@ -61,14 +63,12 @@ export class TelegramAdapter implements ChatAdapter {
|
|
|
61
63
|
platform: "telegram",
|
|
62
64
|
text,
|
|
63
65
|
reply: async (replyText: string) => {
|
|
64
|
-
//
|
|
66
|
+
// Delete status message and send a new one so the user gets a notification
|
|
65
67
|
if (statusMsgId) {
|
|
66
68
|
try {
|
|
67
|
-
await ctx.api.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
} catch {
|
|
71
|
-
// Edit failed (message too old, etc.) — fall through to send new
|
|
69
|
+
await ctx.api.deleteMessage(chatId, statusMsgId);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.debug("telegram status delete failed", { error: String(err) });
|
|
72
72
|
}
|
|
73
73
|
statusMsgId = undefined;
|
|
74
74
|
}
|
|
@@ -82,10 +82,22 @@ export class TelegramAdapter implements ChatAdapter {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
this.bot.start();
|
|
85
|
+
this.started = true;
|
|
86
|
+
this.startedAt = new Date().toISOString();
|
|
85
87
|
logger.info("telegram adapter started");
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
getStatus(): AdapterStatus {
|
|
91
|
+
return {
|
|
92
|
+
name: "telegram",
|
|
93
|
+
type: "chat",
|
|
94
|
+
status: this.started ? "connected" : "disconnected",
|
|
95
|
+
startedAt: this.startedAt,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
async stop(): Promise<void> {
|
|
100
|
+
this.started = false;
|
|
89
101
|
this.bot.stop();
|
|
90
102
|
logger.info("telegram adapter stopped");
|
|
91
103
|
}
|
package/src/adapters/types.ts
CHANGED
|
@@ -6,10 +6,20 @@ export interface IncomingMessage {
|
|
|
6
6
|
updateStatus: (text: string) => Promise<void>;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export interface AdapterStatus {
|
|
10
|
+
name: string;
|
|
11
|
+
type: "chat" | "event";
|
|
12
|
+
status: "connected" | "disconnected" | "degraded" | "unknown";
|
|
13
|
+
error?: string;
|
|
14
|
+
details?: Record<string, unknown>;
|
|
15
|
+
startedAt?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
export interface ChatAdapter {
|
|
10
19
|
start(onMessage: (msg: IncomingMessage) => void): Promise<void>;
|
|
11
20
|
stop(): Promise<void>;
|
|
12
21
|
sendToUser?(userId: string, text: string): Promise<void>;
|
|
22
|
+
getStatus?(): AdapterStatus;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
export type EventSource =
|
|
@@ -29,4 +39,5 @@ export interface EventAdapter {
|
|
|
29
39
|
start(onEvent: (event: IncomingEvent) => void): Promise<void>;
|
|
30
40
|
stop(): Promise<void>;
|
|
31
41
|
respondToEvent(eventId: string, text: string): Promise<void>;
|
|
42
|
+
getStatus?(): AdapterStatus;
|
|
32
43
|
}
|
package/src/adapters/whatsapp.ts
CHANGED
|
@@ -4,7 +4,7 @@ import makeWASocket, {
|
|
|
4
4
|
DisconnectReason,
|
|
5
5
|
type WASocket,
|
|
6
6
|
} from "baileys";
|
|
7
|
-
import type { ChatAdapter, IncomingMessage } from "./types";
|
|
7
|
+
import type { ChatAdapter, IncomingMessage, AdapterStatus } from "./types";
|
|
8
8
|
import { logger } from "../logger";
|
|
9
9
|
|
|
10
10
|
export class WhatsAppAdapter implements ChatAdapter {
|
|
@@ -12,12 +12,18 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
12
12
|
private onMessage?: (msg: IncomingMessage) => void;
|
|
13
13
|
private authDir: string;
|
|
14
14
|
private phoneNumber?: string;
|
|
15
|
+
private allowedChats: Set<string>;
|
|
15
16
|
private reconnectAttempt = 0;
|
|
16
17
|
private sentByBot = new Set<string>();
|
|
18
|
+
private connectionState: "open" | "close" | "connecting" = "connecting";
|
|
19
|
+
private lastError?: string;
|
|
20
|
+
private startedAt?: string;
|
|
21
|
+
private pairingCode?: string;
|
|
17
22
|
|
|
18
|
-
constructor(opts: { authDir?: string; phoneNumber?: string } = {}) {
|
|
23
|
+
constructor(opts: { authDir?: string; phoneNumber?: string; allowedChats?: string[] } = {}) {
|
|
19
24
|
this.authDir = opts.authDir ?? "./auth/whatsapp";
|
|
20
25
|
this.phoneNumber = opts.phoneNumber;
|
|
26
|
+
this.allowedChats = new Set(opts.allowedChats ?? []);
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
async start(onMessage: (msg: IncomingMessage) => void): Promise<void> {
|
|
@@ -33,13 +39,17 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
33
39
|
|
|
34
40
|
let pairingRequested = false;
|
|
35
41
|
|
|
42
|
+
this.startedAt = this.startedAt || new Date().toISOString();
|
|
43
|
+
|
|
36
44
|
this.sock.ev.on("connection.update", async ({ connection, lastDisconnect, qr }) => {
|
|
45
|
+
if (connection) this.connectionState = connection as "open" | "close" | "connecting";
|
|
37
46
|
// Request pairing code when server is ready (sends qr event)
|
|
38
47
|
if (qr && this.phoneNumber && !pairingRequested) {
|
|
39
48
|
pairingRequested = true;
|
|
40
49
|
try {
|
|
41
50
|
const phone = this.phoneNumber.replace(/[^0-9]/g, "");
|
|
42
51
|
const code = await this.sock!.requestPairingCode(phone);
|
|
52
|
+
this.pairingCode = code;
|
|
43
53
|
logger.info(`whatsapp pairing code: ${code}`, { phone });
|
|
44
54
|
console.log(`\n WhatsApp pairing code: ${code}`);
|
|
45
55
|
console.log(` Enter this code on your phone: WhatsApp → Linked Devices → Link a Device\n`);
|
|
@@ -50,16 +60,20 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
50
60
|
|
|
51
61
|
if (connection === "close") {
|
|
52
62
|
const statusCode = (lastDisconnect?.error as any)?.output?.statusCode;
|
|
63
|
+
this.lastError = `disconnected (status ${statusCode})`;
|
|
53
64
|
if (statusCode !== DisconnectReason.loggedOut) {
|
|
54
65
|
this.reconnectAttempt++;
|
|
55
66
|
const delay = Math.min(2000 * this.reconnectAttempt, 30_000);
|
|
56
67
|
logger.warn("whatsapp disconnected, reconnecting...", { statusCode, delay });
|
|
57
68
|
setTimeout(() => this.start(onMessage), delay);
|
|
58
69
|
} else {
|
|
70
|
+
this.lastError = "logged out";
|
|
59
71
|
logger.error("whatsapp logged out");
|
|
60
72
|
}
|
|
61
73
|
} else if (connection === "open") {
|
|
62
74
|
this.reconnectAttempt = 0;
|
|
75
|
+
this.lastError = undefined;
|
|
76
|
+
this.pairingCode = undefined;
|
|
63
77
|
logger.info("whatsapp adapter connected");
|
|
64
78
|
}
|
|
65
79
|
});
|
|
@@ -78,6 +92,14 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
78
92
|
// Skip messages from others (not our phone) — we only process our own commands
|
|
79
93
|
if (!waMsg.key.fromMe) continue;
|
|
80
94
|
|
|
95
|
+
// Only process messages in whitelisted chats (if configured)
|
|
96
|
+
if (this.allowedChats.size > 0) {
|
|
97
|
+
const jid = waMsg.key.remoteJid;
|
|
98
|
+
if (!jid) continue;
|
|
99
|
+
const chatId = jid.split("@")[0];
|
|
100
|
+
if (!this.allowedChats.has(jid) && !this.allowedChats.has(chatId)) continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
81
103
|
const text =
|
|
82
104
|
waMsg.message.conversation ||
|
|
83
105
|
waMsg.message.extendedTextMessage?.text;
|
|
@@ -141,6 +163,22 @@ export class WhatsAppAdapter implements ChatAdapter {
|
|
|
141
163
|
});
|
|
142
164
|
}
|
|
143
165
|
|
|
166
|
+
getStatus(): AdapterStatus {
|
|
167
|
+
let status: AdapterStatus["status"] = "unknown";
|
|
168
|
+
if (this.connectionState === "open") status = "connected";
|
|
169
|
+
else if (this.connectionState === "close") status = "disconnected";
|
|
170
|
+
else if (this.connectionState === "connecting") status = this.reconnectAttempt > 0 ? "degraded" : "unknown";
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
name: "whatsapp",
|
|
174
|
+
type: "chat",
|
|
175
|
+
status,
|
|
176
|
+
error: this.lastError,
|
|
177
|
+
details: { reconnectAttempt: this.reconnectAttempt, pairingCode: this.pairingCode },
|
|
178
|
+
startedAt: this.startedAt,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
144
182
|
async stop(): Promise<void> {
|
|
145
183
|
this.sock?.end(undefined);
|
|
146
184
|
this.sock = null;
|
package/src/flows.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "bun:test";
|
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import { parseMessage, buildPrompt, buildContextualPrompt, type ParsedMessage } from "./router";
|
|
4
4
|
import { TaskQueue } from "./queue";
|
|
5
|
+
import { SessionStore } from "./sessions";
|
|
5
6
|
|
|
6
7
|
describe("Conversational flow routing", () => {
|
|
7
8
|
describe("discuss flows", () => {
|
|
@@ -238,6 +239,26 @@ describe("buildContextualPrompt", () => {
|
|
|
238
239
|
});
|
|
239
240
|
});
|
|
240
241
|
|
|
242
|
+
describe("Conversation-aware repo resolution", () => {
|
|
243
|
+
it("derives lastRepo from recent task history", () => {
|
|
244
|
+
const db = new Database(":memory:");
|
|
245
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
246
|
+
const queue = new TaskQueue(db);
|
|
247
|
+
|
|
248
|
+
const taskId = queue.enqueue({
|
|
249
|
+
userId: "telegram:U1",
|
|
250
|
+
repo: "iris",
|
|
251
|
+
prompt: "check the roadmap",
|
|
252
|
+
});
|
|
253
|
+
queue.dequeue();
|
|
254
|
+
queue.complete(taskId, "Here's the roadmap...");
|
|
255
|
+
|
|
256
|
+
const recent = queue.listByUser("telegram:U1", 1);
|
|
257
|
+
expect(recent.length).toBe(1);
|
|
258
|
+
expect(recent[0].repo).toBe("iris");
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
241
262
|
describe("Queue round-trip with taskType", () => {
|
|
242
263
|
let queue: TaskQueue;
|
|
243
264
|
|
|
@@ -310,3 +331,108 @@ describe("Queue round-trip with taskType", () => {
|
|
|
310
331
|
expect(task!.taskType).toBeNull();
|
|
311
332
|
});
|
|
312
333
|
});
|
|
334
|
+
|
|
335
|
+
describe("Full follow-up conversation flow", () => {
|
|
336
|
+
it("follow-up message without repo uses last task's repo", () => {
|
|
337
|
+
const db = new Database(":memory:");
|
|
338
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
339
|
+
const queue = new TaskQueue(db);
|
|
340
|
+
const sessions = new SessionStore(db);
|
|
341
|
+
|
|
342
|
+
// Simulate conversation: user talked about iris
|
|
343
|
+
sessions.addMessage("telegram:U1", "user", "check the roadmap on iris");
|
|
344
|
+
sessions.addMessage("telegram:U1", "assistant", "Here's the iris roadmap...");
|
|
345
|
+
sessions.addMessage("telegram:U1", "user", "what about tomorrow's plan");
|
|
346
|
+
|
|
347
|
+
// Simulate a completed task on iris
|
|
348
|
+
const taskId = queue.enqueue({
|
|
349
|
+
userId: "telegram:U1",
|
|
350
|
+
repo: "iris",
|
|
351
|
+
prompt: "check the roadmap",
|
|
352
|
+
});
|
|
353
|
+
queue.dequeue();
|
|
354
|
+
queue.complete(taskId, "Here's the roadmap...");
|
|
355
|
+
|
|
356
|
+
// Now a follow-up: "what about tomorrow" — no repo mentioned
|
|
357
|
+
const parsed = parseMessage("what about tomorrow's plan");
|
|
358
|
+
expect(parsed.type).toBe("free-form");
|
|
359
|
+
expect(parsed.repo).toBeUndefined(); // Router can't find repo in text
|
|
360
|
+
|
|
361
|
+
// But the last task was on iris
|
|
362
|
+
const recentTasks = queue.listByUser("telegram:U1", 5);
|
|
363
|
+
const lastRepo = recentTasks.find(t => t.status === "completed" || t.status === "failed")?.repo;
|
|
364
|
+
expect(lastRepo).toBe("iris");
|
|
365
|
+
|
|
366
|
+
// And the conversation history mentions iris
|
|
367
|
+
const history = sessions.getHistory("telegram:U1", 6);
|
|
368
|
+
expect(history.some(m => m.content.includes("iris"))).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("explicit repo in message overrides last task repo", () => {
|
|
372
|
+
const db = new Database(":memory:");
|
|
373
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
374
|
+
const queue = new TaskQueue(db);
|
|
375
|
+
|
|
376
|
+
// Last task was on iris
|
|
377
|
+
const taskId = queue.enqueue({
|
|
378
|
+
userId: "telegram:U1",
|
|
379
|
+
repo: "iris",
|
|
380
|
+
prompt: "check roadmap",
|
|
381
|
+
});
|
|
382
|
+
queue.dequeue();
|
|
383
|
+
queue.complete(taskId, "done");
|
|
384
|
+
|
|
385
|
+
// But new message explicitly says "on docs"
|
|
386
|
+
const parsed = parseMessage("check the tests on docs");
|
|
387
|
+
expect(parsed.repo).toBe("docs"); // Regex hint takes priority
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("lastRepo only considers completed/failed tasks, not pending", () => {
|
|
391
|
+
const db = new Database(":memory:");
|
|
392
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
393
|
+
const queue = new TaskQueue(db);
|
|
394
|
+
|
|
395
|
+
// Completed task on iris
|
|
396
|
+
const task1 = queue.enqueue({
|
|
397
|
+
userId: "telegram:U1",
|
|
398
|
+
repo: "iris",
|
|
399
|
+
prompt: "check roadmap",
|
|
400
|
+
});
|
|
401
|
+
queue.dequeue();
|
|
402
|
+
queue.complete(task1, "done");
|
|
403
|
+
|
|
404
|
+
// Pending task on docs (enqueued but not completed)
|
|
405
|
+
queue.enqueue({
|
|
406
|
+
userId: "telegram:U1",
|
|
407
|
+
repo: "docs",
|
|
408
|
+
prompt: "pending work",
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// lastRepo should be iris (completed), not docs (pending)
|
|
412
|
+
const recentTasks = queue.listByUser("telegram:U1", 5);
|
|
413
|
+
const lastRepo = recentTasks.find(t => t.status === "completed" || t.status === "failed")?.repo;
|
|
414
|
+
expect(lastRepo).toBe("iris");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("LLM resolver prompt includes conversation history", () => {
|
|
418
|
+
const db = new Database(":memory:");
|
|
419
|
+
const sessions = new SessionStore(db);
|
|
420
|
+
|
|
421
|
+
sessions.addMessage("telegram:U1", "user", "check the roadmap on iris");
|
|
422
|
+
sessions.addMessage("telegram:U1", "assistant", "Here's the iris roadmap...");
|
|
423
|
+
sessions.addMessage("telegram:U1", "user", "what about tomorrow");
|
|
424
|
+
|
|
425
|
+
const history = sessions.getHistory("telegram:U1", 6);
|
|
426
|
+
const historyContext = history.length > 1
|
|
427
|
+
? "Recent conversation:\n" + history.slice(0, -1).map(m => `${m.role}: ${m.content}`).join("\n") + "\n\n"
|
|
428
|
+
: "";
|
|
429
|
+
const resolvePrompt = `You are a repo-name resolver. ${historyContext}The user's latest message:\n"what about tomorrow"\n\nAvailable repos: iris, docs, my-app\n\nRespond with ONLY the repo name that best matches their request. Consider the conversation context if the current message doesn't mention a specific repo. Nothing else — just the exact repo name from the list. If you cannot determine which repo, respond with "UNKNOWN".`;
|
|
430
|
+
|
|
431
|
+
expect(resolvePrompt).toContain("Recent conversation:");
|
|
432
|
+
expect(resolvePrompt).toContain("check the roadmap on iris");
|
|
433
|
+
expect(resolvePrompt).toContain("Here's the iris roadmap");
|
|
434
|
+
expect(resolvePrompt).toContain("what about tomorrow");
|
|
435
|
+
expect(resolvePrompt).toContain("iris, docs, my-app");
|
|
436
|
+
expect(resolvePrompt).toContain("Consider the conversation context");
|
|
437
|
+
});
|
|
438
|
+
});
|
package/src/handlers.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { ScheduleStore } from "./schedules";
|
|
|
10
10
|
import type { RepoRegistry } from "./repo-registry";
|
|
11
11
|
import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/types";
|
|
12
12
|
import type { AgentRunner } from "./runner";
|
|
13
|
+
import type { TraceStore } from "./trace";
|
|
13
14
|
|
|
14
15
|
export interface HandlerDeps {
|
|
15
16
|
config: Config;
|
|
@@ -17,6 +18,7 @@ export interface HandlerDeps {
|
|
|
17
18
|
sessions: SessionStore;
|
|
18
19
|
schedules: ScheduleStore;
|
|
19
20
|
repoRegistry: RepoRegistry;
|
|
21
|
+
trace: TraceStore;
|
|
20
22
|
pendingReplies: Map<string, IncomingMessage>;
|
|
21
23
|
pendingEventReplies: Map<string, { adapter: EventAdapter; event: IncomingEvent }>;
|
|
22
24
|
runningProcesses: Map<string, { abort: AbortController; task: Task }>;
|
|
@@ -147,6 +149,7 @@ async function handleHelp(msg: IncomingMessage, deps: HandlerDeps) {
|
|
|
147
149
|
"• init repo <name> <git-url> [branch] — set up a repo from chat",
|
|
148
150
|
"• tasks — see running and pending tasks",
|
|
149
151
|
"• cancel <id> — kill a running or pending task",
|
|
152
|
+
"• trace [task-id] — see what happened step by step",
|
|
150
153
|
"• status / history / clear",
|
|
151
154
|
"• <task> every day/weekday at <time> [on <repo>] — schedule a recurring task",
|
|
152
155
|
"• list schedules — see your scheduled tasks",
|
|
@@ -240,6 +243,44 @@ async function handleRemoveSchedule(msg: IncomingMessage, args: Record<string, a
|
|
|
240
243
|
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
241
244
|
}
|
|
242
245
|
|
|
246
|
+
async function handleTrace(msg: IncomingMessage, args: Record<string, any>, deps: HandlerDeps) {
|
|
247
|
+
let taskId = args.taskId as string | undefined;
|
|
248
|
+
|
|
249
|
+
if (!taskId) {
|
|
250
|
+
const recent = deps.queue.listByUser(msg.userId, 1);
|
|
251
|
+
if (recent.length === 0) {
|
|
252
|
+
await msg.reply("No tasks found. Nothing to trace.");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
taskId = recent[0].id;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Support prefix matching like cancel does
|
|
259
|
+
const task = deps.queue.get(taskId);
|
|
260
|
+
if (!task) {
|
|
261
|
+
await msg.reply(`No task found matching "${taskId}".`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const events = deps.trace.getByTask(task.id);
|
|
266
|
+
if (events.length === 0) {
|
|
267
|
+
const reason = deps.trace.isEnabled()
|
|
268
|
+
? "No trace events recorded for this task."
|
|
269
|
+
: "Tracing is disabled. Set OVE_TRACE=true to enable.";
|
|
270
|
+
await msg.reply(reason);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const lines = events.map((e) => {
|
|
275
|
+
const time = e.ts.slice(11, 19); // HH:MM:SS
|
|
276
|
+
const detail = e.detail ? ` — ${e.detail.slice(0, 120)}` : "";
|
|
277
|
+
return `${time} [${e.kind}] ${e.summary}${detail}`;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const reply = `Trace for ${task.id.slice(0, 7)} (${task.repo}):\n${lines.join("\n")}`;
|
|
281
|
+
await msg.reply(reply);
|
|
282
|
+
}
|
|
283
|
+
|
|
243
284
|
async function handleSchedule(msg: IncomingMessage, parsedRepo: string | undefined, deps: HandlerDeps) {
|
|
244
285
|
await msg.updateStatus("Parsing your schedule...");
|
|
245
286
|
const rawRepos = getUserRepos(deps.config, msg.userId);
|
|
@@ -374,24 +415,41 @@ async function handleTaskMessage(msg: IncomingMessage, parsed: ParsedMessage, de
|
|
|
374
415
|
await msg.reply(reply);
|
|
375
416
|
return;
|
|
376
417
|
} else {
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
await msg.
|
|
418
|
+
// Try last completed task's repo first (cheap)
|
|
419
|
+
const recentTasks = deps.queue.listByUser(msg.userId, 5);
|
|
420
|
+
const lastRepo = recentTasks.find(t => t.status === "completed" || t.status === "failed")?.repo;
|
|
421
|
+
if (lastRepo && repoNames.includes(lastRepo)) {
|
|
422
|
+
parsed.repo = lastRepo;
|
|
423
|
+
logger.info("repo resolved from recent task", { resolved: lastRepo, userText: parsed.rawText.slice(0, 80) });
|
|
424
|
+
} else {
|
|
425
|
+
// Resolve repo via a quick LLM call, then enqueue through the normal path
|
|
426
|
+
const repoList = repoNames.join(", ");
|
|
427
|
+
const history = deps.sessions.getHistory(msg.userId, 6);
|
|
428
|
+
const historyContext = history.length > 1
|
|
429
|
+
? "Recent conversation:\n" + history.slice(0, -1).map(m => `${m.role}: ${m.content}`).join("\n") + "\n\n"
|
|
430
|
+
: "";
|
|
431
|
+
const resolvePrompt = `You are a repo-name resolver. ${historyContext}The user's latest message:\n"${parsed.rawText}"\n\nAvailable repos: ${repoList}\n\nRespond with ONLY the repo name that best matches their request. Consider the conversation context if the current message doesn't mention a specific repo. Nothing else — just the exact repo name from the list. If you cannot determine which repo, respond with "UNKNOWN".`;
|
|
432
|
+
|
|
433
|
+
await msg.updateStatus("Figuring out which repo...");
|
|
434
|
+
try {
|
|
435
|
+
const runner = deps.getRunner(deps.config.runner?.name);
|
|
436
|
+
const result = await runner.run(resolvePrompt, deps.config.reposDir, { maxTurns: 1 });
|
|
437
|
+
const resolved = result.output.trim().replace(/[`"']/g, "");
|
|
438
|
+
|
|
439
|
+
if (resolved === "UNKNOWN" || !repoNames.includes(resolved)) {
|
|
440
|
+
const reply = `Which repo? I see ${repoNames.length} repos. Some matches: ${repoNames.slice(0, 10).join(", ")}${repoNames.length > 10 ? "..." : ""}.\nSay it again with 'on <repo>'.`;
|
|
441
|
+
await msg.reply(reply);
|
|
442
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
parsed.repo = resolved;
|
|
447
|
+
logger.info("repo resolved via LLM", { resolved, userText: parsed.rawText.slice(0, 80) });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
await msg.reply(`Couldn't figure out the repo: ${String(err).slice(0, 300)}`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
393
452
|
}
|
|
394
|
-
return;
|
|
395
453
|
}
|
|
396
454
|
} else {
|
|
397
455
|
const reply = "You don't have access to any repos yet. Set one up:\n`init repo <name> <git-url> [branch]`\nExample: `init repo my-app git@github.com:user/my-app.git`";
|
|
@@ -442,6 +500,7 @@ export function createMessageHandler(deps: HandlerDeps): (msg: IncomingMessage)
|
|
|
442
500
|
"help": () => handleHelp(msg, deps),
|
|
443
501
|
"list-tasks": () => handleListTasks(msg, deps),
|
|
444
502
|
"cancel-task": () => handleCancelTask(msg, parsed.args, deps),
|
|
503
|
+
"trace": () => handleTrace(msg, parsed.args, deps),
|
|
445
504
|
"list-schedules": () => handleListSchedules(msg, deps),
|
|
446
505
|
"remove-schedule": () => handleRemoveSchedule(msg, parsed.args, deps),
|
|
447
506
|
"schedule": () => handleSchedule(msg, parsed.repo, deps),
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { AgentRunner, RunOptions } from "./runner";
|
|
|
18
18
|
import { logger } from "./logger";
|
|
19
19
|
import { RepoRegistry, syncGitHub } from "./repo-registry";
|
|
20
20
|
import { SessionStore } from "./sessions";
|
|
21
|
+
import { TraceStore } from "./trace";
|
|
21
22
|
import { startCronLoop } from "./cron";
|
|
22
23
|
import { ScheduleStore } from "./schedules";
|
|
23
24
|
import { createMessageHandler, createEventHandler } from "./handlers";
|
|
@@ -30,6 +31,7 @@ db.run("PRAGMA journal_mode = WAL");
|
|
|
30
31
|
const queue = new TaskQueue(db);
|
|
31
32
|
const repos = new RepoManager(config.reposDir);
|
|
32
33
|
const sessions = new SessionStore(db);
|
|
34
|
+
const trace = new TraceStore(db);
|
|
33
35
|
const schedules = new ScheduleStore(db);
|
|
34
36
|
const repoRegistry = new RepoRegistry(db);
|
|
35
37
|
|
|
@@ -113,8 +115,11 @@ if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
if (process.env.WHATSAPP_ENABLED === "true") {
|
|
118
|
+
const allowedChats = process.env.WHATSAPP_ALLOWED_CHATS
|
|
119
|
+
?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
116
120
|
adapters.push(new WhatsAppAdapter({
|
|
117
121
|
phoneNumber: process.env.WHATSAPP_PHONE,
|
|
122
|
+
allowedChats,
|
|
118
123
|
}));
|
|
119
124
|
}
|
|
120
125
|
|
|
@@ -132,7 +137,10 @@ const eventAdapters: EventAdapter[] = [];
|
|
|
132
137
|
if (process.env.HTTP_API_PORT) {
|
|
133
138
|
const httpAdapter = new HttpApiAdapter(
|
|
134
139
|
parseInt(process.env.HTTP_API_PORT),
|
|
135
|
-
process.env.HTTP_API_KEY || crypto.randomUUID()
|
|
140
|
+
process.env.HTTP_API_KEY || crypto.randomUUID(),
|
|
141
|
+
trace,
|
|
142
|
+
queue,
|
|
143
|
+
sessions
|
|
136
144
|
);
|
|
137
145
|
eventAdapters.push(httpAdapter);
|
|
138
146
|
}
|
|
@@ -152,12 +160,14 @@ if (process.env.CLI_MODE === "true" || (adapters.length === 0 && eventAdapters.l
|
|
|
152
160
|
|
|
153
161
|
// Main
|
|
154
162
|
async function main() {
|
|
163
|
+
// Capture stale tasks before resetting so we can notify users
|
|
164
|
+
const staleTasks = queue.listActive().filter((t) => t.status === "running");
|
|
155
165
|
const staleCount = queue.resetStale();
|
|
156
166
|
if (staleCount > 0) {
|
|
157
167
|
logger.info("reset stale tasks", { count: staleCount });
|
|
158
168
|
}
|
|
159
169
|
|
|
160
|
-
logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude" });
|
|
170
|
+
logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude", tracing: trace.isEnabled() });
|
|
161
171
|
|
|
162
172
|
startGitHubSync().catch((err) =>
|
|
163
173
|
logger.warn("initial github sync failed", { error: String(err) })
|
|
@@ -169,6 +179,7 @@ async function main() {
|
|
|
169
179
|
sessions,
|
|
170
180
|
schedules,
|
|
171
181
|
repoRegistry,
|
|
182
|
+
trace,
|
|
172
183
|
pendingReplies,
|
|
173
184
|
pendingEventReplies,
|
|
174
185
|
runningProcesses,
|
|
@@ -183,6 +194,7 @@ async function main() {
|
|
|
183
194
|
sessions,
|
|
184
195
|
schedules,
|
|
185
196
|
repoRegistry,
|
|
197
|
+
trace,
|
|
186
198
|
pendingReplies,
|
|
187
199
|
pendingEventReplies,
|
|
188
200
|
runningProcesses,
|
|
@@ -196,6 +208,11 @@ async function main() {
|
|
|
196
208
|
}
|
|
197
209
|
|
|
198
210
|
for (const ea of eventAdapters) {
|
|
211
|
+
// Wire up chat handler for HTTP adapter so web UI gets full chat features
|
|
212
|
+
if (ea instanceof HttpApiAdapter) {
|
|
213
|
+
ea.setMessageHandler(handleMessage);
|
|
214
|
+
ea.setAdapters(adapters, eventAdapters);
|
|
215
|
+
}
|
|
199
216
|
await ea.start((event) => handleEvent(event, ea));
|
|
200
217
|
}
|
|
201
218
|
|
|
@@ -215,6 +232,7 @@ async function main() {
|
|
|
215
232
|
userId: cronTask.userId,
|
|
216
233
|
repo: cronTask.repo,
|
|
217
234
|
prompt: buildCronPrompt(cronTask.prompt),
|
|
235
|
+
taskType: "cron",
|
|
218
236
|
});
|
|
219
237
|
}
|
|
220
238
|
);
|
|
@@ -225,15 +243,30 @@ async function main() {
|
|
|
225
243
|
queue,
|
|
226
244
|
repos,
|
|
227
245
|
sessions,
|
|
246
|
+
adapters,
|
|
228
247
|
pendingReplies,
|
|
229
248
|
pendingEventReplies,
|
|
230
249
|
runningProcesses,
|
|
231
250
|
getRunnerForRepo,
|
|
232
251
|
getRunnerOptsForRepo,
|
|
233
252
|
getRepoInfo,
|
|
253
|
+
trace,
|
|
234
254
|
});
|
|
235
255
|
worker.start();
|
|
236
256
|
|
|
257
|
+
// Notify users whose tasks were interrupted by restart
|
|
258
|
+
if (staleTasks.length > 0) {
|
|
259
|
+
for (const task of staleTasks) {
|
|
260
|
+
const platform = task.userId.split(":")[0];
|
|
261
|
+
const adapter = adapters.find((a) => a.constructor.name.toLowerCase().includes(platform));
|
|
262
|
+
if (adapter?.sendToUser) {
|
|
263
|
+
adapter.sendToUser(task.userId, `Your task was interrupted by a restart: "${task.prompt.slice(0, 100)}". Please re-submit if needed.`).catch((err) =>
|
|
264
|
+
logger.warn("failed to notify user of interrupted task", { userId: task.userId, error: String(err) })
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
237
270
|
logger.info("ove ready");
|
|
238
271
|
|
|
239
272
|
async function shutdown() {
|
package/src/queue.ts
CHANGED
|
@@ -143,6 +143,28 @@ export class TaskQueue {
|
|
|
143
143
|
return result.changes > 0;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
listRecentFailed(limit: number = 20): Task[] {
|
|
147
|
+
const rows = this.db
|
|
148
|
+
.query(
|
|
149
|
+
`SELECT * FROM tasks WHERE status = 'failed' ORDER BY completed_at DESC LIMIT ?`
|
|
150
|
+
)
|
|
151
|
+
.all(limit) as TaskRow[];
|
|
152
|
+
return rows.map((r) => this.rowToTask(r));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
listRecent(limit: number = 20, status?: string): Task[] {
|
|
156
|
+
let sql = `SELECT * FROM tasks`;
|
|
157
|
+
const params: (string | number)[] = [];
|
|
158
|
+
if (status) {
|
|
159
|
+
sql += ` WHERE status = ?`;
|
|
160
|
+
params.push(status);
|
|
161
|
+
}
|
|
162
|
+
sql += ` ORDER BY created_at DESC LIMIT ?`;
|
|
163
|
+
params.push(limit);
|
|
164
|
+
const rows = this.db.query(sql).all(...params) as TaskRow[];
|
|
165
|
+
return rows.map((r) => this.rowToTask(r));
|
|
166
|
+
}
|
|
167
|
+
|
|
146
168
|
resetStale(): number {
|
|
147
169
|
const result = this.db.run(
|
|
148
170
|
`UPDATE tasks SET status = 'failed', result = 'Interrupted — process restarted', completed_at = ? WHERE status = 'running'`,
|