@lovenyberg/ove 0.2.1 → 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/README.md +10 -5
- package/bun.lock +63 -263
- package/config.example.json +13 -2
- package/docs/examples.md +28 -0
- package/docs/index.html +14 -9
- package/docs/plans/2026-02-22-repo-autodiscovery-design.md +98 -0
- package/docs/plans/2026-02-22-repo-autodiscovery-plan.md +826 -0
- package/package.json +2 -2
- 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 +37 -15
- package/src/adapters/whatsapp.ts +49 -11
- package/src/config.test.ts +70 -0
- package/src/config.ts +16 -4
- package/src/handlers.ts +512 -0
- package/src/index.ts +96 -491
- package/src/queue.ts +46 -10
- package/src/repo-registry.test.ts +130 -0
- package/src/repo-registry.ts +201 -0
- package/src/repos.ts +19 -5
- package/src/router.test.ts +125 -1
- package/src/router.ts +46 -3
- package/src/runner.ts +1 -0
- package/src/runners/claude.ts +7 -1
- package/src/runners/codex.ts +7 -1
- package/src/schedules.ts +13 -3
- package/src/sessions.ts +8 -2
- package/src/worker.ts +173 -0
package/src/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { loadConfig
|
|
2
|
+
import { loadConfig } from "./config";
|
|
3
3
|
import { TaskQueue } from "./queue";
|
|
4
4
|
import { RepoManager } from "./repos";
|
|
5
5
|
import { ClaudeRunner } from "./runners/claude";
|
|
6
6
|
import { CodexRunner } from "./runners/codex";
|
|
7
|
-
import {
|
|
7
|
+
import { buildCronPrompt } from "./router";
|
|
8
8
|
import { SlackAdapter } from "./adapters/slack";
|
|
9
9
|
import { WhatsAppAdapter } from "./adapters/whatsapp";
|
|
10
10
|
import { CliAdapter } from "./adapters/cli";
|
|
@@ -14,27 +14,15 @@ import { HttpApiAdapter } from "./adapters/http";
|
|
|
14
14
|
import { GitHubAdapter } from "./adapters/github";
|
|
15
15
|
import type { ChatAdapter, IncomingMessage } from "./adapters/types";
|
|
16
16
|
import type { EventAdapter, IncomingEvent } from "./adapters/types";
|
|
17
|
-
import type { AgentRunner, RunOptions
|
|
17
|
+
import type { AgentRunner, RunOptions } from "./runner";
|
|
18
18
|
import { logger } from "./logger";
|
|
19
|
+
import { RepoRegistry, syncGitHub } from "./repo-registry";
|
|
19
20
|
import { SessionStore } from "./sessions";
|
|
20
21
|
import { startCronLoop } from "./cron";
|
|
21
22
|
import { ScheduleStore } from "./schedules";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import { join } from "node:path";
|
|
26
|
-
|
|
27
|
-
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.
|
|
28
|
-
|
|
29
|
-
Personality traits:
|
|
30
|
-
- Grumble before helping, but always help thoroughly
|
|
31
|
-
- Short, direct sentences. No fluff.
|
|
32
|
-
- Occasionally mutter about "nowadays people" or how things were better before
|
|
33
|
-
- Take pride in doing things properly — no shortcuts
|
|
34
|
-
- Reluctantly kind. You care more than you let on.
|
|
35
|
-
- Sprinkle in the occasional Swedish word (fan, för helvete, herregud, mja, nåväl, jo)
|
|
36
|
-
|
|
37
|
-
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.`;
|
|
23
|
+
import { createMessageHandler, createEventHandler } from "./handlers";
|
|
24
|
+
import { createWorker } from "./worker";
|
|
25
|
+
import type { Task } from "./queue";
|
|
38
26
|
|
|
39
27
|
const config = loadConfig();
|
|
40
28
|
const db = new Database(process.env.DB_PATH || "./ove.db");
|
|
@@ -43,6 +31,17 @@ const queue = new TaskQueue(db);
|
|
|
43
31
|
const repos = new RepoManager(config.reposDir);
|
|
44
32
|
const sessions = new SessionStore(db);
|
|
45
33
|
const schedules = new ScheduleStore(db);
|
|
34
|
+
const repoRegistry = new RepoRegistry(db);
|
|
35
|
+
|
|
36
|
+
// Migrate existing config repos to SQLite
|
|
37
|
+
repoRegistry.migrateFromConfig(
|
|
38
|
+
Object.fromEntries(
|
|
39
|
+
Object.entries(config.repos)
|
|
40
|
+
.filter(([_, r]) => r.url)
|
|
41
|
+
.map(([name, r]) => [name, { url: r.url!, defaultBranch: r.defaultBranch }])
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
|
|
46
45
|
const runners = new Map<string, AgentRunner>();
|
|
47
46
|
|
|
48
47
|
function getRunner(name: string = "claude"): AgentRunner {
|
|
@@ -76,8 +75,35 @@ function getRunnerOptsForRepo(repo: string, baseOpts: RunOptions): RunOptions {
|
|
|
76
75
|
return model ? { ...baseOpts, model } : baseOpts;
|
|
77
76
|
}
|
|
78
77
|
|
|
79
|
-
|
|
78
|
+
function getRepoInfo(repoName: string): { url: string; defaultBranch: string } | null {
|
|
79
|
+
const configRepo = config.repos[repoName];
|
|
80
|
+
const registryRepo = repoRegistry.getByName(repoName);
|
|
81
|
+
|
|
82
|
+
if (!configRepo?.url && !registryRepo) return null;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
url: configRepo?.url || registryRepo?.url || "",
|
|
86
|
+
defaultBranch: configRepo?.defaultBranch || registryRepo?.defaultBranch || "main",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function startGitHubSync() {
|
|
91
|
+
if (!config.github) return;
|
|
92
|
+
const interval = config.github.syncInterval || 1_800_000;
|
|
93
|
+
|
|
94
|
+
await syncGitHub(repoRegistry, config.github.orgs);
|
|
95
|
+
|
|
96
|
+
setInterval(() => {
|
|
97
|
+
syncGitHub(repoRegistry, config.github!.orgs).catch((err) =>
|
|
98
|
+
logger.warn("github sync failed", { error: String(err) })
|
|
99
|
+
);
|
|
100
|
+
}, interval);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Shared state maps
|
|
80
104
|
const pendingReplies = new Map<string, IncomingMessage>();
|
|
105
|
+
const pendingEventReplies = new Map<string, { adapter: EventAdapter; event: IncomingEvent }>();
|
|
106
|
+
const runningProcesses = new Map<string, { abort: AbortController; task: Task }>();
|
|
81
107
|
|
|
82
108
|
// Start adapters based on available env vars
|
|
83
109
|
const adapters: ChatAdapter[] = [];
|
|
@@ -87,7 +113,9 @@ if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
|
|
|
87
113
|
}
|
|
88
114
|
|
|
89
115
|
if (process.env.WHATSAPP_ENABLED === "true") {
|
|
90
|
-
adapters.push(new WhatsAppAdapter(
|
|
116
|
+
adapters.push(new WhatsAppAdapter({
|
|
117
|
+
phoneNumber: process.env.WHATSAPP_PHONE,
|
|
118
|
+
}));
|
|
91
119
|
}
|
|
92
120
|
|
|
93
121
|
if (process.env.TELEGRAM_BOT_TOKEN) {
|
|
@@ -122,488 +150,55 @@ if (process.env.CLI_MODE === "true" || (adapters.length === 0 && eventAdapters.l
|
|
|
122
150
|
adapters.push(new CliAdapter(cliUserId));
|
|
123
151
|
}
|
|
124
152
|
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
telegram: 4096,
|
|
131
|
-
discord: 2000,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
function splitAndReply(text: string, platform: string): string[] {
|
|
135
|
-
const limit = MESSAGE_LIMITS[platform] || 3900;
|
|
136
|
-
if (text.length <= limit) return [text];
|
|
137
|
-
const parts: string[] = [];
|
|
138
|
-
let remaining = text;
|
|
139
|
-
while (remaining.length > 0) {
|
|
140
|
-
if (remaining.length <= limit) {
|
|
141
|
-
parts.push(remaining);
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
// Try to split at a newline near the limit
|
|
145
|
-
let splitAt = remaining.lastIndexOf("\n", limit);
|
|
146
|
-
if (splitAt < limit * 0.5) splitAt = limit;
|
|
147
|
-
parts.push(remaining.slice(0, splitAt));
|
|
148
|
-
remaining = remaining.slice(splitAt).replace(/^\n/, "");
|
|
149
|
-
}
|
|
150
|
-
return parts;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function formatStatusLog(log: string[]): string {
|
|
154
|
-
return log.slice(-10).map((l) => `> ${l}`).join("\n");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function handleMessage(msg: IncomingMessage) {
|
|
158
|
-
// Store user message in session
|
|
159
|
-
sessions.addMessage(msg.userId, "user", msg.text);
|
|
160
|
-
|
|
161
|
-
const parsed = parseMessage(msg.text);
|
|
162
|
-
|
|
163
|
-
// Handle clear/reset command
|
|
164
|
-
if (parsed.type === "clear") {
|
|
165
|
-
sessions.clear(msg.userId);
|
|
166
|
-
await msg.reply("Nåväl. Slate wiped clean. Try not to make a mess of it again.");
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Handle non-task commands
|
|
171
|
-
if (parsed.type === "status") {
|
|
172
|
-
const stats = queue.stats();
|
|
173
|
-
const reply = `${stats.pending} pending, ${stats.running} running, ${stats.completed} done, ${stats.failed} failed. I'm keeping track so you don't have to.`;
|
|
174
|
-
await msg.reply(reply);
|
|
175
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (parsed.type === "history") {
|
|
180
|
-
const tasks = queue.listByUser(msg.userId, 5);
|
|
181
|
-
if (tasks.length === 0) {
|
|
182
|
-
await msg.reply("Nothing. You haven't asked me to do anything yet. Typical.");
|
|
183
|
-
sessions.addMessage(msg.userId, "assistant", "No recent tasks.");
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
const lines = tasks.map(
|
|
187
|
-
(t) => `• [${t.status}] ${t.prompt.slice(0, 80)} (${t.repo})`
|
|
188
|
-
);
|
|
189
|
-
const reply = `Here. Your recent tasks:\n${lines.join("\n")}`;
|
|
190
|
-
await msg.reply(reply);
|
|
191
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (parsed.type === "help") {
|
|
196
|
-
const reply = [
|
|
197
|
-
"Fine. Here's what I can do, since apparently you need to be told:",
|
|
198
|
-
"• review PR #N on <repo> — I'll find every problem",
|
|
199
|
-
"• fix issue #N on <repo> — I'll fix it properly",
|
|
200
|
-
"• simplify <path> in <repo> — clean up your mess",
|
|
201
|
-
"• validate <repo> — run tests, unlike some people",
|
|
202
|
-
"• discuss <topic> — I'll brainstorm, but no promises I'll be nice",
|
|
203
|
-
"• create project <name> [with template <type>]",
|
|
204
|
-
"• init repo <name> <git-url> [branch] — set up a repo from chat",
|
|
205
|
-
"• status / history / clear",
|
|
206
|
-
"• <task> every day/weekday at <time> [on <repo>] — schedule a recurring task",
|
|
207
|
-
"• list schedules — see your scheduled tasks",
|
|
208
|
-
"• remove schedule #N — remove a scheduled task",
|
|
209
|
-
"• Or just ask me whatever. I'll figure it out.",
|
|
210
|
-
].join("\n");
|
|
211
|
-
await msg.reply(reply);
|
|
212
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// List schedules
|
|
217
|
-
if (parsed.type === "list-schedules") {
|
|
218
|
-
const userSchedules = 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
|
-
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
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Remove schedule
|
|
235
|
-
if (parsed.type === "remove-schedule") {
|
|
236
|
-
const id = parsed.args.scheduleId;
|
|
237
|
-
const removed = schedules.remove(msg.userId, id);
|
|
238
|
-
const reply = removed
|
|
239
|
-
? `Schedule #${id} removed. One less thing for me to do.`
|
|
240
|
-
: `Schedule #${id} not found or not yours. I don't delete other people's things.`;
|
|
241
|
-
await msg.reply(reply);
|
|
242
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Create schedule
|
|
247
|
-
if (parsed.type === "schedule") {
|
|
248
|
-
await msg.updateStatus("Parsing your schedule...");
|
|
249
|
-
const userRepos = getUserRepos(config, msg.userId);
|
|
250
|
-
|
|
251
|
-
if (userRepos.length === 0) {
|
|
252
|
-
await msg.reply("You don't have access to any repos. Set one up first with `init repo <name> <git-url>`.");
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const result = await parseSchedule(msg.text, userRepos);
|
|
257
|
-
|
|
258
|
-
if (!result) {
|
|
259
|
-
await msg.reply("Couldn't figure out that schedule. Try something like: 'lint and check every day at 9 on my-app'");
|
|
260
|
-
sessions.addMessage(msg.userId, "assistant", "Failed to parse schedule.");
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Resolve repo
|
|
265
|
-
let repo = result.repo;
|
|
266
|
-
if (!repo || !userRepos.includes(repo)) {
|
|
267
|
-
if (parsed.repo && userRepos.includes(parsed.repo)) {
|
|
268
|
-
repo = parsed.repo;
|
|
269
|
-
} else if (userRepos.length === 1) {
|
|
270
|
-
repo = userRepos[0];
|
|
271
|
-
} else {
|
|
272
|
-
const reply = `Which repo? You have: ${userRepos.join(", ")}. Say it again with 'on <repo>'.`;
|
|
273
|
-
await msg.reply(reply);
|
|
274
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const id = schedules.create({
|
|
280
|
-
userId: msg.userId,
|
|
281
|
-
repo,
|
|
282
|
-
prompt: result.prompt,
|
|
283
|
-
schedule: result.schedule,
|
|
284
|
-
description: result.description,
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
const reply = `Fine. Schedule #${id} created. I'll "${result.prompt}" on ${repo} ${result.description}. You can see all schedules with "list schedules".`;
|
|
288
|
-
await msg.reply(reply);
|
|
289
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Discuss runs inline — no queue, no worktree
|
|
294
|
-
if (parsed.type === "discuss") {
|
|
295
|
-
const history = sessions.getHistory(msg.userId, 6);
|
|
296
|
-
const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
|
|
297
|
-
|
|
298
|
-
await msg.updateStatus("Thinking...");
|
|
299
|
-
|
|
300
|
-
try {
|
|
301
|
-
const discussRunner = getRunner(config.runner?.name);
|
|
302
|
-
const result = await discussRunner.run(
|
|
303
|
-
prompt,
|
|
304
|
-
config.reposDir,
|
|
305
|
-
{ maxTurns: 5 },
|
|
306
|
-
(event) => {
|
|
307
|
-
if (event.kind === "text") {
|
|
308
|
-
msg.updateStatus(event.text.slice(0, 200));
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
const parts = splitAndReply(result.output, msg.platform);
|
|
314
|
-
for (const part of parts) {
|
|
315
|
-
await msg.reply(part);
|
|
316
|
-
}
|
|
317
|
-
sessions.addMessage(msg.userId, "assistant", result.output.slice(0, 500));
|
|
318
|
-
} catch (err) {
|
|
319
|
-
await msg.reply(`Discussion error: ${String(err).slice(0, 500)}`);
|
|
320
|
-
}
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Create-project doesn't need an existing repo
|
|
325
|
-
if (parsed.type === "create-project") {
|
|
326
|
-
const projectName = parsed.args.name;
|
|
327
|
-
const history = sessions.getHistory(msg.userId, 6);
|
|
328
|
-
const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
|
|
329
|
-
|
|
330
|
-
const taskId = queue.enqueue({
|
|
331
|
-
userId: msg.userId,
|
|
332
|
-
repo: projectName,
|
|
333
|
-
prompt,
|
|
334
|
-
taskType: "create-project",
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
pendingReplies.set(taskId, msg);
|
|
338
|
-
await msg.reply(`Nåväl. Creating "${projectName}" (${taskId.slice(0, 8)}). I'll set it up properly.`);
|
|
339
|
-
logger.info("task enqueued", { taskId, type: "create-project", name: projectName });
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Init repo — onboarding a new repo from chat
|
|
344
|
-
if (parsed.type === "init-repo") {
|
|
345
|
-
const { name, url, branch } = parsed.args;
|
|
346
|
-
|
|
347
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
348
|
-
await msg.reply("Repo name must be alphanumeric, dashes, or underscores. Try again.");
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
if (config.repos[name]) {
|
|
353
|
-
// Repo exists — just grant access
|
|
354
|
-
addUser(config, msg.userId, msg.userId, [name]);
|
|
355
|
-
const reply = `Repo "${name}" already exists. I've added you to it. Go ahead.`;
|
|
356
|
-
await msg.reply(reply);
|
|
357
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
addRepo(config, name, url, branch);
|
|
362
|
-
addUser(config, msg.userId, msg.userId, [name]);
|
|
363
|
-
const reply = `Fine. Added repo "${name}" (${url}, branch: ${branch}). You're good to go — ask me to do something on ${name}.`;
|
|
364
|
-
await msg.reply(reply);
|
|
365
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Need a repo for task commands
|
|
370
|
-
if (!parsed.repo) {
|
|
371
|
-
const userRepos = getUserRepos(config, msg.userId);
|
|
372
|
-
if (userRepos.length === 1) {
|
|
373
|
-
parsed.repo = userRepos[0];
|
|
374
|
-
} else if (userRepos.length > 1) {
|
|
375
|
-
const reply = `Which repo? You have access to: ${userRepos.join(", ")}. Pick one.`;
|
|
376
|
-
await msg.reply(reply);
|
|
377
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
378
|
-
return;
|
|
379
|
-
} else {
|
|
380
|
-
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`";
|
|
381
|
-
await msg.reply(reply);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Auth check
|
|
387
|
-
if (!isAuthorized(config, msg.userId, parsed.repo)) {
|
|
388
|
-
await msg.reply(`You're not authorized for ${parsed.repo}. I don't make the rules.`);
|
|
389
|
-
return;
|
|
153
|
+
// Main
|
|
154
|
+
async function main() {
|
|
155
|
+
const staleCount = queue.resetStale();
|
|
156
|
+
if (staleCount > 0) {
|
|
157
|
+
logger.info("reset stale tasks", { count: staleCount });
|
|
390
158
|
}
|
|
391
159
|
|
|
392
|
-
|
|
393
|
-
const repoConfig = config.repos[parsed.repo];
|
|
394
|
-
if (!repoConfig) {
|
|
395
|
-
await msg.reply(`Never heard of ${parsed.repo}. Check the config.`);
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
160
|
+
logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude" });
|
|
398
161
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
162
|
+
startGitHubSync().catch((err) =>
|
|
163
|
+
logger.warn("initial github sync failed", { error: String(err) })
|
|
164
|
+
);
|
|
402
165
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
166
|
+
const handleMessage = createMessageHandler({
|
|
167
|
+
config,
|
|
168
|
+
queue,
|
|
169
|
+
sessions,
|
|
170
|
+
schedules,
|
|
171
|
+
repoRegistry,
|
|
172
|
+
pendingReplies,
|
|
173
|
+
pendingEventReplies,
|
|
174
|
+
runningProcesses,
|
|
175
|
+
getRunner,
|
|
176
|
+
getRunnerForRepo,
|
|
177
|
+
getRepoInfo,
|
|
408
178
|
});
|
|
409
179
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (!parsed.repo) {
|
|
424
|
-
const userRepos = getUserRepos(config, event.userId);
|
|
425
|
-
if (userRepos.length === 1) {
|
|
426
|
-
parsed.repo = userRepos[0];
|
|
427
|
-
} else if ("repo" in event.source && event.source.repo) {
|
|
428
|
-
const shortName = event.source.repo.split("/").pop() || event.source.repo;
|
|
429
|
-
if (isAuthorized(config, event.userId, shortName)) {
|
|
430
|
-
parsed.repo = shortName;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (!parsed.repo) {
|
|
436
|
-
await adapter.respondToEvent(event.eventId, "Couldn't determine which repo. Configure your user in config.json.");
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (!isAuthorized(config, event.userId, parsed.repo)) {
|
|
441
|
-
await adapter.respondToEvent(event.eventId, `Not authorized for ${parsed.repo}.`);
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const repoConfig = config.repos[parsed.repo];
|
|
446
|
-
if (!repoConfig) {
|
|
447
|
-
await adapter.respondToEvent(event.eventId, `Unknown repo: ${parsed.repo}.`);
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
const prompt = buildContextualPrompt(parsed, [], OVE_PERSONA);
|
|
452
|
-
const taskId = queue.enqueue({
|
|
453
|
-
userId: event.userId,
|
|
454
|
-
repo: parsed.repo,
|
|
455
|
-
prompt,
|
|
180
|
+
const handleEvent = createEventHandler({
|
|
181
|
+
config,
|
|
182
|
+
queue,
|
|
183
|
+
sessions,
|
|
184
|
+
schedules,
|
|
185
|
+
repoRegistry,
|
|
186
|
+
pendingReplies,
|
|
187
|
+
pendingEventReplies,
|
|
188
|
+
runningProcesses,
|
|
189
|
+
getRunner,
|
|
190
|
+
getRunnerForRepo,
|
|
191
|
+
getRepoInfo,
|
|
456
192
|
});
|
|
457
193
|
|
|
458
|
-
pendingEventReplies.set(taskId, { adapter, event });
|
|
459
|
-
logger.info("event task enqueued", { taskId, eventId: event.eventId, repo: parsed.repo });
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
async function processTask(task: import("./queue").Task) {
|
|
463
|
-
const isCreateProject = task.taskType === "create-project";
|
|
464
|
-
const repoConfig = isCreateProject ? null : config.repos[task.repo];
|
|
465
|
-
|
|
466
|
-
if (!isCreateProject && !repoConfig) {
|
|
467
|
-
queue.fail(task.id, `Unknown repo: ${task.repo}`);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const originalMsg = pendingReplies.get(task.id);
|
|
472
|
-
const statusLog: string[] = [];
|
|
473
|
-
|
|
474
|
-
try {
|
|
475
|
-
// Status update
|
|
476
|
-
await originalMsg?.updateStatus(`Working on task ${task.id.slice(0, 8)}...`);
|
|
477
|
-
|
|
478
|
-
let workDir: string;
|
|
479
|
-
|
|
480
|
-
if (isCreateProject) {
|
|
481
|
-
// Create project directory under reposDir
|
|
482
|
-
workDir = join(config.reposDir, task.repo);
|
|
483
|
-
await Bun.write(join(workDir, ".gitkeep"), "");
|
|
484
|
-
} else {
|
|
485
|
-
// Ensure repo is cloned and up to date
|
|
486
|
-
await repos.cloneIfNeeded(task.repo, repoConfig!.url);
|
|
487
|
-
await repos.pull(task.repo, repoConfig!.defaultBranch);
|
|
488
|
-
|
|
489
|
-
// Create worktree
|
|
490
|
-
workDir = await repos.createWorktree(
|
|
491
|
-
task.repo,
|
|
492
|
-
task.id,
|
|
493
|
-
repoConfig!.defaultBranch
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
try {
|
|
498
|
-
// Write MCP config to temp file if configured
|
|
499
|
-
let mcpConfigPath: string | undefined;
|
|
500
|
-
if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
|
|
501
|
-
mcpConfigPath = join(tmpdir(), `mcp-${task.id}.json`);
|
|
502
|
-
await Bun.write(mcpConfigPath, JSON.stringify({ mcpServers: config.mcpServers }));
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const taskRunner = getRunnerForRepo(task.repo);
|
|
506
|
-
const runOpts = getRunnerOptsForRepo(task.repo, {
|
|
507
|
-
maxTurns: config.claude.maxTurns,
|
|
508
|
-
mcpConfigPath,
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
const result = await taskRunner.run(
|
|
512
|
-
task.prompt,
|
|
513
|
-
workDir,
|
|
514
|
-
runOpts,
|
|
515
|
-
(event: StatusEvent) => {
|
|
516
|
-
if (event.kind === "tool") {
|
|
517
|
-
statusLog.push(`${event.tool}: ${event.input}`);
|
|
518
|
-
} else {
|
|
519
|
-
statusLog.push(event.text.slice(0, 200));
|
|
520
|
-
}
|
|
521
|
-
originalMsg?.updateStatus(formatStatusLog(statusLog));
|
|
522
|
-
}
|
|
523
|
-
);
|
|
524
|
-
|
|
525
|
-
// Clean up MCP temp file
|
|
526
|
-
if (mcpConfigPath) {
|
|
527
|
-
try {
|
|
528
|
-
await unlink(mcpConfigPath);
|
|
529
|
-
} catch {}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
if (result.success) {
|
|
533
|
-
queue.complete(task.id, result.output);
|
|
534
|
-
logger.info("task completed", { taskId: task.id, durationMs: result.durationMs });
|
|
535
|
-
|
|
536
|
-
// Reply to user — split long results across messages
|
|
537
|
-
const platform = originalMsg?.platform || "slack";
|
|
538
|
-
const parts = splitAndReply(result.output, platform);
|
|
539
|
-
for (const part of parts) {
|
|
540
|
-
await originalMsg?.reply(part);
|
|
541
|
-
}
|
|
542
|
-
sessions.addMessage(task.userId, "assistant", result.output.slice(0, 500));
|
|
543
|
-
|
|
544
|
-
// Check if this was triggered by an event adapter
|
|
545
|
-
const eventReply = pendingEventReplies.get(task.id);
|
|
546
|
-
if (eventReply) {
|
|
547
|
-
await eventReply.adapter.respondToEvent(eventReply.event.eventId, result.output);
|
|
548
|
-
pendingEventReplies.delete(task.id);
|
|
549
|
-
}
|
|
550
|
-
} else {
|
|
551
|
-
queue.fail(task.id, result.output);
|
|
552
|
-
logger.error("task failed", { taskId: task.id });
|
|
553
|
-
await originalMsg?.reply(`Task failed: ${result.output.slice(0, 500)}`);
|
|
554
|
-
sessions.addMessage(task.userId, "assistant", `Task failed: ${result.output.slice(0, 200)}`);
|
|
555
|
-
|
|
556
|
-
// Notify event adapter of failure too
|
|
557
|
-
const eventReply = pendingEventReplies.get(task.id);
|
|
558
|
-
if (eventReply) {
|
|
559
|
-
await eventReply.adapter.respondToEvent(eventReply.event.eventId, `Task failed: ${result.output.slice(0, 500)}`);
|
|
560
|
-
pendingEventReplies.delete(task.id);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
} finally {
|
|
564
|
-
// Only clean up worktree for non-create-project tasks
|
|
565
|
-
if (!isCreateProject) {
|
|
566
|
-
await repos.removeWorktree(task.repo, task.id).catch(() => {});
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
} catch (err) {
|
|
570
|
-
queue.fail(task.id, String(err));
|
|
571
|
-
logger.error("task processing error", { taskId: task.id, error: String(err) });
|
|
572
|
-
await originalMsg?.reply(`Task error: ${String(err).slice(0, 500)}`);
|
|
573
|
-
} finally {
|
|
574
|
-
pendingReplies.delete(task.id);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Worker loop — polls queue every 2 seconds
|
|
579
|
-
async function workerLoop() {
|
|
580
|
-
while (true) {
|
|
581
|
-
try {
|
|
582
|
-
const task = queue.dequeue();
|
|
583
|
-
if (task) {
|
|
584
|
-
await processTask(task);
|
|
585
|
-
}
|
|
586
|
-
} catch (err) {
|
|
587
|
-
logger.error("worker loop error", { error: String(err) });
|
|
588
|
-
}
|
|
589
|
-
await Bun.sleep(2000);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// Main
|
|
594
|
-
async function main() {
|
|
595
|
-
logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude" });
|
|
596
|
-
|
|
597
194
|
for (const adapter of adapters) {
|
|
598
195
|
await adapter.start(handleMessage);
|
|
599
196
|
}
|
|
600
197
|
|
|
601
|
-
// Start event adapters
|
|
602
198
|
for (const ea of eventAdapters) {
|
|
603
199
|
await ea.start((event) => handleEvent(event, ea));
|
|
604
200
|
}
|
|
605
201
|
|
|
606
|
-
// Start cron loop — checks both config-based and user-created schedules
|
|
607
202
|
const configCron = config.cron || [];
|
|
608
203
|
startCronLoop(
|
|
609
204
|
() => [
|
|
@@ -619,18 +214,28 @@ async function main() {
|
|
|
619
214
|
queue.enqueue({
|
|
620
215
|
userId: cronTask.userId,
|
|
621
216
|
repo: cronTask.repo,
|
|
622
|
-
prompt: cronTask.prompt,
|
|
217
|
+
prompt: buildCronPrompt(cronTask.prompt),
|
|
623
218
|
});
|
|
624
219
|
}
|
|
625
220
|
);
|
|
626
221
|
logger.info("cron started", { configTasks: configCron.length });
|
|
627
222
|
|
|
628
|
-
|
|
629
|
-
|
|
223
|
+
const worker = createWorker({
|
|
224
|
+
config,
|
|
225
|
+
queue,
|
|
226
|
+
repos,
|
|
227
|
+
sessions,
|
|
228
|
+
pendingReplies,
|
|
229
|
+
pendingEventReplies,
|
|
230
|
+
runningProcesses,
|
|
231
|
+
getRunnerForRepo,
|
|
232
|
+
getRunnerOptsForRepo,
|
|
233
|
+
getRepoInfo,
|
|
234
|
+
});
|
|
235
|
+
worker.start();
|
|
630
236
|
|
|
631
237
|
logger.info("ove ready");
|
|
632
238
|
|
|
633
|
-
// Graceful shutdown
|
|
634
239
|
async function shutdown() {
|
|
635
240
|
logger.info("shutting down...");
|
|
636
241
|
for (const adapter of adapters) {
|