@lovenyberg/ove 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovenyberg/ove",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": {
package/src/handlers.ts CHANGED
@@ -6,7 +6,7 @@ import { parseSchedule } from "./schedule-parser";
6
6
  import { logger } from "./logger";
7
7
  import type { Config } from "./config";
8
8
  import type { TaskQueue, Task } from "./queue";
9
- import type { SessionStore } from "./sessions";
9
+ import type { SessionStore, UserMode } from "./sessions";
10
10
  import type { ScheduleStore } from "./schedules";
11
11
  import type { RepoRegistry } from "./repo-registry";
12
12
  import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/types";
@@ -42,6 +42,19 @@ Keep the personality subtle in code output — don't let it interfere with code
42
42
 
43
43
  export { OVE_PERSONA };
44
44
 
45
+ const ASSISTANT_ADDENDUM = `IMPORTANT MODE OVERRIDE: You are currently in "assistant mode". The user has asked you to be a general-purpose assistant. While you keep your Ove personality (grumble, be direct, sprinkle Swedish), you are now willing to help with ANY request — not just code. This includes:
46
+ - Sending reminders, drafting emails/messages
47
+ - Answering general knowledge questions
48
+ - Helping with personal tasks, recommendations
49
+ - Anything the user asks
50
+
51
+ You still grumble about it ("Fan, nu ska jag vara sekreterare också...") but you DO the task. If you genuinely cannot do something (no tool/integration available), explain what would be needed rather than just refusing.`;
52
+
53
+ function getPersona(userId: string, deps: HandlerDeps): string {
54
+ const mode = deps.sessions.getMode(userId);
55
+ return mode === "assistant" ? OVE_PERSONA + "\n\n" + ASSISTANT_ADDENDUM : OVE_PERSONA;
56
+ }
57
+
45
58
  const PLATFORM_FORMAT_HINTS: Record<string, string> = {
46
59
  telegram: "Format output for Telegram: use *bold* for emphasis, `code` for inline code, ```code blocks```. No markdown tables. Use simple bulleted lists with • instead. Keep it concise.",
47
60
  slack: "Format output for Slack: use *bold*, no markdown tables. Use simple bulleted lists with • instead. Keep it concise.",
@@ -147,6 +160,26 @@ async function handleClear(msg: IncomingMessage, deps: HandlerDeps) {
147
160
  await msg.reply("Conversation cleared.");
148
161
  }
149
162
 
163
+ async function handleSetMode(msg: IncomingMessage, args: Record<string, any>, deps: HandlerDeps) {
164
+ const mode = args.mode;
165
+ if (mode !== "assistant" && mode !== "strict") {
166
+ await msg.reply(`Unknown mode "${String(mode)}". Use "mode assistant" or "mode strict".`);
167
+ return;
168
+ }
169
+ try {
170
+ deps.sessions.setMode(msg.userId, mode);
171
+ } catch (err) {
172
+ logger.error("failed to set user mode", { userId: msg.userId, mode, error: String(err) });
173
+ await msg.reply("Failed to save mode. Try again.");
174
+ return;
175
+ }
176
+ const reply = mode === "assistant"
177
+ ? "Mja, fine. Assistant mode. Jag hjälper dig med vad du vill. Men klaga inte om resultatet."
178
+ : "Back to code mode. Äntligen. Riktigt arbete.";
179
+ await msg.reply(reply);
180
+ deps.sessions.addMessage(msg.userId, "assistant", reply);
181
+ }
182
+
150
183
  async function handleStatus(msg: IncomingMessage, deps: HandlerDeps) {
151
184
  const userTasks = deps.queue.listByUser(msg.userId, 5);
152
185
  const running = userTasks.find((t) => t.status === "running");
@@ -199,6 +232,8 @@ async function handleHelp(msg: IncomingMessage, deps: HandlerDeps) {
199
232
  "• tasks — see running and pending tasks",
200
233
  "• cancel <id> — kill a running or pending task",
201
234
  "• trace [task-id] — see what happened step by step",
235
+ "• mode assistant — I'll (reluctantly) help with anything",
236
+ "• mode strict — back to code-only (default)",
202
237
  "• status / history / clear",
203
238
  "• <task> every day/weekday at <time> [on <repo>] — schedule a recurring task",
204
239
  "• list schedules — see your scheduled tasks",
@@ -383,7 +418,7 @@ async function handleSchedule(msg: IncomingMessage, parsedRepo: string | undefin
383
418
  }
384
419
 
385
420
  async function handleDiscuss(msg: IncomingMessage, parsed: ParsedMessage, history: { role: string; content: string }[], deps: HandlerDeps) {
386
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
421
+ const prompt = buildContextualPrompt(parsed, history, getPersona(msg.userId, deps));
387
422
 
388
423
  await msg.updateStatus("Thinking...");
389
424
 
@@ -413,7 +448,7 @@ async function handleDiscuss(msg: IncomingMessage, parsed: ParsedMessage, histor
413
448
 
414
449
  async function handleCreateProject(msg: IncomingMessage, parsed: ParsedMessage, history: { role: string; content: string }[], deps: HandlerDeps) {
415
450
  const projectName = parsed.args.name;
416
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
451
+ const prompt = buildContextualPrompt(parsed, history, getPersona(msg.userId, deps));
417
452
 
418
453
  const taskId = deps.queue.enqueue({
419
454
  userId: msg.userId,
@@ -485,7 +520,7 @@ async function handleTaskMessage(msg: IncomingMessage, parsed: ParsedMessage, de
485
520
  }
486
521
 
487
522
  const history = deps.sessions.getHistory(msg.userId, 6);
488
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
523
+ const prompt = buildContextualPrompt(parsed, history, getPersona(msg.userId, deps));
489
524
 
490
525
  const taskId = deps.queue.enqueue({
491
526
  userId: msg.userId,
@@ -515,6 +550,7 @@ export function createMessageHandler(deps: HandlerDeps): (msg: IncomingMessage)
515
550
  "help": () => handleHelp(msg, deps),
516
551
  "list-tasks": () => handleListTasks(msg, deps),
517
552
  "cancel-task": () => handleCancelTask(msg, parsed.args, deps),
553
+ "set-mode": () => handleSetMode(msg, parsed.args, deps),
518
554
  "trace": () => handleTrace(msg, parsed.args, deps),
519
555
  "list-schedules": () => handleListSchedules(msg, deps),
520
556
  "remove-schedule": () => handleRemoveSchedule(msg, parsed.args, deps),
@@ -580,7 +616,7 @@ export function createEventHandler(deps: HandlerDeps): (event: IncomingEvent, ad
580
616
  return;
581
617
  }
582
618
 
583
- const prompt = buildContextualPrompt(parsed, [], OVE_PERSONA);
619
+ const prompt = buildContextualPrompt(parsed, [], getPersona(event.userId, deps));
584
620
  const taskId = deps.queue.enqueue({
585
621
  userId: event.userId,
586
622
  repo: parsed.repo,
@@ -260,6 +260,73 @@ describe("parseMessage", () => {
260
260
  });
261
261
  });
262
262
 
263
+ describe("set-mode parsing", () => {
264
+ it("parses 'mode assistant'", () => {
265
+ const result = parseMessage("mode assistant");
266
+ expect(result.type).toBe("set-mode");
267
+ expect(result.args.mode).toBe("assistant");
268
+ });
269
+
270
+ it("parses 'mode strict'", () => {
271
+ const result = parseMessage("mode strict");
272
+ expect(result.type).toBe("set-mode");
273
+ expect(result.args.mode).toBe("strict");
274
+ });
275
+
276
+ it("parses '/mode assistant'", () => {
277
+ const result = parseMessage("/mode assistant");
278
+ expect(result.type).toBe("set-mode");
279
+ expect(result.args.mode).toBe("assistant");
280
+ });
281
+
282
+ it("parses 'assistant mode'", () => {
283
+ const result = parseMessage("assistant mode");
284
+ expect(result.type).toBe("set-mode");
285
+ expect(result.args.mode).toBe("assistant");
286
+ });
287
+
288
+ it("parses 'yolo mode'", () => {
289
+ const result = parseMessage("yolo mode");
290
+ expect(result.type).toBe("set-mode");
291
+ expect(result.args.mode).toBe("assistant");
292
+ });
293
+
294
+ it("parses 'be more helpful'", () => {
295
+ const result = parseMessage("be more helpful");
296
+ expect(result.type).toBe("set-mode");
297
+ expect(result.args.mode).toBe("assistant");
298
+ });
299
+
300
+ it("parses 'help me with anything'", () => {
301
+ const result = parseMessage("help me with anything");
302
+ expect(result.type).toBe("set-mode");
303
+ expect(result.args.mode).toBe("assistant");
304
+ });
305
+
306
+ it("parses 'strict mode'", () => {
307
+ const result = parseMessage("strict mode");
308
+ expect(result.type).toBe("set-mode");
309
+ expect(result.args.mode).toBe("strict");
310
+ });
311
+
312
+ it("parses 'code mode'", () => {
313
+ const result = parseMessage("code mode");
314
+ expect(result.type).toBe("set-mode");
315
+ expect(result.args.mode).toBe("strict");
316
+ });
317
+
318
+ it("parses 'back to normal'", () => {
319
+ const result = parseMessage("back to normal");
320
+ expect(result.type).toBe("set-mode");
321
+ expect(result.args.mode).toBe("strict");
322
+ });
323
+
324
+ it("does NOT match 'help me fix a bug on my-app' as set-mode", () => {
325
+ const result = parseMessage("help me fix a bug on my-app");
326
+ expect(result.type).not.toBe("set-mode");
327
+ });
328
+ });
329
+
263
330
  describe("buildPrompt", () => {
264
331
  it("builds review-pr prompt", () => {
265
332
  const prompt = buildPrompt({ type: "review-pr", repo: "my-app", args: { prNumber: 42 }, rawText: "" });
package/src/router.ts CHANGED
@@ -16,7 +16,8 @@ export type MessageType =
16
16
  | "remove-schedule"
17
17
  | "list-tasks"
18
18
  | "cancel-task"
19
- | "trace";
19
+ | "trace"
20
+ | "set-mode";
20
21
 
21
22
  export interface ParsedMessage {
22
23
  type: MessageType;
@@ -49,6 +50,25 @@ export function parseMessage(text: string): ParsedMessage {
49
50
  const traceMatch = trimmed.match(/^(?:\/)?trace(?:\s+(\S+))?$/i);
50
51
  if (traceMatch) return { type: "trace", args: { taskId: traceMatch[1] }, rawText: trimmed };
51
52
 
53
+ // Mode switching — explicit commands
54
+ const modeMatch = trimmed.match(/^(?:\/)?mode\s+(assistant|strict)$/i);
55
+ if (modeMatch) {
56
+ return { type: "set-mode", args: { mode: modeMatch[1].toLowerCase() }, rawText: trimmed };
57
+ }
58
+
59
+ // Mode switching — natural language for assistant mode
60
+ if (/^(?:assistant|yolo)\s+mode$/i.test(lower) ||
61
+ /^be\s+more\s+helpful$/i.test(lower) ||
62
+ /^help\s+me\s+with\s+(?:anything|everything)$/i.test(lower)) {
63
+ return { type: "set-mode", args: { mode: "assistant" }, rawText: trimmed };
64
+ }
65
+
66
+ // Mode switching — natural language for strict mode
67
+ if (/^(?:strict|code|normal)\s+mode$/i.test(lower) ||
68
+ /^back\s+to\s+(?:normal|code|strict)$/i.test(lower)) {
69
+ return { type: "set-mode", args: { mode: "strict" }, rawText: trimmed };
70
+ }
71
+
52
72
  // Natural language status inquiries — short messages asking about progress
53
73
  if (isStatusInquiry(lower)) return { type: "status", args: {}, rawText: trimmed };
54
74
 
@@ -42,4 +42,45 @@ describe("SessionStore", () => {
42
42
  store.clear("slack:U123");
43
43
  expect(store.getHistory("slack:U123").length).toBe(0);
44
44
  });
45
+
46
+ describe("user modes", () => {
47
+ it("returns 'strict' as default mode", () => {
48
+ const mode = store.getMode("slack:U123");
49
+ expect(mode).toBe("strict");
50
+ });
51
+
52
+ it("stores and retrieves a mode", () => {
53
+ store.setMode("slack:U123", "assistant");
54
+ expect(store.getMode("slack:U123")).toBe("assistant");
55
+ });
56
+
57
+ it("upserts mode (overwrites previous)", () => {
58
+ store.setMode("slack:U123", "assistant");
59
+ store.setMode("slack:U123", "strict");
60
+ expect(store.getMode("slack:U123")).toBe("strict");
61
+ });
62
+
63
+ it("keeps separate modes per user", () => {
64
+ store.setMode("slack:U1", "assistant");
65
+ store.setMode("slack:U2", "strict");
66
+ expect(store.getMode("slack:U1")).toBe("assistant");
67
+ expect(store.getMode("slack:U2")).toBe("strict");
68
+ });
69
+
70
+ it("resets mode when session is cleared", () => {
71
+ store.setMode("slack:U123", "assistant");
72
+ store.clear("slack:U123");
73
+ expect(store.getMode("slack:U123")).toBe("strict");
74
+ });
75
+
76
+ it("falls back to strict for unexpected values in database", () => {
77
+ // Simulate a corrupted/unexpected value by writing directly to SQLite
78
+ const db = (store as any).db as Database;
79
+ db.run(
80
+ `INSERT INTO user_modes (user_id, mode, updated_at) VALUES (?, ?, ?)`,
81
+ ["slack:U999", "banana", new Date().toISOString()]
82
+ );
83
+ expect(store.getMode("slack:U999")).toBe("strict");
84
+ });
85
+ });
45
86
  });
package/src/sessions.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
 
3
+ export type UserMode = "strict" | "assistant";
4
+
3
5
  export interface ChatMessage {
4
6
  role: "user" | "assistant";
5
7
  content: string;
@@ -27,6 +29,13 @@ export class SessionStore {
27
29
  )
28
30
  `);
29
31
  this.db.run(`CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_history(user_id)`);
32
+ this.db.run(`
33
+ CREATE TABLE IF NOT EXISTS user_modes (
34
+ user_id TEXT PRIMARY KEY,
35
+ mode TEXT NOT NULL,
36
+ updated_at TEXT NOT NULL
37
+ )
38
+ `);
30
39
  }
31
40
 
32
41
  addMessage(userId: string, role: "user" | "assistant", content: string) {
@@ -55,7 +64,25 @@ export class SessionStore {
55
64
  }));
56
65
  }
57
66
 
67
+ getMode(userId: string): UserMode {
68
+ const row = this.db
69
+ .query(`SELECT mode FROM user_modes WHERE user_id = ?`)
70
+ .get(userId) as { mode: string } | null;
71
+ if (!row) return "strict";
72
+ if (row.mode === "assistant" || row.mode === "strict") return row.mode;
73
+ return "strict";
74
+ }
75
+
76
+ setMode(userId: string, mode: UserMode): void {
77
+ this.db.run(
78
+ `INSERT INTO user_modes (user_id, mode, updated_at) VALUES (?, ?, ?)
79
+ ON CONFLICT(user_id) DO UPDATE SET mode = excluded.mode, updated_at = excluded.updated_at`,
80
+ [userId, mode, new Date().toISOString()]
81
+ );
82
+ }
83
+
58
84
  clear(userId: string) {
59
85
  this.db.run(`DELETE FROM chat_history WHERE user_id = ?`, [userId]);
86
+ this.db.run(`DELETE FROM user_modes WHERE user_id = ?`, [userId]);
60
87
  }
61
88
  }
package/src/smoke.test.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { describe, it, expect } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
- import { parseMessage, buildPrompt } from "./router";
3
+ import { parseMessage, buildPrompt, buildContextualPrompt } from "./router";
4
4
  import { TaskQueue } from "./queue";
5
5
  import { ClaudeRunner } from "./runners/claude";
6
6
  import { SessionStore } from "./sessions";
7
+ import { OVE_PERSONA } from "./handlers";
7
8
 
8
9
  describe("smoke test: full message flow", () => {
9
10
  it("routes a PR review through the full pipeline", () => {
@@ -77,4 +78,33 @@ describe("smoke test: full message flow", () => {
77
78
  expect(task).not.toBeNull();
78
79
  expect(task!.prompt).toContain("auth middleware");
79
80
  });
81
+
82
+ it("mode switch changes persona in prompts", () => {
83
+ const db = new Database(":memory:");
84
+ const sessions = new SessionStore(db);
85
+ const parsed = parseMessage("explain the auth flow");
86
+
87
+ // Default mode — prompt should NOT contain assistant addendum
88
+ const strictPrompt = buildContextualPrompt(parsed, [], OVE_PERSONA);
89
+ expect(strictPrompt).toContain("grumpy");
90
+ expect(strictPrompt).not.toContain("IMPORTANT MODE OVERRIDE");
91
+
92
+ // Switch to assistant — prompt should contain the addendum
93
+ sessions.setMode("slack:U123", "assistant");
94
+ expect(sessions.getMode("slack:U123")).toBe("assistant");
95
+
96
+ const assistantPersona = OVE_PERSONA + "\n\n" + "IMPORTANT MODE OVERRIDE";
97
+ const assistantPrompt = buildContextualPrompt(parsed, [], assistantPersona);
98
+ expect(assistantPrompt).toContain("IMPORTANT MODE OVERRIDE");
99
+ expect(assistantPrompt).toContain("grumpy");
100
+
101
+ // Switch back — verify storage round-trips
102
+ sessions.setMode("slack:U123", "strict");
103
+ expect(sessions.getMode("slack:U123")).toBe("strict");
104
+
105
+ // Verify parseMessage detects mode commands
106
+ const modeCmd = parseMessage("mode assistant");
107
+ expect(modeCmd.type).toBe("set-mode");
108
+ expect(modeCmd.args.mode).toBe("assistant");
109
+ });
80
110
  });