@lovenyberg/ove 0.5.2 → 0.6.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 +12 -0
- package/docs/index.html +32 -0
- package/docs/screenshot-chat.png +0 -0
- package/docs/screenshot-telegram.png +0 -0
- package/docs/screenshot-trace.png +0 -0
- package/package.json +1 -1
- package/screenshot-chat.png +0 -0
- package/screenshot-telegram.png +0 -0
- package/screenshot-trace.png +0 -0
- package/src/config.test.ts +6 -0
- package/src/config.ts +5 -1
- package/src/flows.test.ts +1 -1
- package/src/handlers.ts +75 -70
package/README.md
CHANGED
|
@@ -15,6 +15,18 @@ Talk to Ove from Slack, WhatsApp, Telegram, Discord, GitHub issues, a Web UI, or
|
|
|
15
15
|
|
|
16
16
|
**Just chat.** You don't need to memorize commands. Talk to Ove like you'd talk to a colleague — ask questions, describe what you need, paste error messages, think out loud. He understands natural language. The commands below are shortcuts, not requirements.
|
|
17
17
|
|
|
18
|
+
<p align="center">
|
|
19
|
+
<img src="screenshot-telegram.png" width="320" alt="Ove on Telegram" />
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
### Web UI
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
### Trace Viewer
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
18
30
|
## Quick Start
|
|
19
31
|
|
|
20
32
|
```bash
|
package/docs/index.html
CHANGED
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<link rel="icon" href="favicon.ico">
|
|
7
7
|
<title>Ove — Your Grumpy Dev Companion</title>
|
|
8
|
+
<meta name="description" content="AI coding agent for Slack, WhatsApp, Telegram, Discord, GitHub and CLI. Routes tasks to Claude Code or Codex in isolated worktrees.">
|
|
9
|
+
|
|
10
|
+
<!-- Open Graph -->
|
|
11
|
+
<meta property="og:type" content="website">
|
|
12
|
+
<meta property="og:url" content="https://jacksoncage.github.io/ove/">
|
|
13
|
+
<meta property="og:title" content="Ove — Your Grumpy Dev Companion">
|
|
14
|
+
<meta property="og:description" content="AI coding agent for Slack, WhatsApp, Telegram, Discord, GitHub and CLI. Routes tasks to Claude Code or Codex in isolated worktrees.">
|
|
15
|
+
<meta property="og:image" content="https://jacksoncage.github.io/ove/logo.png">
|
|
16
|
+
|
|
17
|
+
<!-- Twitter Card -->
|
|
18
|
+
<meta name="twitter:card" content="summary">
|
|
19
|
+
<meta name="twitter:title" content="Ove — Your Grumpy Dev Companion">
|
|
20
|
+
<meta name="twitter:description" content="AI coding agent for Slack, WhatsApp, Telegram, Discord, GitHub and CLI.">
|
|
21
|
+
<meta name="twitter:image" content="https://jacksoncage.github.io/ove/logo.png">
|
|
8
22
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
23
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
24
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
@@ -671,6 +685,24 @@
|
|
|
671
685
|
"People today don't even know how a worktree works. In my day we used patches. On paper."
|
|
672
686
|
<span class="attr">— Ove, probably</span>
|
|
673
687
|
</div>
|
|
688
|
+
|
|
689
|
+
<h3>Screenshots</h3>
|
|
690
|
+
<div style="display: flex; flex-direction: column; gap: 16px; margin-top: 16px;">
|
|
691
|
+
<div style="display: flex; justify-content: center;">
|
|
692
|
+
<div style="max-width: 360px;">
|
|
693
|
+
<div class="feature-label">Telegram</div>
|
|
694
|
+
<img src="screenshot-telegram.png" alt="Ove on Telegram" style="width: 100%; border: 1px solid var(--border); border-radius: 3px;">
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
<div>
|
|
698
|
+
<div class="feature-label">Web UI — Chat</div>
|
|
699
|
+
<img src="screenshot-chat.png" alt="Ove Web UI chat interface" style="width: 100%; border: 1px solid var(--border); border-radius: 3px;">
|
|
700
|
+
</div>
|
|
701
|
+
<div>
|
|
702
|
+
<div class="feature-label">Trace Viewer</div>
|
|
703
|
+
<img src="screenshot-trace.png" alt="Ove Trace Viewer" style="width: 100%; border: 1px solid var(--border); border-radius: 3px;">
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
674
706
|
</section>
|
|
675
707
|
|
|
676
708
|
<section class="section" id="getting-started">
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/config.test.ts
CHANGED
|
@@ -60,6 +60,12 @@ describe("getUserRepos", () => {
|
|
|
60
60
|
config.users["slack:U123"] = { name: "test", repos: ["a", "b"] };
|
|
61
61
|
expect(getUserRepos(config, "slack:U123")).toEqual(["a", "b"]);
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
it("returns wildcard for known user with empty repos", () => {
|
|
65
|
+
const config = loadConfig();
|
|
66
|
+
config.users["slack:U123"] = { name: "test", repos: [] };
|
|
67
|
+
expect(getUserRepos(config, "slack:U123")).toEqual(["*"]);
|
|
68
|
+
});
|
|
63
69
|
});
|
|
64
70
|
|
|
65
71
|
describe("saveConfig / addRepo / addUser", () => {
|
package/src/config.ts
CHANGED
|
@@ -79,6 +79,9 @@ export function loadConfig(): Config {
|
|
|
79
79
|
export function getUserRepos(config: Config, platformUserId: string): string[] {
|
|
80
80
|
const user = config.users[platformUserId];
|
|
81
81
|
if (!user) return [];
|
|
82
|
+
// Known users with empty repos get wildcard access — avoids
|
|
83
|
+
// silently falling back to discuss-only mode with no tracing.
|
|
84
|
+
if (user.repos.length === 0) return ["*"];
|
|
82
85
|
return user.repos;
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -86,7 +89,8 @@ export function isAuthorized(config: Config, platformUserId: string, repo?: stri
|
|
|
86
89
|
const user = config.users[platformUserId];
|
|
87
90
|
if (!user) return false;
|
|
88
91
|
if (!repo) return true;
|
|
89
|
-
|
|
92
|
+
// Empty repos = wildcard (same as getUserRepos)
|
|
93
|
+
return user.repos.length === 0 || user.repos.includes("*") || user.repos.includes(repo);
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
export function saveConfig(config: Config): void {
|
package/src/flows.test.ts
CHANGED
|
@@ -426,7 +426,7 @@ describe("Full follow-up conversation flow", () => {
|
|
|
426
426
|
const historyContext = history.length > 1
|
|
427
427
|
? "Recent conversation:\n" + history.slice(0, -1).map(m => `${m.role}: ${m.content}`).join("\n") + "\n\n"
|
|
428
428
|
: "";
|
|
429
|
-
const resolvePrompt = `You are a repo-name resolver. ${historyContext}The user's latest message:\n"what about tomorrow"\n\nAvailable repos: iris, docs, my-app\n\nRespond with ONLY the repo name that best matches their request. Consider the conversation context if the current message doesn't mention a specific repo. Nothing else — just the exact repo name from the list. If you cannot determine which repo, respond with "UNKNOWN".`;
|
|
429
|
+
const resolvePrompt = `You are a repo-name resolver. ${historyContext}The user's latest message:\n"what about tomorrow"\n\nAvailable repos: iris, docs, my-app\n\nRespond with ONLY the repo name that best matches their request. Consider the conversation context if the current message doesn't mention a specific repo. Nothing else — just the exact repo name from the list. If the question doesn't need a specific repo (e.g. "list my open PRs", "what should I work on today", cross-repo queries, general questions about the user's GitHub activity), respond with "NONE". If you cannot determine which specific repo, respond with "UNKNOWN".`;
|
|
430
430
|
|
|
431
431
|
expect(resolvePrompt).toContain("Recent conversation:");
|
|
432
432
|
expect(resolvePrompt).toContain("check the roadmap on iris");
|
package/src/handlers.ts
CHANGED
|
@@ -76,20 +76,68 @@ export function splitAndReply(text: string, platform: string): string[] {
|
|
|
76
76
|
return parts;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function getUserRepoNames(userId: string, deps: HandlerDeps): string[] {
|
|
80
|
+
const userRepos = getUserRepos(deps.config, userId);
|
|
81
|
+
if (userRepos.includes("*")) return deps.repoRegistry.getAllNames();
|
|
82
|
+
return userRepos;
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
function resolveRepo(userId: string, hint: string | undefined, deps: HandlerDeps): string | null {
|
|
80
86
|
if (hint && deps.getRepoInfo(hint)) return hint;
|
|
87
|
+
const repoNames = getUserRepoNames(userId, deps);
|
|
88
|
+
if (repoNames.length === 1) return repoNames[0];
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
81
91
|
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
type RepoResolution =
|
|
93
|
+
| { kind: "resolved"; repo: string }
|
|
94
|
+
| { kind: "none" }
|
|
95
|
+
| { kind: "unknown"; repoNames: string[] }
|
|
96
|
+
| { kind: "error"; message: string };
|
|
97
|
+
|
|
98
|
+
async function resolveRepoWithLLM(
|
|
99
|
+
userId: string,
|
|
100
|
+
rawText: string,
|
|
101
|
+
hint: string | undefined,
|
|
102
|
+
deps: HandlerDeps,
|
|
103
|
+
onStatus?: (text: string) => void,
|
|
104
|
+
): Promise<RepoResolution> {
|
|
105
|
+
if (hint && deps.getRepoInfo(hint)) return { kind: "resolved", repo: hint };
|
|
106
|
+
|
|
107
|
+
const repoNames = getUserRepoNames(userId, deps);
|
|
108
|
+
if (repoNames.length === 0) return { kind: "error", message: "No repos discovered yet. Set one up with `init repo <name> <git-url>` or configure GitHub sync." };
|
|
109
|
+
if (repoNames.length === 1) return { kind: "resolved", repo: repoNames[0] };
|
|
110
|
+
|
|
111
|
+
const history = deps.sessions.getHistory(userId, 6);
|
|
112
|
+
const recentTasks = deps.queue.listByUser(userId, 5);
|
|
113
|
+
const lastRepo = recentTasks.find(t => t.status === "completed" || t.status === "failed")?.repo;
|
|
114
|
+
const historyContext = history.length > 1
|
|
115
|
+
? "Recent conversation:\n" + history.slice(0, -1).map(m => `${m.role}: ${m.content}`).join("\n") + "\n\n"
|
|
116
|
+
: "";
|
|
117
|
+
const lastRepoHint = lastRepo && repoNames.includes(lastRepo)
|
|
118
|
+
? `The user's most recent task was on repo "${lastRepo}", but only use this if the conversation context supports it.\n\n`
|
|
119
|
+
: "";
|
|
120
|
+
const resolvePrompt = `You are a repo-name resolver. ${historyContext}${lastRepoHint}The user's latest message:\n"${rawText}"\n\nAvailable repos: ${repoNames.join(", ")}\n\nRespond with ONLY the repo name that best matches their request. Consider the conversation context if the current message doesn't mention a specific repo. Nothing else — just the exact repo name from the list. If the question doesn't need a specific repo (e.g. "list my open PRs", "what should I work on today", cross-repo queries, general questions about the user's GitHub activity), respond with "NONE". If you cannot determine which specific repo, respond with "UNKNOWN".`;
|
|
121
|
+
|
|
122
|
+
onStatus?.("Figuring out which repo...");
|
|
123
|
+
try {
|
|
124
|
+
const runner = deps.getRunner(deps.config.runner?.name);
|
|
125
|
+
const result = await runner.run(resolvePrompt, deps.config.reposDir, { maxTurns: 1 });
|
|
126
|
+
const resolved = result.output.trim().replace(/[`"']/g, "");
|
|
84
127
|
|
|
85
|
-
|
|
128
|
+
if (resolved === "NONE") {
|
|
129
|
+
logger.info("repo resolver returned NONE — falling back to discuss", { userText: rawText.slice(0, 80) });
|
|
130
|
+
return { kind: "none" };
|
|
131
|
+
}
|
|
132
|
+
if (resolved === "UNKNOWN" || !repoNames.includes(resolved)) {
|
|
133
|
+
return { kind: "unknown", repoNames };
|
|
134
|
+
}
|
|
86
135
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
136
|
+
logger.info("repo resolved via LLM", { resolved, userText: rawText.slice(0, 80) });
|
|
137
|
+
return { kind: "resolved", repo: resolved };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { kind: "error", message: `Couldn't figure out the repo: ${String(err).slice(0, 300)}` };
|
|
90
140
|
}
|
|
91
|
-
|
|
92
|
-
return null;
|
|
93
141
|
}
|
|
94
142
|
|
|
95
143
|
// --- Individual command handlers ---
|
|
@@ -284,8 +332,7 @@ async function handleTrace(msg: IncomingMessage, args: Record<string, any>, deps
|
|
|
284
332
|
|
|
285
333
|
async function handleSchedule(msg: IncomingMessage, parsedRepo: string | undefined, deps: HandlerDeps) {
|
|
286
334
|
await msg.updateStatus("Parsing your schedule...");
|
|
287
|
-
const
|
|
288
|
-
const userRepos = rawRepos.includes("*") ? deps.repoRegistry.getAllNames() : rawRepos;
|
|
335
|
+
const userRepos = getUserRepoNames(msg.userId, deps);
|
|
289
336
|
|
|
290
337
|
if (userRepos.length === 0) {
|
|
291
338
|
await msg.reply("You don't have access to any repos. Set one up first with `init repo <name> <git-url>`.");
|
|
@@ -396,77 +443,35 @@ async function handleInitRepo(msg: IncomingMessage, args: Record<string, any>, d
|
|
|
396
443
|
}
|
|
397
444
|
|
|
398
445
|
async function handleTaskMessage(msg: IncomingMessage, parsed: ParsedMessage, deps: HandlerDeps) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
parsed.repo = undefined;
|
|
402
|
-
}
|
|
446
|
+
const hint = parsed.repo && deps.getRepoInfo(parsed.repo) ? parsed.repo : undefined;
|
|
447
|
+
const resolution = await resolveRepoWithLLM(msg.userId, parsed.rawText, hint, deps, (text) => msg.updateStatus(text));
|
|
403
448
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
if (repoNames.length === 1) {
|
|
414
|
-
parsed.repo = repoNames[0];
|
|
415
|
-
} else if (repoNames.length === 0) {
|
|
416
|
-
const reply = "No repos discovered yet. Set one up with `init repo <name> <git-url>` or configure GitHub sync.";
|
|
417
|
-
await msg.reply(reply);
|
|
418
|
-
return;
|
|
419
|
-
} else {
|
|
420
|
-
// Try last completed task's repo first (cheap)
|
|
421
|
-
const recentTasks = deps.queue.listByUser(msg.userId, 5);
|
|
422
|
-
const lastRepo = recentTasks.find(t => t.status === "completed" || t.status === "failed")?.repo;
|
|
423
|
-
if (lastRepo && repoNames.includes(lastRepo)) {
|
|
424
|
-
parsed.repo = lastRepo;
|
|
425
|
-
logger.info("repo resolved from recent task", { resolved: lastRepo, userText: parsed.rawText.slice(0, 80) });
|
|
426
|
-
} else {
|
|
427
|
-
// Resolve repo via a quick LLM call, then enqueue through the normal path
|
|
428
|
-
const repoList = repoNames.join(", ");
|
|
429
|
-
const history = deps.sessions.getHistory(msg.userId, 6);
|
|
430
|
-
const historyContext = history.length > 1
|
|
431
|
-
? "Recent conversation:\n" + history.slice(0, -1).map(m => `${m.role}: ${m.content}`).join("\n") + "\n\n"
|
|
432
|
-
: "";
|
|
433
|
-
const resolvePrompt = `You are a repo-name resolver. ${historyContext}The user's latest message:\n"${parsed.rawText}"\n\nAvailable repos: ${repoList}\n\nRespond with ONLY the repo name that best matches their request. Consider the conversation context if the current message doesn't mention a specific repo. Nothing else — just the exact repo name from the list. If you cannot determine which repo, respond with "UNKNOWN".`;
|
|
434
|
-
|
|
435
|
-
await msg.updateStatus("Figuring out which repo...");
|
|
436
|
-
try {
|
|
437
|
-
const runner = deps.getRunner(deps.config.runner?.name);
|
|
438
|
-
const result = await runner.run(resolvePrompt, deps.config.reposDir, { maxTurns: 1 });
|
|
439
|
-
const resolved = result.output.trim().replace(/[`"']/g, "");
|
|
440
|
-
|
|
441
|
-
if (resolved === "UNKNOWN" || !repoNames.includes(resolved)) {
|
|
442
|
-
const reply = `Which repo? I see ${repoNames.length} repos. Some matches: ${repoNames.slice(0, 10).join(", ")}${repoNames.length > 10 ? "..." : ""}.\nSay it again with 'on <repo>'.`;
|
|
443
|
-
await msg.reply(reply);
|
|
444
|
-
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
parsed.repo = resolved;
|
|
449
|
-
logger.info("repo resolved via LLM", { resolved, userText: parsed.rawText.slice(0, 80) });
|
|
450
|
-
} catch (err) {
|
|
451
|
-
await msg.reply(`Couldn't figure out the repo: ${String(err).slice(0, 300)}`);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
} else {
|
|
457
|
-
const reply = `No repos configured yet. Add one with:\n\`init repo <name> <git-url> [branch]\`\nExample: \`init repo my-app git@github.com:user/my-app.git\``;
|
|
449
|
+
switch (resolution.kind) {
|
|
450
|
+
case "none": {
|
|
451
|
+
const history = deps.sessions.getHistory(msg.userId, 6);
|
|
452
|
+
return handleDiscuss(msg, { ...parsed, type: "free-form" }, history, deps);
|
|
453
|
+
}
|
|
454
|
+
case "unknown": {
|
|
455
|
+
const { repoNames } = resolution;
|
|
456
|
+
const reply = `Which repo? I see ${repoNames.length} repos. Some matches: ${repoNames.slice(0, 10).join(", ")}${repoNames.length > 10 ? "..." : ""}.\nSay it again with 'on <repo>'.`;
|
|
458
457
|
await msg.reply(reply);
|
|
458
|
+
deps.sessions.addMessage(msg.userId, "assistant", reply);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
case "error": {
|
|
462
|
+
await msg.reply(resolution.message);
|
|
459
463
|
return;
|
|
460
464
|
}
|
|
461
465
|
}
|
|
462
466
|
|
|
467
|
+
parsed.repo = resolution.repo;
|
|
468
|
+
|
|
463
469
|
if (!isAuthorized(deps.config, msg.userId, parsed.repo)) {
|
|
464
470
|
await msg.reply(`Not authorized for ${parsed.repo}.`);
|
|
465
471
|
return;
|
|
466
472
|
}
|
|
467
473
|
|
|
468
|
-
|
|
469
|
-
if (!repoInfo) {
|
|
474
|
+
if (!deps.getRepoInfo(parsed.repo)) {
|
|
470
475
|
await msg.reply(`Unknown repo: ${parsed.repo}`);
|
|
471
476
|
return;
|
|
472
477
|
}
|