@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 +1 -1
- package/src/adapters/http.ts +28 -6
- package/src/handlers.ts +49 -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/adapters/http.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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,
|
|
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,
|
|
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, [],
|
|
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,
|
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
|
});
|