@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/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+)\s*$/i);
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
@@ -2,6 +2,7 @@ export interface RunOptions {
2
2
  maxTurns: number;
3
3
  mcpConfigPath?: string;
4
4
  model?: string;
5
+ signal?: AbortSignal;
5
6
  }
6
7
 
7
8
  export interface RunResult {
@@ -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 {
@@ -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 any[])
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 any[])
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: any): Schedule {
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 any[];
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
+ }