@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/router.ts
CHANGED
|
@@ -13,7 +13,9 @@ export type MessageType =
|
|
|
13
13
|
| "clear"
|
|
14
14
|
| "schedule"
|
|
15
15
|
| "list-schedules"
|
|
16
|
-
| "remove-schedule"
|
|
16
|
+
| "remove-schedule"
|
|
17
|
+
| "list-tasks"
|
|
18
|
+
| "cancel-task";
|
|
17
19
|
|
|
18
20
|
export interface ParsedMessage {
|
|
19
21
|
type: MessageType;
|
|
@@ -26,11 +28,26 @@ export function parseMessage(text: string): ParsedMessage {
|
|
|
26
28
|
const trimmed = text.trim();
|
|
27
29
|
const lower = trimmed.toLowerCase();
|
|
28
30
|
|
|
31
|
+
// Handle Telegram /commands — strip leading slash
|
|
32
|
+
if (lower === "/start") return { type: "help", args: {}, rawText: trimmed };
|
|
33
|
+
if (lower === "/help") return { type: "help", args: {}, rawText: trimmed };
|
|
34
|
+
if (lower === "/status") return { type: "status", args: {}, rawText: trimmed };
|
|
35
|
+
if (lower === "/history") return { type: "history", args: {}, rawText: trimmed };
|
|
36
|
+
if (lower === "/clear") return { type: "clear", args: {}, rawText: trimmed };
|
|
37
|
+
|
|
29
38
|
if (lower === "status") return { type: "status", args: {}, rawText: trimmed };
|
|
30
39
|
if (lower === "history" || lower === "my tasks") return { type: "history", args: {}, rawText: trimmed };
|
|
31
40
|
if (lower === "help") return { type: "help", args: {}, rawText: trimmed };
|
|
32
41
|
if (lower === "clear" || lower === "reset") return { type: "clear", args: {}, rawText: trimmed };
|
|
33
42
|
|
|
43
|
+
// Task management
|
|
44
|
+
if (lower === "tasks" || lower === "/tasks") return { type: "list-tasks", args: {}, rawText: trimmed };
|
|
45
|
+
const cancelMatch = trimmed.match(/^(?:\/)?cancel\s+(\S+)$/i);
|
|
46
|
+
if (cancelMatch) return { type: "cancel-task", args: { taskId: cancelMatch[1] }, rawText: trimmed };
|
|
47
|
+
|
|
48
|
+
// Natural language status inquiries — short messages asking about progress
|
|
49
|
+
if (isStatusInquiry(lower)) return { type: "status", args: {}, rawText: trimmed };
|
|
50
|
+
|
|
34
51
|
const prMatch = trimmed.match(/review\s+pr\s+#?(\d+)\s+(?:on|in)\s+(\S+)/i);
|
|
35
52
|
if (prMatch) {
|
|
36
53
|
return { type: "review-pr", repo: prMatch[2], args: { prNumber: parseInt(prMatch[1]) }, rawText: trimmed };
|
|
@@ -70,7 +87,7 @@ export function parseMessage(text: string): ParsedMessage {
|
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
// Schedule creation — detect natural language scheduling intent
|
|
73
|
-
if (/\b(?:every\s+(?:day|week|month|monday|tuesday|wednesday|thursday|friday|saturday|sunday|weekday|morning|evening)|each\s+(?:day|week|weekday)|daily|weekly|monthly)\b/i.test(lower) ||
|
|
90
|
+
if (/\b(?:every\s+(?:day|week|month|monday|tuesday|wednesday|thursday|friday|saturday|sunday|weekday|morning|evening|hour|(?:\d+\s+)?(?:min(?:ute)?s?|hours?))|each\s+(?:day|week|weekday)|daily|weekly|monthly)\b/i.test(lower) ||
|
|
74
91
|
/\b(?:at\s+\d{1,2}(?::\d{2})?)\b.*\b(?:every|each|daily|weekly)\b/i.test(lower)) {
|
|
75
92
|
const repoHint = trimmed.match(/(?:in|on)\s+(\S+)\s*$/i);
|
|
76
93
|
return { type: "schedule", repo: repoHint?.[1], args: {}, rawText: trimmed };
|
|
@@ -95,10 +112,36 @@ export function parseMessage(text: string): ParsedMessage {
|
|
|
95
112
|
};
|
|
96
113
|
}
|
|
97
114
|
|
|
98
|
-
const repoHint = trimmed.match(/(?:in|on)\s+(\S
|
|
115
|
+
const repoHint = trimmed.match(/(?:in|on)\s+(\S+?)[?.!,]*\s*$/i);
|
|
99
116
|
return { type: "free-form", repo: repoHint?.[1], args: {}, rawText: trimmed };
|
|
100
117
|
}
|
|
101
118
|
|
|
119
|
+
// Detect natural language status/progress inquiries.
|
|
120
|
+
// Only matches short, question-like messages — not work requests that happen
|
|
121
|
+
// to contain words like "progress" or "status" (e.g. "fix the status page").
|
|
122
|
+
const STATUS_PATTERNS = [
|
|
123
|
+
/^(?:how.s it going|how.?s .* going)/, // how's it going, how is the work going
|
|
124
|
+
/^(?:any )?update[s]?\??$/, // updates?, any updates?
|
|
125
|
+
/^(?:are you |you |is it )done/, // are you done?, is it done
|
|
126
|
+
/^done\s*(?:yet)?\??$/, // done?, done yet?
|
|
127
|
+
/^(?:what.s the |what is the )?progress\??$/, // progress?, what's the progress
|
|
128
|
+
/^how (?:far|much)/, // how far along, how much left
|
|
129
|
+
/^(?:how.s|what.s|how is) (?:the )?(?:progress|status|work)/, // how's the progress/status/work
|
|
130
|
+
/^(?:is it |are you )(?:still )?(?:working|running|busy)/, // is it still running
|
|
131
|
+
/^(?:how do(?:se|es)? (?:it )?(?:the )?work go|how (?:do(?:se|es)? )?(?:the )?work go)/, // how does the work go (typo-tolerant)
|
|
132
|
+
/^(?:eta|when (?:will|are) (?:you|it) (?:be )?done)/, // eta, when will you be done
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
function isStatusInquiry(lower: string): boolean {
|
|
136
|
+
// Only consider short messages (< 60 chars) to avoid matching work requests
|
|
137
|
+
if (lower.length > 60) return false;
|
|
138
|
+
return STATUS_PATTERNS.some((p) => p.test(lower));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function buildCronPrompt(prompt: string): string {
|
|
142
|
+
return `This is an autonomous scheduled task. Do not ask questions — make your own decisions and proceed with the work. If there are multiple options, pick the best one and go.\n\n${prompt}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
102
145
|
export function buildContextualPrompt(
|
|
103
146
|
parsed: ParsedMessage,
|
|
104
147
|
history: { role: string; content: string }[],
|
package/src/runner.ts
CHANGED
package/src/runners/claude.ts
CHANGED
|
@@ -53,6 +53,10 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
53
53
|
env: { ...process.env, CI: "1" },
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
if (opts.signal) {
|
|
57
|
+
opts.signal.addEventListener("abort", () => proc.kill(), { once: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
56
60
|
let resultText: string | null = null;
|
|
57
61
|
let lastTextBlock: string | null = null;
|
|
58
62
|
const decoder = new TextDecoder();
|
|
@@ -84,7 +88,9 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
|
-
} catch {
|
|
91
|
+
} catch {
|
|
92
|
+
// Non-JSON line in stream output — skip
|
|
93
|
+
}
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
96
|
} finally {
|
package/src/runners/codex.ts
CHANGED
|
@@ -79,6 +79,10 @@ export class CodexRunner implements AgentRunner {
|
|
|
79
79
|
stderr: "pipe",
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
if (opts.signal) {
|
|
83
|
+
opts.signal.addEventListener("abort", () => proc.kill(), { once: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
let lastAgentMessage: string | null = null;
|
|
83
87
|
let errorMessage: string | null = null;
|
|
84
88
|
const decoder = new TextDecoder();
|
|
@@ -112,7 +116,9 @@ export class CodexRunner implements AgentRunner {
|
|
|
112
116
|
if (event.type === "turn.failed") {
|
|
113
117
|
errorMessage = event.error?.message || "Turn failed";
|
|
114
118
|
}
|
|
115
|
-
} catch {
|
|
119
|
+
} catch {
|
|
120
|
+
// Non-JSON line in stream output — skip
|
|
121
|
+
}
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
124
|
} finally {
|
package/src/schedules.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
|
|
3
|
+
interface ScheduleRow {
|
|
4
|
+
id: number;
|
|
5
|
+
user_id: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
prompt: string;
|
|
8
|
+
schedule: string;
|
|
9
|
+
description: string | null;
|
|
10
|
+
created_at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export interface Schedule {
|
|
4
14
|
id: number;
|
|
5
15
|
userId: string;
|
|
@@ -45,7 +55,7 @@ export class ScheduleStore {
|
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
listByUser(userId: string): Schedule[] {
|
|
48
|
-
return (this.db.query(`SELECT * FROM schedules WHERE user_id = ? ORDER BY id`).all(userId) as
|
|
58
|
+
return (this.db.query(`SELECT * FROM schedules WHERE user_id = ? ORDER BY id`).all(userId) as ScheduleRow[])
|
|
49
59
|
.map(this.rowToSchedule);
|
|
50
60
|
}
|
|
51
61
|
|
|
@@ -55,11 +65,11 @@ export class ScheduleStore {
|
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
getAll(): Schedule[] {
|
|
58
|
-
return (this.db.query(`SELECT * FROM schedules ORDER BY id`).all() as
|
|
68
|
+
return (this.db.query(`SELECT * FROM schedules ORDER BY id`).all() as ScheduleRow[])
|
|
59
69
|
.map(this.rowToSchedule);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
private rowToSchedule(row:
|
|
72
|
+
private rowToSchedule(row: ScheduleRow): Schedule {
|
|
63
73
|
return {
|
|
64
74
|
id: row.id,
|
|
65
75
|
userId: row.user_id,
|
package/src/sessions.ts
CHANGED
|
@@ -6,6 +6,12 @@ export interface ChatMessage {
|
|
|
6
6
|
timestamp: string;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
interface ChatMessageRow {
|
|
10
|
+
role: string;
|
|
11
|
+
content: string;
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
export class SessionStore {
|
|
10
16
|
private db: Database;
|
|
11
17
|
|
|
@@ -38,12 +44,12 @@ export class SessionStore {
|
|
|
38
44
|
ORDER BY id DESC
|
|
39
45
|
LIMIT ?`
|
|
40
46
|
)
|
|
41
|
-
.all(userId, limit) as
|
|
47
|
+
.all(userId, limit) as ChatMessageRow[];
|
|
42
48
|
|
|
43
49
|
return rows
|
|
44
50
|
.reverse()
|
|
45
51
|
.map((r) => ({
|
|
46
|
-
role: r.role,
|
|
52
|
+
role: r.role as "user" | "assistant",
|
|
47
53
|
content: r.content,
|
|
48
54
|
timestamp: r.created_at,
|
|
49
55
|
}));
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
4
|
+
import { logger } from "./logger";
|
|
5
|
+
import { splitAndReply } from "./handlers";
|
|
6
|
+
import type { Config } from "./config";
|
|
7
|
+
import type { TaskQueue, Task } from "./queue";
|
|
8
|
+
import type { RepoManager } from "./repos";
|
|
9
|
+
import type { SessionStore } from "./sessions";
|
|
10
|
+
import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/types";
|
|
11
|
+
import type { AgentRunner, RunOptions, StatusEvent } from "./runner";
|
|
12
|
+
|
|
13
|
+
export interface WorkerDeps {
|
|
14
|
+
config: Config;
|
|
15
|
+
queue: TaskQueue;
|
|
16
|
+
repos: RepoManager;
|
|
17
|
+
sessions: SessionStore;
|
|
18
|
+
pendingReplies: Map<string, IncomingMessage>;
|
|
19
|
+
pendingEventReplies: Map<string, { adapter: EventAdapter; event: IncomingEvent }>;
|
|
20
|
+
runningProcesses: Map<string, { abort: AbortController; task: Task }>;
|
|
21
|
+
getRunnerForRepo: (repo: string) => AgentRunner;
|
|
22
|
+
getRunnerOptsForRepo: (repo: string, baseOpts: RunOptions) => RunOptions;
|
|
23
|
+
getRepoInfo: (repoName: string) => { url: string; defaultBranch: string } | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function processTask(task: Task, deps: WorkerDeps) {
|
|
27
|
+
const isCreateProject = task.taskType === "create-project";
|
|
28
|
+
const repoInfo = isCreateProject ? null : deps.getRepoInfo(task.repo);
|
|
29
|
+
|
|
30
|
+
if (!isCreateProject && !repoInfo) {
|
|
31
|
+
deps.queue.fail(task.id, `Unknown repo: ${task.repo}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const abortController = new AbortController();
|
|
36
|
+
deps.runningProcesses.set(task.id, { abort: abortController, task });
|
|
37
|
+
|
|
38
|
+
const originalMsg = deps.pendingReplies.get(task.id);
|
|
39
|
+
const statusLog: string[] = [];
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await originalMsg?.updateStatus(`Working on it...`);
|
|
43
|
+
|
|
44
|
+
let workDir: string;
|
|
45
|
+
|
|
46
|
+
if (isCreateProject) {
|
|
47
|
+
workDir = join(deps.config.reposDir, task.repo);
|
|
48
|
+
await Bun.write(join(workDir, ".gitkeep"), "");
|
|
49
|
+
} else {
|
|
50
|
+
await deps.repos.cloneIfNeeded(task.repo, repoInfo!.url);
|
|
51
|
+
await deps.repos.pull(task.repo, repoInfo!.defaultBranch);
|
|
52
|
+
|
|
53
|
+
workDir = await deps.repos.createWorktree(
|
|
54
|
+
task.repo,
|
|
55
|
+
task.id,
|
|
56
|
+
repoInfo!.defaultBranch
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
let mcpConfigPath: string | undefined;
|
|
62
|
+
if (deps.config.mcpServers && Object.keys(deps.config.mcpServers).length > 0) {
|
|
63
|
+
mcpConfigPath = join(tmpdir(), `mcp-${task.id}.json`);
|
|
64
|
+
await Bun.write(mcpConfigPath, JSON.stringify({ mcpServers: deps.config.mcpServers }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const taskRunner = deps.getRunnerForRepo(task.repo);
|
|
68
|
+
const runOpts = deps.getRunnerOptsForRepo(task.repo, {
|
|
69
|
+
maxTurns: deps.config.claude.maxTurns,
|
|
70
|
+
mcpConfigPath,
|
|
71
|
+
signal: abortController.signal,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const result = await taskRunner.run(
|
|
75
|
+
task.prompt,
|
|
76
|
+
workDir,
|
|
77
|
+
runOpts,
|
|
78
|
+
(event: StatusEvent) => {
|
|
79
|
+
if (event.kind === "tool") {
|
|
80
|
+
const last = statusLog[statusLog.length - 1];
|
|
81
|
+
const summary = `Using ${event.tool}...`;
|
|
82
|
+
if (last !== summary) statusLog.push(summary);
|
|
83
|
+
} else {
|
|
84
|
+
statusLog.push(event.text.slice(0, 200));
|
|
85
|
+
}
|
|
86
|
+
originalMsg?.updateStatus(statusLog.slice(-5).join("\n"));
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (mcpConfigPath) {
|
|
91
|
+
try {
|
|
92
|
+
await unlink(mcpConfigPath);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
logger.debug("failed to clean up mcp config", { error: String(err) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (result.success) {
|
|
99
|
+
deps.queue.complete(task.id, result.output);
|
|
100
|
+
logger.info("task completed", { taskId: task.id, durationMs: result.durationMs });
|
|
101
|
+
|
|
102
|
+
const platform = originalMsg?.platform || "slack";
|
|
103
|
+
const parts = splitAndReply(result.output, platform);
|
|
104
|
+
for (const part of parts) {
|
|
105
|
+
await originalMsg?.reply(part);
|
|
106
|
+
}
|
|
107
|
+
deps.sessions.addMessage(task.userId, "assistant", result.output.slice(0, 500));
|
|
108
|
+
|
|
109
|
+
const eventReply = deps.pendingEventReplies.get(task.id);
|
|
110
|
+
if (eventReply) {
|
|
111
|
+
await eventReply.adapter.respondToEvent(eventReply.event.eventId, result.output);
|
|
112
|
+
deps.pendingEventReplies.delete(task.id);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
deps.queue.fail(task.id, result.output);
|
|
116
|
+
logger.error("task failed", { taskId: task.id });
|
|
117
|
+
await originalMsg?.reply(`Task failed: ${result.output.slice(0, 500)}`);
|
|
118
|
+
deps.sessions.addMessage(task.userId, "assistant", `Task failed: ${result.output.slice(0, 200)}`);
|
|
119
|
+
|
|
120
|
+
const eventReply = deps.pendingEventReplies.get(task.id);
|
|
121
|
+
if (eventReply) {
|
|
122
|
+
await eventReply.adapter.respondToEvent(eventReply.event.eventId, `Task failed: ${result.output.slice(0, 500)}`);
|
|
123
|
+
deps.pendingEventReplies.delete(task.id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
if (!isCreateProject) {
|
|
128
|
+
await deps.repos.removeWorktree(task.repo, task.id).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
deps.queue.fail(task.id, String(err));
|
|
133
|
+
logger.error("task processing error", { taskId: task.id, error: String(err) });
|
|
134
|
+
await originalMsg?.reply(`Task error: ${String(err).slice(0, 500)}`);
|
|
135
|
+
} finally {
|
|
136
|
+
deps.runningProcesses.delete(task.id);
|
|
137
|
+
deps.pendingReplies.delete(task.id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createWorker(deps: WorkerDeps): { start: () => void; cancel: (id: string) => boolean } {
|
|
142
|
+
async function workerLoop() {
|
|
143
|
+
const maxConcurrent = 5;
|
|
144
|
+
|
|
145
|
+
while (true) {
|
|
146
|
+
if (deps.runningProcesses.size < maxConcurrent) {
|
|
147
|
+
try {
|
|
148
|
+
const task = deps.queue.dequeue();
|
|
149
|
+
if (task) {
|
|
150
|
+
processTask(task, deps).catch((err) =>
|
|
151
|
+
logger.error("worker task error", { taskId: task.id, error: String(err) })
|
|
152
|
+
);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
logger.error("worker loop error", { error: String(err) });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
await Bun.sleep(2000);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
start: () => { workerLoop(); },
|
|
165
|
+
cancel: (id: string) => {
|
|
166
|
+
const entry = deps.runningProcesses.get(id);
|
|
167
|
+
if (!entry) return false;
|
|
168
|
+
entry.abort.abort();
|
|
169
|
+
deps.queue.cancel(entry.task.id);
|
|
170
|
+
return true;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|