@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,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
  }
@@ -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
- // Edit may fail if content unchanged or message too old — ignore
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
- // Replace the status message with the first reply, then send new messages for the rest
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.editMessageText(chatId, statusMsgId, mdToHtml(replyText), { parse_mode: "HTML" });
68
- statusMsgId = undefined;
69
- return;
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
  }
@@ -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
  }
@@ -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
- const repoList = repoNames.join(", ");
378
- const history = deps.sessions.getHistory(msg.userId, 6);
379
- const formatHint = PLATFORM_FORMAT_HINTS[msg.platform] || PLATFORM_FORMAT_HINTS.slack;
380
- const inlinePrompt = `${OVE_PERSONA}\n\nAvailable repos: ${repoList}\n\nThe user has access to ${repoNames.length} repos. Based on their message, determine which repo(s) they mean and answer their question fully. Use \`gh\` CLI to query GitHub (e.g. \`gh pr list --repo owner/repo\`, \`gh issue list --repo owner/repo\`). Do NOT stop after identifying the repo — complete the actual task.\n\n${formatHint}\n\n${parsed.rawText}`;
381
-
382
- await msg.updateStatus("Working...");
383
- try {
384
- const runner = deps.getRunner(deps.config.runner?.name);
385
- const result = await runner.run(inlinePrompt, deps.config.reposDir, { maxTurns: 10 }, (event) => {
386
- if (event.kind === "tool") msg.updateStatus(`Using ${event.tool}...`);
387
- });
388
- const parts = splitAndReply(result.output, msg.platform);
389
- for (const part of parts) await msg.reply(part);
390
- deps.sessions.addMessage(msg.userId, "assistant", result.output.slice(0, 500));
391
- } catch (err) {
392
- await msg.reply(`Error: ${String(err).slice(0, 500)}`);
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'`,