@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.
- 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 +42 -0
- package/src/adapters/discord.ts +18 -13
- package/src/adapters/github.ts +38 -1
- package/src/adapters/http.test.ts +7 -1
- package/src/adapters/http.ts +227 -47
- package/src/adapters/slack.ts +18 -13
- package/src/adapters/telegram.ts +22 -20
- package/src/adapters/types.ts +11 -0
- package/src/adapters/whatsapp.ts +40 -2
- package/src/config.ts +4 -1
- package/src/flows.test.ts +126 -0
- package/src/handlers.ts +571 -0
- package/src/index.ts +85 -649
- package/src/queue.ts +44 -11
- package/src/repo-registry.ts +23 -5
- package/src/router.ts +8 -2
- package/src/runners/claude.ts +27 -6
- package/src/runners/codex.ts +23 -2
- package/src/schedules.ts +13 -3
- package/src/sessions.ts +8 -2
- package/src/trace.ts +71 -0
- package/src/worker.ts +235 -0
package/src/adapters/slack.ts
CHANGED
|
@@ -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
|
}
|
package/src/adapters/telegram.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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.
|
|
78
|
-
|
|
79
|
-
|
|
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
|
}
|
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/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
|
+
});
|