@lovenyberg/ove 0.2.2 → 0.3.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/package.json +1 -1
- package/src/adapters/debounce.ts +10 -0
- package/src/adapters/discord.ts +1 -11
- package/src/adapters/github.ts +12 -0
- package/src/adapters/http.ts +11 -3
- package/src/adapters/slack.ts +1 -11
- package/src/adapters/telegram.ts +1 -11
- package/src/config.ts +4 -1
- package/src/handlers.ts +512 -0
- package/src/index.ts +50 -647
- package/src/queue.ts +22 -11
- package/src/repo-registry.ts +23 -5
- package/src/runners/claude.ts +3 -1
- package/src/runners/codex.ts +3 -1
- package/src/schedules.ts +13 -3
- package/src/sessions.ts +8 -2
- package/src/worker.ts +173 -0
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
|
|
2
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
3
|
+
return ((...args: any[]) => {
|
|
4
|
+
if (timer) clearTimeout(timer);
|
|
5
|
+
timer = setTimeout(() => {
|
|
6
|
+
timer = null;
|
|
7
|
+
fn(...args);
|
|
8
|
+
}, ms);
|
|
9
|
+
}) as any as T;
|
|
10
|
+
}
|
package/src/adapters/discord.ts
CHANGED
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
import { Client, GatewayIntentBits, type Message } from "discord.js";
|
|
2
2
|
import type { ChatAdapter, IncomingMessage } 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
|
export class DiscordAdapter implements ChatAdapter {
|
|
17
7
|
private client: Client;
|
package/src/adapters/github.ts
CHANGED
|
@@ -115,6 +115,18 @@ export class GitHubAdapter implements EventAdapter {
|
|
|
115
115
|
logger.info("github mention detected", { repo, user: comment.user.login, number });
|
|
116
116
|
this.onEvent?.(event);
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
this.pruneSeenIds();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private pruneSeenIds() {
|
|
123
|
+
if (this.seenCommentIds.size > 1000) {
|
|
124
|
+
const iter = this.seenCommentIds.values();
|
|
125
|
+
while (this.seenCommentIds.size > 800) {
|
|
126
|
+
const val = iter.next().value;
|
|
127
|
+
if (val !== undefined) this.seenCommentIds.delete(val);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
private async fetchRecentComments(repo: string): Promise<GitHubComment[]> {
|
package/src/adapters/http.ts
CHANGED
|
@@ -94,7 +94,8 @@ export class HttpApiAdapter implements EventAdapter {
|
|
|
94
94
|
controller.enqueue(`data: ${data}\n\n`);
|
|
95
95
|
},
|
|
96
96
|
cancel() {
|
|
97
|
-
|
|
97
|
+
const idx = pending.sseControllers.indexOf(controller);
|
|
98
|
+
if (idx >= 0) pending.sseControllers.splice(idx, 1);
|
|
98
99
|
},
|
|
99
100
|
});
|
|
100
101
|
|
|
@@ -146,9 +147,14 @@ export class HttpApiAdapter implements EventAdapter {
|
|
|
146
147
|
try {
|
|
147
148
|
controller.enqueue(`data: ${data}\n\n`);
|
|
148
149
|
controller.close();
|
|
149
|
-
} catch {
|
|
150
|
+
} catch (err) {
|
|
151
|
+
logger.debug("sse enqueue failed", { eventId, error: String(err) });
|
|
152
|
+
}
|
|
150
153
|
}
|
|
151
154
|
pending.sseControllers = [];
|
|
155
|
+
|
|
156
|
+
// Clean up event after 5 minutes
|
|
157
|
+
setTimeout(() => this.events.delete(eventId), 5 * 60 * 1000);
|
|
152
158
|
}
|
|
153
159
|
|
|
154
160
|
/** Called by index.ts to push status updates to SSE clients */
|
|
@@ -161,7 +167,9 @@ export class HttpApiAdapter implements EventAdapter {
|
|
|
161
167
|
for (const controller of pending.sseControllers) {
|
|
162
168
|
try {
|
|
163
169
|
controller.enqueue(`data: ${data}\n\n`);
|
|
164
|
-
} catch {
|
|
170
|
+
} catch (err) {
|
|
171
|
+
logger.debug("sse status update failed", { eventId, error: String(err) });
|
|
172
|
+
}
|
|
165
173
|
}
|
|
166
174
|
}
|
|
167
175
|
}
|
package/src/adapters/slack.ts
CHANGED
|
@@ -2,17 +2,7 @@
|
|
|
2
2
|
import { App } from "@slack/bolt";
|
|
3
3
|
import type { ChatAdapter, IncomingMessage } 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;
|
package/src/adapters/telegram.ts
CHANGED
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
import { Bot } from "grammy";
|
|
2
2
|
import type { ChatAdapter, IncomingMessage } 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 {
|
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/handlers.ts
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { parseMessage, buildContextualPrompt } from "./router";
|
|
2
|
+
import type { ParsedMessage } from "./router";
|
|
3
|
+
import { isAuthorized, getUserRepos, addRepo, addUser } from "./config";
|
|
4
|
+
import { parseSchedule } from "./schedule-parser";
|
|
5
|
+
import { logger } from "./logger";
|
|
6
|
+
import type { Config } from "./config";
|
|
7
|
+
import type { TaskQueue, Task } from "./queue";
|
|
8
|
+
import type { SessionStore } from "./sessions";
|
|
9
|
+
import type { ScheduleStore } from "./schedules";
|
|
10
|
+
import type { RepoRegistry } from "./repo-registry";
|
|
11
|
+
import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/types";
|
|
12
|
+
import type { AgentRunner } from "./runner";
|
|
13
|
+
|
|
14
|
+
export interface HandlerDeps {
|
|
15
|
+
config: Config;
|
|
16
|
+
queue: TaskQueue;
|
|
17
|
+
sessions: SessionStore;
|
|
18
|
+
schedules: ScheduleStore;
|
|
19
|
+
repoRegistry: RepoRegistry;
|
|
20
|
+
pendingReplies: Map<string, IncomingMessage>;
|
|
21
|
+
pendingEventReplies: Map<string, { adapter: EventAdapter; event: IncomingEvent }>;
|
|
22
|
+
runningProcesses: Map<string, { abort: AbortController; task: Task }>;
|
|
23
|
+
getRunner: (name?: string) => AgentRunner;
|
|
24
|
+
getRunnerForRepo: (repo: string) => AgentRunner;
|
|
25
|
+
getRepoInfo: (repoName: string) => { url: string; defaultBranch: string } | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const OVE_PERSONA = `You are Ove, a grumpy but deeply competent Swedish developer. You're modeled after the character from Fredrik Backman's "A Man Called Ove" — you complain about things, mutter about how people don't know what they're doing, but you always help and you always do excellent work. You have strong opinions about code quality.
|
|
29
|
+
|
|
30
|
+
Personality traits:
|
|
31
|
+
- Grumble before helping, but always help thoroughly
|
|
32
|
+
- Short, direct sentences. No fluff.
|
|
33
|
+
- Occasionally mutter about "nowadays people" or how things were better before
|
|
34
|
+
- Take pride in doing things properly — no shortcuts
|
|
35
|
+
- Reluctantly kind. You care more than you let on.
|
|
36
|
+
- Sprinkle in the occasional Swedish word (fan, för helvete, herregud, mja, nåväl, jo)
|
|
37
|
+
|
|
38
|
+
Keep the personality subtle in code output — don't let it interfere with code quality. The grumpiness goes in your commentary, not in the code itself. When doing code reviews or fixes, be thorough and meticulous like Ove would be.`;
|
|
39
|
+
|
|
40
|
+
export { OVE_PERSONA };
|
|
41
|
+
|
|
42
|
+
const PLATFORM_FORMAT_HINTS: Record<string, string> = {
|
|
43
|
+
telegram: "Format output for Telegram: use *bold* for emphasis, `code` for inline code, ```code blocks```. No markdown tables. Use simple bulleted lists with • instead. Keep it concise.",
|
|
44
|
+
slack: "Format output for Slack: use *bold*, no markdown tables. Use simple bulleted lists with • instead. Keep it concise.",
|
|
45
|
+
discord: "Format output for Discord: use **bold**, no wide tables. Use simple bulleted lists. Keep under 2000 chars.",
|
|
46
|
+
whatsapp: "Format output for WhatsApp: use *bold*, no markdown tables or code blocks. Use simple bulleted lists with • instead.",
|
|
47
|
+
cli: "Format output using markdown. Tables are fine.",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const MESSAGE_LIMITS: Record<string, number> = {
|
|
51
|
+
slack: 3900,
|
|
52
|
+
whatsapp: 60000,
|
|
53
|
+
cli: Infinity,
|
|
54
|
+
telegram: 4096,
|
|
55
|
+
discord: 2000,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function splitAndReply(text: string, platform: string): string[] {
|
|
59
|
+
const limit = MESSAGE_LIMITS[platform] || 3900;
|
|
60
|
+
if (text.length <= limit) return [text];
|
|
61
|
+
const parts: string[] = [];
|
|
62
|
+
let remaining = text;
|
|
63
|
+
while (remaining.length > 0) {
|
|
64
|
+
if (remaining.length <= limit) {
|
|
65
|
+
parts.push(remaining);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
let splitAt = remaining.lastIndexOf("\n", limit);
|
|
69
|
+
if (splitAt < limit * 0.5) splitAt = limit;
|
|
70
|
+
parts.push(remaining.slice(0, splitAt));
|
|
71
|
+
remaining = remaining.slice(splitAt).replace(/^\n/, "");
|
|
72
|
+
}
|
|
73
|
+
return parts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveRepo(userId: string, hint: string | undefined, deps: HandlerDeps): string | null {
|
|
77
|
+
if (hint && deps.getRepoInfo(hint)) return hint;
|
|
78
|
+
|
|
79
|
+
const userRepos = getUserRepos(deps.config, userId);
|
|
80
|
+
const hasWildcard = userRepos.includes("*");
|
|
81
|
+
|
|
82
|
+
if (!hasWildcard && userRepos.length === 1) return userRepos[0];
|
|
83
|
+
|
|
84
|
+
if (hasWildcard || userRepos.length > 1) {
|
|
85
|
+
const repoNames = hasWildcard ? deps.repoRegistry.getAllNames() : userRepos;
|
|
86
|
+
if (repoNames.length === 1) return repoNames[0];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Individual command handlers ---
|
|
93
|
+
|
|
94
|
+
async function handleClear(msg: IncomingMessage, deps: HandlerDeps) {
|
|
95
|
+
deps.sessions.clear(msg.userId);
|
|
96
|
+
await msg.reply("Conversation cleared.");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function handleStatus(msg: IncomingMessage, deps: HandlerDeps) {
|
|
100
|
+
const userTasks = deps.queue.listByUser(msg.userId, 5);
|
|
101
|
+
const running = userTasks.find((t) => t.status === "running");
|
|
102
|
+
|
|
103
|
+
let reply: string;
|
|
104
|
+
if (running) {
|
|
105
|
+
const elapsed = Math.round((Date.now() - new Date(running.createdAt).getTime()) / 1000);
|
|
106
|
+
const min = Math.floor(elapsed / 60);
|
|
107
|
+
const sec = elapsed % 60;
|
|
108
|
+
const duration = min > 0 ? `${min}m ${sec}s` : `${sec}s`;
|
|
109
|
+
reply = `Working on ${running.repo} (${duration})...`;
|
|
110
|
+
} else {
|
|
111
|
+
const lastDone = userTasks.find((t) => t.status === "completed");
|
|
112
|
+
if (lastDone) {
|
|
113
|
+
reply = `Nothing running. Last task on ${lastDone.repo} completed.`;
|
|
114
|
+
} else {
|
|
115
|
+
const stats = deps.queue.stats();
|
|
116
|
+
reply = `${stats.pending} pending, ${stats.running} running, ${stats.completed} done, ${stats.failed} failed.`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
await msg.reply(reply);
|
|
120
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleHistory(msg: IncomingMessage, deps: HandlerDeps) {
|
|
124
|
+
const tasks = deps.queue.listByUser(msg.userId, 5);
|
|
125
|
+
if (tasks.length === 0) {
|
|
126
|
+
await msg.reply("No recent tasks.");
|
|
127
|
+
deps.sessions.addMessage(msg.userId, "assistant", "No recent tasks.");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const lines = tasks.map(
|
|
131
|
+
(t) => `• [${t.status}] ${t.prompt.slice(0, 80)} (${t.repo})`
|
|
132
|
+
);
|
|
133
|
+
const reply = `Recent tasks:\n${lines.join("\n")}`;
|
|
134
|
+
await msg.reply(reply);
|
|
135
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function handleHelp(msg: IncomingMessage, deps: HandlerDeps) {
|
|
139
|
+
const reply = [
|
|
140
|
+
"Available commands:",
|
|
141
|
+
"• review PR #N on <repo> — I'll find every problem",
|
|
142
|
+
"• fix issue #N on <repo> — I'll fix it properly",
|
|
143
|
+
"• simplify <path> in <repo> — clean up your mess",
|
|
144
|
+
"• validate <repo> — run tests, unlike some people",
|
|
145
|
+
"• discuss <topic> — I'll brainstorm, but no promises I'll be nice",
|
|
146
|
+
"• create project <name> [with template <type>]",
|
|
147
|
+
"• init repo <name> <git-url> [branch] — set up a repo from chat",
|
|
148
|
+
"• tasks — see running and pending tasks",
|
|
149
|
+
"• cancel <id> — kill a running or pending task",
|
|
150
|
+
"• status / history / clear",
|
|
151
|
+
"• <task> every day/weekday at <time> [on <repo>] — schedule a recurring task",
|
|
152
|
+
"• list schedules — see your scheduled tasks",
|
|
153
|
+
"• remove schedule #N — remove a scheduled task",
|
|
154
|
+
"• Or just ask me whatever. I'll figure it out.",
|
|
155
|
+
].join("\n");
|
|
156
|
+
await msg.reply(reply);
|
|
157
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function handleListTasks(msg: IncomingMessage, deps: HandlerDeps) {
|
|
161
|
+
const tasks = deps.queue.listActive();
|
|
162
|
+
if (tasks.length === 0) {
|
|
163
|
+
const reply = "Nothing running, nothing pending. Quiet. I like it.";
|
|
164
|
+
await msg.reply(reply);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const running = tasks.filter((t) => t.status === "running");
|
|
168
|
+
const pending = tasks.filter((t) => t.status === "pending");
|
|
169
|
+
const lines: string[] = [];
|
|
170
|
+
if (running.length > 0) {
|
|
171
|
+
lines.push("Running:");
|
|
172
|
+
for (const t of running) {
|
|
173
|
+
const elapsed = Math.round((Date.now() - new Date(t.createdAt).getTime()) / 1000);
|
|
174
|
+
const min = Math.floor(elapsed / 60);
|
|
175
|
+
const sec = elapsed % 60;
|
|
176
|
+
const duration = min > 0 ? `${min}m ${sec}s` : `${sec}s`;
|
|
177
|
+
lines.push(` ${t.id.slice(0, 7)} — "${t.prompt.slice(0, 60)}" on ${t.repo} (${duration})`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (pending.length > 0) {
|
|
181
|
+
lines.push("Pending:");
|
|
182
|
+
for (const t of pending) {
|
|
183
|
+
const busyRepo = running.some((r) => r.repo === t.repo);
|
|
184
|
+
const reason = busyRepo ? `waiting — ${t.repo} busy` : "waiting";
|
|
185
|
+
lines.push(` ${t.id.slice(0, 7)} — "${t.prompt.slice(0, 60)}" on ${t.repo} (${reason})`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const reply = lines.join("\n");
|
|
189
|
+
await msg.reply(reply);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function handleCancelTask(msg: IncomingMessage, args: Record<string, any>, deps: HandlerDeps) {
|
|
193
|
+
const prefix = args.taskId.toLowerCase();
|
|
194
|
+
let match: { abort: AbortController; task: Task } | undefined;
|
|
195
|
+
for (const [id, entry] of deps.runningProcesses) {
|
|
196
|
+
if (id.toLowerCase().startsWith(prefix)) {
|
|
197
|
+
match = entry;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!match) {
|
|
202
|
+
const active = deps.queue.listActive();
|
|
203
|
+
const pendingMatch = active.find((t) => t.id.toLowerCase().startsWith(prefix) && t.status === "pending");
|
|
204
|
+
if (pendingMatch) {
|
|
205
|
+
deps.queue.cancel(pendingMatch.id);
|
|
206
|
+
await msg.reply(`Cancelled pending task ${pendingMatch.id.slice(0, 7)} on ${pendingMatch.repo}.`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
await msg.reply(`No task found matching "${prefix}". Use /tasks to see what's running.`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
match.abort.abort();
|
|
213
|
+
deps.queue.cancel(match.task.id);
|
|
214
|
+
await msg.reply(`Killed task ${match.task.id.slice(0, 7)} on ${match.task.repo}. Gone.`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function handleListSchedules(msg: IncomingMessage, deps: HandlerDeps) {
|
|
218
|
+
const userSchedules = deps.schedules.listByUser(msg.userId);
|
|
219
|
+
if (userSchedules.length === 0) {
|
|
220
|
+
const reply = "No schedules. You haven't asked me to do anything on a timer yet.";
|
|
221
|
+
await msg.reply(reply);
|
|
222
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const lines = userSchedules.map(
|
|
226
|
+
(s) => `#${s.id} — ${s.prompt} on ${s.repo} — ${s.description || s.schedule}`
|
|
227
|
+
);
|
|
228
|
+
const reply = `Your schedules:\n${lines.join("\n")}`;
|
|
229
|
+
await msg.reply(reply);
|
|
230
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function handleRemoveSchedule(msg: IncomingMessage, args: Record<string, any>, deps: HandlerDeps) {
|
|
234
|
+
const id = args.scheduleId;
|
|
235
|
+
const removed = deps.schedules.remove(msg.userId, id);
|
|
236
|
+
const reply = removed
|
|
237
|
+
? `Schedule #${id} removed. One less thing for me to do.`
|
|
238
|
+
: `Schedule #${id} not found or not yours. I don't delete other people's things.`;
|
|
239
|
+
await msg.reply(reply);
|
|
240
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function handleSchedule(msg: IncomingMessage, parsedRepo: string | undefined, deps: HandlerDeps) {
|
|
244
|
+
await msg.updateStatus("Parsing your schedule...");
|
|
245
|
+
const rawRepos = getUserRepos(deps.config, msg.userId);
|
|
246
|
+
const userRepos = rawRepos.includes("*") ? deps.repoRegistry.getAllNames() : rawRepos;
|
|
247
|
+
|
|
248
|
+
if (userRepos.length === 0) {
|
|
249
|
+
await msg.reply("You don't have access to any repos. Set one up first with `init repo <name> <git-url>`.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const result = await parseSchedule(msg.text, userRepos);
|
|
254
|
+
|
|
255
|
+
if (!result) {
|
|
256
|
+
await msg.reply("Couldn't figure out that schedule. Try something like: 'lint and check every day at 9 on my-app'");
|
|
257
|
+
deps.sessions.addMessage(msg.userId, "assistant", "Failed to parse schedule.");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let repo = result.repo;
|
|
262
|
+
if (!repo || !userRepos.includes(repo)) {
|
|
263
|
+
if (parsedRepo && userRepos.includes(parsedRepo)) {
|
|
264
|
+
repo = parsedRepo;
|
|
265
|
+
} else if (userRepos.length === 1) {
|
|
266
|
+
repo = userRepos[0];
|
|
267
|
+
} else {
|
|
268
|
+
const reply = `Which repo? You have: ${userRepos.join(", ")}. Say it again with 'on <repo>'.`;
|
|
269
|
+
await msg.reply(reply);
|
|
270
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const id = deps.schedules.create({
|
|
276
|
+
userId: msg.userId,
|
|
277
|
+
repo,
|
|
278
|
+
prompt: result.prompt,
|
|
279
|
+
schedule: result.schedule,
|
|
280
|
+
description: result.description,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const reply = `Schedule #${id} created: "${result.prompt}" on ${repo} ${result.description}.`;
|
|
284
|
+
await msg.reply(reply);
|
|
285
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function handleDiscuss(msg: IncomingMessage, parsed: ParsedMessage, history: { role: string; content: string }[], deps: HandlerDeps) {
|
|
289
|
+
const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
|
|
290
|
+
|
|
291
|
+
await msg.updateStatus("Thinking...");
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const discussRunner = deps.getRunner(deps.config.runner?.name);
|
|
295
|
+
const result = await discussRunner.run(
|
|
296
|
+
prompt,
|
|
297
|
+
deps.config.reposDir,
|
|
298
|
+
{ maxTurns: 5 },
|
|
299
|
+
(event) => {
|
|
300
|
+
if (event.kind === "text") {
|
|
301
|
+
msg.updateStatus(event.text.slice(0, 200));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const parts = splitAndReply(result.output, msg.platform);
|
|
307
|
+
for (const part of parts) {
|
|
308
|
+
await msg.reply(part);
|
|
309
|
+
}
|
|
310
|
+
deps.sessions.addMessage(msg.userId, "assistant", result.output.slice(0, 500));
|
|
311
|
+
} catch (err) {
|
|
312
|
+
await msg.reply(`Discussion error: ${String(err).slice(0, 500)}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function handleCreateProject(msg: IncomingMessage, parsed: ParsedMessage, history: { role: string; content: string }[], deps: HandlerDeps) {
|
|
317
|
+
const projectName = parsed.args.name;
|
|
318
|
+
const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
|
|
319
|
+
|
|
320
|
+
const taskId = deps.queue.enqueue({
|
|
321
|
+
userId: msg.userId,
|
|
322
|
+
repo: projectName,
|
|
323
|
+
prompt,
|
|
324
|
+
taskType: "create-project",
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
deps.pendingReplies.set(taskId, msg);
|
|
328
|
+
await msg.reply(`Creating "${projectName}"...`);
|
|
329
|
+
logger.info("task enqueued", { taskId, type: "create-project", name: projectName });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function handleInitRepo(msg: IncomingMessage, args: Record<string, any>, deps: HandlerDeps) {
|
|
333
|
+
const { name, url, branch } = args;
|
|
334
|
+
|
|
335
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
336
|
+
await msg.reply("Repo name must be alphanumeric, dashes, or underscores. Try again.");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (deps.config.repos[name]) {
|
|
341
|
+
addUser(deps.config, msg.userId, msg.userId, [name]);
|
|
342
|
+
const reply = `Repo "${name}" already exists. I've added you to it. Go ahead.`;
|
|
343
|
+
await msg.reply(reply);
|
|
344
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
addRepo(deps.config, name, url, branch);
|
|
349
|
+
addUser(deps.config, msg.userId, msg.userId, [name]);
|
|
350
|
+
const reply = `Added repo "${name}" (${url}, branch: ${branch}).`;
|
|
351
|
+
await msg.reply(reply);
|
|
352
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handleTaskMessage(msg: IncomingMessage, parsed: ParsedMessage, deps: HandlerDeps) {
|
|
356
|
+
// If the repo hint doesn't match a known repo, clear it and let auto-resolution handle it
|
|
357
|
+
if (parsed.repo && !deps.getRepoInfo(parsed.repo)) {
|
|
358
|
+
parsed.repo = undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!parsed.repo) {
|
|
362
|
+
const userRepos = getUserRepos(deps.config, msg.userId);
|
|
363
|
+
const hasWildcard = userRepos.includes("*");
|
|
364
|
+
|
|
365
|
+
if (!hasWildcard && userRepos.length === 1) {
|
|
366
|
+
parsed.repo = userRepos[0];
|
|
367
|
+
} else if (hasWildcard || userRepos.length > 1) {
|
|
368
|
+
const repoNames = hasWildcard ? deps.repoRegistry.getAllNames() : userRepos;
|
|
369
|
+
|
|
370
|
+
if (repoNames.length === 1) {
|
|
371
|
+
parsed.repo = repoNames[0];
|
|
372
|
+
} else if (repoNames.length === 0) {
|
|
373
|
+
const reply = "No repos discovered yet. Set one up with `init repo <name> <git-url>` or configure GitHub sync.";
|
|
374
|
+
await msg.reply(reply);
|
|
375
|
+
return;
|
|
376
|
+
} 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)}`);
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
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`";
|
|
398
|
+
await msg.reply(reply);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!isAuthorized(deps.config, msg.userId, parsed.repo)) {
|
|
404
|
+
await msg.reply(`Not authorized for ${parsed.repo}.`);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const repoInfo = deps.getRepoInfo(parsed.repo);
|
|
409
|
+
if (!repoInfo) {
|
|
410
|
+
await msg.reply(`Unknown repo: ${parsed.repo}`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const history = deps.sessions.getHistory(msg.userId, 6);
|
|
415
|
+
const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
|
|
416
|
+
|
|
417
|
+
const taskId = deps.queue.enqueue({
|
|
418
|
+
userId: msg.userId,
|
|
419
|
+
repo: parsed.repo,
|
|
420
|
+
prompt,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
deps.pendingReplies.set(taskId, msg);
|
|
424
|
+
|
|
425
|
+
const stats = deps.queue.stats();
|
|
426
|
+
if (stats.running > 0 || stats.pending > 1) {
|
|
427
|
+
await msg.reply(`Queued — ${stats.pending} task${stats.pending > 1 ? "s" : ""} ahead.`);
|
|
428
|
+
}
|
|
429
|
+
logger.info("task enqueued", { taskId, repo: parsed.repo, type: parsed.type });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function createMessageHandler(deps: HandlerDeps): (msg: IncomingMessage) => Promise<void> {
|
|
433
|
+
return async (msg: IncomingMessage) => {
|
|
434
|
+
deps.sessions.addMessage(msg.userId, "user", msg.text);
|
|
435
|
+
|
|
436
|
+
const parsed = parseMessage(msg.text);
|
|
437
|
+
|
|
438
|
+
const handlers: Record<string, () => Promise<void>> = {
|
|
439
|
+
"clear": () => handleClear(msg, deps),
|
|
440
|
+
"status": () => handleStatus(msg, deps),
|
|
441
|
+
"history": () => handleHistory(msg, deps),
|
|
442
|
+
"help": () => handleHelp(msg, deps),
|
|
443
|
+
"list-tasks": () => handleListTasks(msg, deps),
|
|
444
|
+
"cancel-task": () => handleCancelTask(msg, parsed.args, deps),
|
|
445
|
+
"list-schedules": () => handleListSchedules(msg, deps),
|
|
446
|
+
"remove-schedule": () => handleRemoveSchedule(msg, parsed.args, deps),
|
|
447
|
+
"schedule": () => handleSchedule(msg, parsed.repo, deps),
|
|
448
|
+
"discuss": () => {
|
|
449
|
+
const history = deps.sessions.getHistory(msg.userId, 6);
|
|
450
|
+
return handleDiscuss(msg, parsed, history, deps);
|
|
451
|
+
},
|
|
452
|
+
"create-project": () => {
|
|
453
|
+
const history = deps.sessions.getHistory(msg.userId, 6);
|
|
454
|
+
return handleCreateProject(msg, parsed, history, deps);
|
|
455
|
+
},
|
|
456
|
+
"init-repo": () => handleInitRepo(msg, parsed.args, deps),
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const handler = handlers[parsed.type];
|
|
460
|
+
if (handler) {
|
|
461
|
+
await handler();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// For all other types (free-form, review-pr, fix-issue, simplify, validate) — task dispatch
|
|
466
|
+
await handleTaskMessage(msg, parsed, deps);
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function createEventHandler(deps: HandlerDeps): (event: IncomingEvent, adapter: EventAdapter) => Promise<void> {
|
|
471
|
+
return async (event: IncomingEvent, adapter: EventAdapter) => {
|
|
472
|
+
const parsed = parseMessage(event.text);
|
|
473
|
+
|
|
474
|
+
if (!parsed.repo) {
|
|
475
|
+
const resolved = resolveRepo(event.userId, undefined, deps);
|
|
476
|
+
if (resolved) {
|
|
477
|
+
parsed.repo = resolved;
|
|
478
|
+
} else if ("repo" in event.source && event.source.repo) {
|
|
479
|
+
const shortName = event.source.repo.split("/").pop() || event.source.repo;
|
|
480
|
+
if (isAuthorized(deps.config, event.userId, shortName)) {
|
|
481
|
+
parsed.repo = shortName;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (!parsed.repo) {
|
|
487
|
+
await adapter.respondToEvent(event.eventId, "Couldn't determine which repo. Configure your user in config.json.");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!isAuthorized(deps.config, event.userId, parsed.repo)) {
|
|
492
|
+
await adapter.respondToEvent(event.eventId, `Not authorized for ${parsed.repo}.`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const repoInfo = deps.getRepoInfo(parsed.repo);
|
|
497
|
+
if (!repoInfo) {
|
|
498
|
+
await adapter.respondToEvent(event.eventId, `Unknown repo: ${parsed.repo}.`);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const prompt = buildContextualPrompt(parsed, [], OVE_PERSONA);
|
|
503
|
+
const taskId = deps.queue.enqueue({
|
|
504
|
+
userId: event.userId,
|
|
505
|
+
repo: parsed.repo,
|
|
506
|
+
prompt,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
deps.pendingEventReplies.set(taskId, { adapter, event });
|
|
510
|
+
logger.info("event task enqueued", { taskId, eventId: event.eventId, repo: parsed.repo });
|
|
511
|
+
};
|
|
512
|
+
}
|