@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 +1 -1
- package/src/handlers.ts +41 -5
- package/src/router.test.ts +67 -0
- package/src/router.ts +21 -1
- package/src/sessions.test.ts +41 -0
- package/src/sessions.ts +27 -0
- package/src/smoke.test.ts +31 -1
package/package.json
CHANGED
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,
|
|
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,
|
|
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,
|
|
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, [],
|
|
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,
|
package/src/router.test.ts
CHANGED
|
@@ -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
|
|
package/src/sessions.test.ts
CHANGED
|
@@ -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
|
});
|