@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,22 +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
-
6
- function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
7
- let timer: ReturnType<typeof setTimeout> | null = null;
8
- return ((...args: any[]) => {
9
- if (timer) clearTimeout(timer);
10
- timer = setTimeout(() => {
11
- timer = null;
12
- fn(...args);
13
- }, ms);
14
- }) as any as T;
15
- }
5
+ import { debounce } from "./debounce";
16
6
 
17
7
  export class SlackAdapter implements ChatAdapter {
18
8
  private app: App;
19
9
  private onMessage?: (msg: IncomingMessage) => void;
10
+ private started = false;
11
+ private startedAt?: string;
20
12
 
21
13
  constructor() {
22
14
  this.app = new App({
@@ -42,7 +34,8 @@ export class SlackAdapter implements ChatAdapter {
42
34
  ts: statusMsgTs,
43
35
  text: statusText,
44
36
  });
45
- } catch {
37
+ } catch (err) {
38
+ logger.warn("slack status update failed", { error: String(err) });
46
39
  const result = await say(statusText);
47
40
  if (result && "ts" in result) statusMsgTs = result.ts;
48
41
  }
@@ -96,10 +89,22 @@ export class SlackAdapter implements ChatAdapter {
96
89
  });
97
90
 
98
91
  await this.app.start();
92
+ this.started = true;
93
+ this.startedAt = new Date().toISOString();
99
94
  logger.info("slack adapter started");
100
95
  }
101
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
+
102
106
  async stop(): Promise<void> {
107
+ this.started = false;
103
108
  await this.app.stop();
104
109
  logger.info("slack adapter stopped");
105
110
  }
@@ -1,17 +1,7 @@
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
-
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
  /** Convert simple markdown (*bold*, `code`, ```blocks```) to Telegram HTML */
17
7
  function mdToHtml(text: string): string {
@@ -34,6 +24,8 @@ function mdToHtml(text: string): string {
34
24
  export class TelegramAdapter implements ChatAdapter {
35
25
  private bot: Bot;
36
26
  private onMessage?: (msg: IncomingMessage) => void;
27
+ private started = false;
28
+ private startedAt?: string;
37
29
 
38
30
  constructor(token: string) {
39
31
  if (!token) throw new Error("Telegram bot token is required");
@@ -61,8 +53,8 @@ export class TelegramAdapter implements ChatAdapter {
61
53
  const sent = await ctx.reply(html, { parse_mode: "HTML" });
62
54
  statusMsgId = sent.message_id;
63
55
  }
64
- } catch {
65
- // Edit may fail if content unchanged or message too old — ignore
56
+ } catch (err) {
57
+ logger.warn("telegram status update failed", { error: String(err) });
66
58
  }
67
59
  }, 3000);
68
60
 
@@ -71,14 +63,12 @@ export class TelegramAdapter implements ChatAdapter {
71
63
  platform: "telegram",
72
64
  text,
73
65
  reply: async (replyText: string) => {
74
- // 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
75
67
  if (statusMsgId) {
76
68
  try {
77
- await ctx.api.editMessageText(chatId, statusMsgId, mdToHtml(replyText), { parse_mode: "HTML" });
78
- statusMsgId = undefined;
79
- return;
80
- } catch {
81
- // 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) });
82
72
  }
83
73
  statusMsgId = undefined;
84
74
  }
@@ -92,10 +82,22 @@ export class TelegramAdapter implements ChatAdapter {
92
82
  });
93
83
 
94
84
  this.bot.start();
85
+ this.started = true;
86
+ this.startedAt = new Date().toISOString();
95
87
  logger.info("telegram adapter started");
96
88
  }
97
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
+
98
99
  async stop(): Promise<void> {
100
+ this.started = false;
99
101
  this.bot.stop();
100
102
  logger.info("telegram adapter stopped");
101
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/config.ts CHANGED
@@ -64,6 +64,7 @@ export function loadConfig(): Config {
64
64
  github: raw.github,
65
65
  };
66
66
  } catch {
67
+ // Config file doesn't exist yet or is invalid — use defaults
67
68
  return {
68
69
  repos: {},
69
70
  users: {},
@@ -94,7 +95,9 @@ export function saveConfig(config: Config): void {
94
95
  let existing: Record<string, any> = {};
95
96
  try {
96
97
  existing = JSON.parse(readFileSync(configPath, "utf-8"));
97
- } catch {}
98
+ } catch {
99
+ // File doesn't exist yet or is invalid — start with empty object
100
+ }
98
101
  const merged = { ...existing, repos: config.repos, users: config.users, claude: config.claude, reposDir: config.reposDir };
99
102
  if (config.mcpServers) merged.mcpServers = config.mcpServers;
100
103
  if (config.cron) merged.cron = config.cron;
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
+ });