@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 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
+ ![Ove Web UI](screenshot-chat.png)
25
+
26
+ ### Trace Viewer
27
+
28
+ ![Ove Trace Viewer](screenshot-trace.png)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovenyberg/ove",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Your grumpy but meticulous dev companion. AI coding agent for Slack, WhatsApp, Telegram, Discord, GitHub, HTTP API, and CLI.",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
Binary file
Binary file
@@ -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
- return user.repos.includes("*") || user.repos.includes(repo);
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
- const userRepos = getUserRepos(deps.config, userId);
83
- const hasWildcard = userRepos.includes("*");
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
- if (!hasWildcard && userRepos.length === 1) return userRepos[0];
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
- if (hasWildcard || userRepos.length > 1) {
88
- const repoNames = hasWildcard ? deps.repoRegistry.getAllNames() : userRepos;
89
- if (repoNames.length === 1) return repoNames[0];
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 rawRepos = getUserRepos(deps.config, msg.userId);
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
- // If the repo hint doesn't match a known repo, clear it and let auto-resolution handle it
400
- if (parsed.repo && !deps.getRepoInfo(parsed.repo)) {
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
- if (!parsed.repo) {
405
- const userRepos = getUserRepos(deps.config, msg.userId);
406
- const hasWildcard = userRepos.includes("*");
407
-
408
- if (!hasWildcard && userRepos.length === 1) {
409
- parsed.repo = userRepos[0];
410
- } else if (hasWildcard || userRepos.length > 1) {
411
- const repoNames = hasWildcard ? deps.repoRegistry.getAllNames() : userRepos;
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
- const repoInfo = deps.getRepoInfo(parsed.repo);
469
- if (!repoInfo) {
474
+ if (!deps.getRepoInfo(parsed.repo)) {
470
475
  await msg.reply(`Unknown repo: ${parsed.repo}`);
471
476
  return;
472
477
  }