@lovenyberg/ove 0.6.1 → 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.6.1",
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": {
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
- import { join, extname } from "node:path";
2
+ import { join, extname, resolve } from "node:path";
3
+ import { timingSafeEqual } from "node:crypto";
3
4
  import type { EventAdapter, IncomingEvent, IncomingMessage, ChatAdapter, AdapterStatus } from "./types";
4
5
  import type { TraceStore } from "../trace";
5
6
  import type { TaskQueue } from "../queue";
@@ -13,6 +14,13 @@ interface PendingChat {
13
14
  sseControllers: ReadableStreamDefaultController[];
14
15
  }
15
16
 
17
+ function safeEqual(a: string, b: string): boolean {
18
+ const bufA = Buffer.from(a);
19
+ const bufB = Buffer.from(b);
20
+ if (bufA.length !== bufB.length) return false;
21
+ return timingSafeEqual(bufA, bufB);
22
+ }
23
+
16
24
  export class HttpApiAdapter implements EventAdapter {
17
25
  private port: number;
18
26
  private apiKey: string;
@@ -40,7 +48,7 @@ export class HttpApiAdapter implements EventAdapter {
40
48
  this.trace = trace;
41
49
  this.queue = queue || null;
42
50
  this.sessions = sessions || null;
43
- const publicDir = join(import.meta.dir, "../../public");
51
+ const publicDir = resolve(import.meta.dir, "../../public");
44
52
  this.publicDir = publicDir;
45
53
  try {
46
54
  this.webUiHtml = readFileSync(join(publicDir, "index.html"), "utf-8");
@@ -114,7 +122,7 @@ export class HttpApiAdapter implements EventAdapter {
114
122
  // Auth check for API routes
115
123
  if (path.startsWith("/api/")) {
116
124
  const key = req.headers.get("X-API-Key") || url.searchParams.get("key");
117
- if (key !== self.apiKey) {
125
+ if (!key || !safeEqual(key, self.apiKey)) {
118
126
  return Response.json({ error: "Unauthorized" }, { status: 401 });
119
127
  }
120
128
  }
@@ -159,9 +167,20 @@ export class HttpApiAdapter implements EventAdapter {
159
167
 
160
168
  // POST /api/message — submit a chat message (full chat pipeline)
161
169
  if (path === "/api/message" && req.method === "POST") {
162
- const body = await req.json() as { text: string; userId?: string };
170
+ let body: { text: string };
171
+ try {
172
+ body = await req.json() as { text: string };
173
+ } catch {
174
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
175
+ }
176
+ if (!body || typeof body.text !== "string" || body.text.trim().length === 0) {
177
+ return Response.json({ error: "Missing or invalid 'text' field" }, { status: 400 });
178
+ }
179
+ if (body.text.length > 50000) {
180
+ return Response.json({ error: "Message too long (max 50000 chars)" }, { status: 400 });
181
+ }
163
182
  const chatId = crypto.randomUUID();
164
- const userId = body.userId || "http:web";
183
+ const userId = "http:web";
165
184
 
166
185
  const chat: PendingChat = { status: "pending", replies: [], sseControllers: [] };
167
186
  self.chats.set(chatId, chat);
@@ -290,7 +309,10 @@ export class HttpApiAdapter implements EventAdapter {
290
309
  const MIME: Record<string, string> = { ".png": "image/png", ".ico": "image/x-icon", ".svg": "image/svg+xml", ".jpg": "image/jpeg", ".css": "text/css", ".js": "application/javascript" };
291
310
  const ext = extname(path);
292
311
  if (ext && MIME[ext]) {
293
- const filePath = join(self.publicDir, path);
312
+ const filePath = resolve(self.publicDir, "." + path);
313
+ if (!filePath.startsWith(self.publicDir + "/")) {
314
+ return Response.json({ error: "Not found" }, { status: 404 });
315
+ }
294
316
  if (existsSync(filePath)) {
295
317
  const data = readFileSync(filePath);
296
318
  return new Response(data, { headers: { "Content-Type": MIME[ext], "Cache-Control": "public, max-age=3600" } });
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",
@@ -254,6 +289,10 @@ async function handleCancelTask(msg: IncomingMessage, args: Record<string, any>,
254
289
  const active = deps.queue.listActive();
255
290
  const pendingMatch = active.find((t) => t.id.toLowerCase().startsWith(prefix) && t.status === "pending");
256
291
  if (pendingMatch) {
292
+ if (pendingMatch.userId !== msg.userId) {
293
+ await msg.reply("That's not your task.");
294
+ return;
295
+ }
257
296
  deps.queue.cancel(pendingMatch.id);
258
297
  await msg.reply(`Cancelled pending task ${pendingMatch.id.slice(0, 7)} on ${pendingMatch.repo}.`);
259
298
  return;
@@ -261,6 +300,10 @@ async function handleCancelTask(msg: IncomingMessage, args: Record<string, any>,
261
300
  await msg.reply(`No task found matching "${prefix}". Use /tasks to see what's running.`);
262
301
  return;
263
302
  }
303
+ if (match.task.userId !== msg.userId) {
304
+ await msg.reply("That's not your task.");
305
+ return;
306
+ }
264
307
  match.abort.abort();
265
308
  deps.queue.cancel(match.task.id);
266
309
  await msg.reply(`Killed task ${match.task.id.slice(0, 7)} on ${match.task.repo}. Gone.`);
@@ -375,7 +418,7 @@ async function handleSchedule(msg: IncomingMessage, parsedRepo: string | undefin
375
418
  }
376
419
 
377
420
  async function handleDiscuss(msg: IncomingMessage, parsed: ParsedMessage, history: { role: string; content: string }[], deps: HandlerDeps) {
378
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
421
+ const prompt = buildContextualPrompt(parsed, history, getPersona(msg.userId, deps));
379
422
 
380
423
  await msg.updateStatus("Thinking...");
381
424
 
@@ -405,7 +448,7 @@ async function handleDiscuss(msg: IncomingMessage, parsed: ParsedMessage, histor
405
448
 
406
449
  async function handleCreateProject(msg: IncomingMessage, parsed: ParsedMessage, history: { role: string; content: string }[], deps: HandlerDeps) {
407
450
  const projectName = parsed.args.name;
408
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
451
+ const prompt = buildContextualPrompt(parsed, history, getPersona(msg.userId, deps));
409
452
 
410
453
  const taskId = deps.queue.enqueue({
411
454
  userId: msg.userId,
@@ -477,7 +520,7 @@ async function handleTaskMessage(msg: IncomingMessage, parsed: ParsedMessage, de
477
520
  }
478
521
 
479
522
  const history = deps.sessions.getHistory(msg.userId, 6);
480
- const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
523
+ const prompt = buildContextualPrompt(parsed, history, getPersona(msg.userId, deps));
481
524
 
482
525
  const taskId = deps.queue.enqueue({
483
526
  userId: msg.userId,
@@ -507,6 +550,7 @@ export function createMessageHandler(deps: HandlerDeps): (msg: IncomingMessage)
507
550
  "help": () => handleHelp(msg, deps),
508
551
  "list-tasks": () => handleListTasks(msg, deps),
509
552
  "cancel-task": () => handleCancelTask(msg, parsed.args, deps),
553
+ "set-mode": () => handleSetMode(msg, parsed.args, deps),
510
554
  "trace": () => handleTrace(msg, parsed.args, deps),
511
555
  "list-schedules": () => handleListSchedules(msg, deps),
512
556
  "remove-schedule": () => handleRemoveSchedule(msg, parsed.args, deps),
@@ -572,7 +616,7 @@ export function createEventHandler(deps: HandlerDeps): (event: IncomingEvent, ad
572
616
  return;
573
617
  }
574
618
 
575
- const prompt = buildContextualPrompt(parsed, [], OVE_PERSONA);
619
+ const prompt = buildContextualPrompt(parsed, [], getPersona(event.userId, deps));
576
620
  const taskId = deps.queue.enqueue({
577
621
  userId: event.userId,
578
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
  });