@lovenyberg/ove 0.7.0 → 0.9.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/src/router.ts CHANGED
@@ -16,60 +16,121 @@ 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;
23
24
  repo?: string;
24
25
  args: Record<string, any>;
25
26
  rawText: string;
27
+ priority: number;
28
+ }
29
+
30
+ export function parsePriority(text: string): { priority: number; text: string } {
31
+ const lower = text.toLowerCase();
32
+
33
+ // --priority urgent / --priority high / --priority normal
34
+ const flagMatch = text.match(/--priority\s+(urgent|high|normal|low)/i);
35
+ if (flagMatch) {
36
+ const level = flagMatch[1].toLowerCase();
37
+ const cleaned = text.replace(/\s*--priority\s+(urgent|high|normal|low)/i, "").trim();
38
+ const map: Record<string, number> = { urgent: 2, high: 1, normal: 0, low: 0 };
39
+ return { priority: map[level] ?? 0, text: cleaned };
40
+ }
41
+
42
+ // urgent: prefix
43
+ if (/^urgent:\s*/i.test(text)) {
44
+ return { priority: 2, text: text.replace(/^urgent:\s*/i, "").trim() };
45
+ }
46
+
47
+ // !important marker (anywhere)
48
+ if (/!important\b/i.test(lower)) {
49
+ return { priority: 1, text: text.replace(/\s*!important/i, "").trim() };
50
+ }
51
+
52
+ // p1 / p2 / p3 markers
53
+ const pMatch = text.match(/\bp([0-3])\b/i);
54
+ if (pMatch) {
55
+ const pNum = parseInt(pMatch[1]);
56
+ // p0 = urgent (2), p1 = high (1), p2 = normal (0), p3 = normal (0)
57
+ const map: Record<number, number> = { 0: 2, 1: 1, 2: 0, 3: 0 };
58
+ const cleaned = text.replace(/\s*\bp[0-3]\b/i, "").trim();
59
+ return { priority: map[pNum] ?? 0, text: cleaned };
60
+ }
61
+
62
+ return { priority: 0, text };
26
63
  }
27
64
 
28
65
  export function parseMessage(text: string): ParsedMessage {
29
- const trimmed = text.trim();
66
+ const { priority, text: priorityStripped } = parsePriority(text.trim());
67
+ const trimmed = priorityStripped;
30
68
  const lower = trimmed.toLowerCase();
31
69
 
70
+ function msg(partial: Omit<ParsedMessage, "priority">): ParsedMessage {
71
+ return { ...partial, priority };
72
+ }
73
+
32
74
  // Handle Telegram /commands — strip leading slash
33
- if (lower === "/start") return { type: "help", args: {}, rawText: trimmed };
34
- if (lower === "/help") return { type: "help", args: {}, rawText: trimmed };
35
- if (lower === "/status") return { type: "status", args: {}, rawText: trimmed };
36
- if (lower === "/history") return { type: "history", args: {}, rawText: trimmed };
37
- if (lower === "/clear") return { type: "clear", args: {}, rawText: trimmed };
75
+ if (lower === "/start") return msg({ type: "help", args: {}, rawText: trimmed });
76
+ if (lower === "/help") return msg({ type: "help", args: {}, rawText: trimmed });
77
+ if (lower === "/status") return msg({ type: "status", args: {}, rawText: trimmed });
78
+ if (lower === "/history") return msg({ type: "history", args: {}, rawText: trimmed });
79
+ if (lower === "/clear") return msg({ type: "clear", args: {}, rawText: trimmed });
38
80
 
39
- if (lower === "status") return { type: "status", args: {}, rawText: trimmed };
40
- if (lower === "history" || lower === "my tasks") return { type: "history", args: {}, rawText: trimmed };
41
- if (lower === "help") return { type: "help", args: {}, rawText: trimmed };
42
- if (lower === "clear" || lower === "reset") return { type: "clear", args: {}, rawText: trimmed };
81
+ if (lower === "status") return msg({ type: "status", args: {}, rawText: trimmed });
82
+ if (lower === "history" || lower === "my tasks") return msg({ type: "history", args: {}, rawText: trimmed });
83
+ if (lower === "help") return msg({ type: "help", args: {}, rawText: trimmed });
84
+ if (lower === "clear" || lower === "reset") return msg({ type: "clear", args: {}, rawText: trimmed });
43
85
 
44
86
  // Task management
45
- if (lower === "tasks" || lower === "/tasks") return { type: "list-tasks", args: {}, rawText: trimmed };
87
+ if (lower === "tasks" || lower === "/tasks") return msg({ type: "list-tasks", args: {}, rawText: trimmed });
46
88
  const cancelMatch = trimmed.match(/^(?:\/)?cancel\s+(\S+)$/i);
47
- if (cancelMatch) return { type: "cancel-task", args: { taskId: cancelMatch[1] }, rawText: trimmed };
89
+ if (cancelMatch) return msg({ type: "cancel-task", args: { taskId: cancelMatch[1] }, rawText: trimmed });
48
90
 
49
91
  const traceMatch = trimmed.match(/^(?:\/)?trace(?:\s+(\S+))?$/i);
50
- if (traceMatch) return { type: "trace", args: { taskId: traceMatch[1] }, rawText: trimmed };
92
+ if (traceMatch) return msg({ type: "trace", args: { taskId: traceMatch[1] }, rawText: trimmed });
93
+
94
+ // Mode switching — explicit commands
95
+ const modeMatch = trimmed.match(/^(?:\/)?mode\s+(assistant|strict)$/i);
96
+ if (modeMatch) {
97
+ return msg({ type: "set-mode", args: { mode: modeMatch[1].toLowerCase() }, rawText: trimmed });
98
+ }
99
+
100
+ // Mode switching — natural language for assistant mode
101
+ if (/^(?:assistant|yolo)\s+mode$/i.test(lower) ||
102
+ /^be\s+more\s+helpful$/i.test(lower) ||
103
+ /^help\s+me\s+with\s+(?:anything|everything)$/i.test(lower)) {
104
+ return msg({ type: "set-mode", args: { mode: "assistant" }, rawText: trimmed });
105
+ }
106
+
107
+ // Mode switching — natural language for strict mode
108
+ if (/^(?:strict|code|normal)\s+mode$/i.test(lower) ||
109
+ /^back\s+to\s+(?:normal|code|strict)$/i.test(lower)) {
110
+ return msg({ type: "set-mode", args: { mode: "strict" }, rawText: trimmed });
111
+ }
51
112
 
52
113
  // Natural language status inquiries — short messages asking about progress
53
- if (isStatusInquiry(lower)) return { type: "status", args: {}, rawText: trimmed };
114
+ if (isStatusInquiry(lower)) return msg({ type: "status", args: {}, rawText: trimmed });
54
115
 
55
116
  const prMatch = trimmed.match(/review\s+pr\s+#?(\d+)\s+(?:on|in)\s+(\S+)/i);
56
117
  if (prMatch) {
57
- return { type: "review-pr", repo: prMatch[2], args: { prNumber: parseInt(prMatch[1]) }, rawText: trimmed };
118
+ return msg({ type: "review-pr", repo: prMatch[2], args: { prNumber: parseInt(prMatch[1]) }, rawText: trimmed });
58
119
  }
59
120
 
60
121
  const issueMatch = trimmed.match(/fix\s+issue\s+#?(\d+)\s+(?:on|in)\s+(\S+)/i);
61
122
  if (issueMatch) {
62
- return { type: "fix-issue", repo: issueMatch[2], args: { issueNumber: parseInt(issueMatch[1]) }, rawText: trimmed };
123
+ return msg({ type: "fix-issue", repo: issueMatch[2], args: { issueNumber: parseInt(issueMatch[1]) }, rawText: trimmed });
63
124
  }
64
125
 
65
126
  const simplifyMatch = trimmed.match(/simplify\s+(\S+)\s+(?:on|in)\s+(\S+)/i);
66
127
  if (simplifyMatch) {
67
- return { type: "simplify", repo: simplifyMatch[2], args: { filePath: simplifyMatch[1] }, rawText: trimmed };
128
+ return msg({ type: "simplify", repo: simplifyMatch[2], args: { filePath: simplifyMatch[1] }, rawText: trimmed });
68
129
  }
69
130
 
70
131
  const validateMatch = trimmed.match(/validate\s+(\S+)/i);
71
132
  if (validateMatch) {
72
- return { type: "validate", repo: validateMatch[1], args: {}, rawText: trimmed };
133
+ return msg({ type: "validate", repo: validateMatch[1], args: {}, rawText: trimmed });
73
134
  }
74
135
 
75
136
  // create project <name> [with template <type>]
@@ -77,43 +138,43 @@ export function parseMessage(text: string): ParsedMessage {
77
138
  if (createMatch) {
78
139
  const args: Record<string, any> = { name: createMatch[1] };
79
140
  if (createMatch[2]) args.template = createMatch[2];
80
- return { type: "create-project", args, rawText: trimmed };
141
+ return msg({ type: "create-project", args, rawText: trimmed });
81
142
  }
82
143
 
83
144
  // Schedule management
84
145
  if (/list\s+schedules|show\s+(?:my\s+)?schedules|what.?s\s+scheduled/i.test(lower)) {
85
- return { type: "list-schedules", args: {}, rawText: trimmed };
146
+ return msg({ type: "list-schedules", args: {}, rawText: trimmed });
86
147
  }
87
148
 
88
149
  const removeScheduleMatch = trimmed.match(/(?:remove|delete|cancel)\s+schedule\s+#?(\d+)/i);
89
150
  if (removeScheduleMatch) {
90
- return { type: "remove-schedule", args: { scheduleId: parseInt(removeScheduleMatch[1]) }, rawText: trimmed };
151
+ return msg({ type: "remove-schedule", args: { scheduleId: parseInt(removeScheduleMatch[1]) }, rawText: trimmed });
91
152
  }
92
153
 
93
154
  // Schedule creation — detect natural language scheduling intent
94
155
  if (/\b(?:every\s+(?:day|week|month|monday|tuesday|wednesday|thursday|friday|saturday|sunday|weekday|morning|evening|hour|(?:\d+\s+)?(?:min(?:ute)?s?|hours?))|each\s+(?:day|week|weekday)|daily|weekly|monthly)\b/i.test(lower) ||
95
156
  /\b(?:at\s+\d{1,2}(?::\d{2})?)\b.*\b(?:every|each|daily|weekly)\b/i.test(lower)) {
96
157
  const repoHint = trimmed.match(/(?:in|on)\s+(\S+)\s*$/i);
97
- return { type: "schedule", repo: repoHint?.[1], args: {}, rawText: trimmed };
158
+ return msg({ type: "schedule", repo: repoHint?.[1], args: {}, rawText: trimmed });
98
159
  }
99
160
 
100
161
  // discuss / brainstorm / "I have an idea"
101
162
  const discussMatch = trimmed.match(/^(?:discuss|brainstorm)\s+(.+)/i);
102
163
  if (discussMatch) {
103
- return { type: "discuss", args: { topic: discussMatch[1] }, rawText: trimmed };
164
+ return msg({ type: "discuss", args: { topic: discussMatch[1] }, rawText: trimmed });
104
165
  }
105
166
  if (/^i\s+have\s+(?:a|an)\s+(?:idea|new\s+idea)/i.test(lower)) {
106
- return { type: "discuss", args: { topic: trimmed }, rawText: trimmed };
167
+ return msg({ type: "discuss", args: { topic: trimmed }, rawText: trimmed });
107
168
  }
108
169
 
109
170
  // init repo <name> <url> [branch]
110
171
  const initRepoMatch = trimmed.match(/^(?:init|setup|add)\s+repo\s+(\S+)\s+((?:git@|https:\/\/)\S+)(?:\s+(\S+))?$/i);
111
172
  if (initRepoMatch) {
112
- return {
173
+ return msg({
113
174
  type: "init-repo",
114
175
  args: { name: initRepoMatch[1], url: initRepoMatch[2], branch: initRepoMatch[3] || "main" },
115
176
  rawText: trimmed,
116
- };
177
+ });
117
178
  }
118
179
 
119
180
  // Natural language repo setup: "clone org/repo", "setup org/repo", "add org/repo"
@@ -122,11 +183,11 @@ export function parseMessage(text: string): ParsedMessage {
122
183
  const slug = naturalRepoMatch[1];
123
184
  const name = slug.split("/").pop()!;
124
185
  const url = `git@github.com:${slug}.git`;
125
- return {
186
+ return msg({
126
187
  type: "init-repo",
127
188
  args: { name, url, branch: "main" },
128
189
  rawText: trimmed,
129
- };
190
+ });
130
191
  }
131
192
 
132
193
  // Detect org/repo or GitHub URLs anywhere in a message that looks like a setup request
@@ -137,27 +198,27 @@ export function parseMessage(text: string): ParsedMessage {
137
198
  const slug = ghUrl[2];
138
199
  const name = slug.split("/").pop()!;
139
200
  const url = ghUrl[1].endsWith(".git") ? ghUrl[1] : ghUrl[1] + ".git";
140
- return {
201
+ return msg({
141
202
  type: "init-repo",
142
203
  args: { name, url: url.startsWith("git@") ? url : `git@github.com:${slug}.git`, branch: "main" },
143
204
  rawText: trimmed,
144
- };
205
+ });
145
206
  }
146
207
  const slugMatch = trimmed.match(/\b([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)\b/);
147
208
  if (slugMatch && slugMatch[1].indexOf("/") > 0) {
148
209
  const slug = slugMatch[1];
149
210
  const name = slug.split("/").pop()!;
150
211
  const url = `git@github.com:${slug}.git`;
151
- return {
212
+ return msg({
152
213
  type: "init-repo",
153
214
  args: { name, url, branch: "main" },
154
215
  rawText: trimmed,
155
- };
216
+ });
156
217
  }
157
218
  }
158
219
 
159
220
  const repoHint = trimmed.match(/(?:in|on)\s+(\S+?)[?.!,]*\s*$/i);
160
- return { type: "free-form", repo: repoHint?.[1], args: {}, rawText: trimmed };
221
+ return msg({ type: "free-form", repo: repoHint?.[1], args: {}, rawText: trimmed });
161
222
  }
162
223
 
163
224
  // Detect natural language status/progress inquiries.
@@ -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/setup.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync, accessSync, constants } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { resolve, dirname } from "node:path";
4
4
  import { userInfo } from "node:os";
@@ -10,6 +10,165 @@ interface ValidationResult {
10
10
  issues: string[];
11
11
  }
12
12
 
13
+ export interface DiagnosticResult {
14
+ name: string;
15
+ status: "pass" | "fail" | "warn";
16
+ message: string;
17
+ }
18
+
19
+ export interface DiagnosticDeps {
20
+ which: (cmd: string) => string | null;
21
+ fetch: typeof globalThis.fetch;
22
+ spawn: typeof Bun.spawn;
23
+ accessSync: typeof accessSync;
24
+ existsSync: typeof existsSync;
25
+ }
26
+
27
+ const defaultDeps: DiagnosticDeps = {
28
+ which: (cmd: string) => Bun.which(cmd),
29
+ fetch: globalThis.fetch,
30
+ spawn: Bun.spawn,
31
+ accessSync,
32
+ existsSync,
33
+ };
34
+
35
+ export async function runDiagnostics(config: Config, deps: DiagnosticDeps = defaultDeps): Promise<DiagnosticResult[]> {
36
+ const results: DiagnosticResult[] = [];
37
+
38
+ // Check git installed
39
+ const gitPath = deps.which("git");
40
+ if (gitPath) {
41
+ let version = "";
42
+ try {
43
+ const proc = deps.spawn(["git", "--version"], { stdout: "pipe", stderr: "pipe" });
44
+ await proc.exited;
45
+ version = await new Response(proc.stdout).text();
46
+ const match = version.match(/(\d+\.\d+[\.\d]*)/);
47
+ results.push({ name: "git", status: "pass", message: `git installed (${match ? match[1] : "unknown version"})` });
48
+ } catch {
49
+ results.push({ name: "git", status: "pass", message: "git installed" });
50
+ }
51
+ } else {
52
+ results.push({ name: "git", status: "fail", message: "git not found" });
53
+ }
54
+
55
+ // Check runner CLI installed (claude or codex)
56
+ const runnerName = config.runner?.name || "claude";
57
+ const runnerCmd = runnerName === "codex" ? "codex" : "claude";
58
+ const runnerPath = deps.which(runnerCmd);
59
+ if (runnerPath) {
60
+ results.push({ name: runnerCmd, status: "pass", message: `${runnerCmd} CLI installed` });
61
+ } else {
62
+ results.push({ name: runnerCmd, status: "fail", message: `${runnerCmd} CLI not found` });
63
+ }
64
+
65
+ // Check gh CLI installed (if GitHub sync configured)
66
+ const hasGitHubSync = !!(config.github?.orgs && config.github.orgs.length > 0);
67
+ const hasGitHubPoll = !!process.env.GITHUB_POLL_REPOS;
68
+ if (hasGitHubSync || hasGitHubPoll) {
69
+ const ghPath = deps.which("gh");
70
+ if (ghPath) {
71
+ results.push({ name: "gh", status: "pass", message: "gh CLI installed" });
72
+ } else {
73
+ results.push({ name: "gh", status: "warn", message: "gh CLI not found (GitHub sync will not work)" });
74
+ }
75
+ }
76
+
77
+ // Check SSH access to GitHub
78
+ try {
79
+ const proc = deps.spawn(
80
+ ["ssh", "-T", "git@github.com", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"],
81
+ { stdout: "pipe", stderr: "pipe" }
82
+ );
83
+ const exitCode = await proc.exited;
84
+ // GitHub SSH returns exit code 1 with "successfully authenticated" on success
85
+ const stderr = await new Response(proc.stderr).text();
86
+ if (exitCode === 0 || exitCode === 1 && stderr.includes("successfully authenticated")) {
87
+ results.push({ name: "ssh", status: "pass", message: "SSH access to github.com" });
88
+ } else {
89
+ results.push({ name: "ssh", status: "warn", message: "SSH access to github.com failed (HTTPS clones still work)" });
90
+ }
91
+ } catch {
92
+ results.push({ name: "ssh", status: "warn", message: "SSH access to github.com failed (HTTPS clones still work)" });
93
+ }
94
+
95
+ // Check REPOS_DIR exists and is writable
96
+ const reposDir = config.reposDir;
97
+ if (deps.existsSync(reposDir)) {
98
+ try {
99
+ deps.accessSync(reposDir, constants.W_OK);
100
+ results.push({ name: "repos_dir", status: "pass", message: `REPOS_DIR ${reposDir} exists and is writable` });
101
+ } catch {
102
+ results.push({ name: "repos_dir", status: "fail", message: `REPOS_DIR ${reposDir} exists but is not writable` });
103
+ }
104
+ } else {
105
+ results.push({ name: "repos_dir", status: "fail", message: `REPOS_DIR ${reposDir} does not exist` });
106
+ }
107
+
108
+ // Check bot token validity via API calls
109
+ const slackToken = process.env.SLACK_BOT_TOKEN;
110
+ if (slackToken && slackToken !== "xoxb-...") {
111
+ try {
112
+ const res = await deps.fetch("https://slack.com/api/auth.test", {
113
+ method: "POST",
114
+ headers: { Authorization: `Bearer ${slackToken}`, "Content-Type": "application/json" },
115
+ signal: AbortSignal.timeout(5000),
116
+ });
117
+ const data = await res.json() as { ok: boolean };
118
+ if (data.ok) {
119
+ results.push({ name: "slack", status: "pass", message: "Slack bot token is valid" });
120
+ } else {
121
+ results.push({ name: "slack", status: "fail", message: "Slack bot token is invalid" });
122
+ }
123
+ } catch {
124
+ results.push({ name: "slack", status: "warn", message: "Could not verify Slack bot token (network error)" });
125
+ }
126
+ }
127
+
128
+ const telegramToken = process.env.TELEGRAM_BOT_TOKEN;
129
+ if (telegramToken) {
130
+ try {
131
+ const res = await deps.fetch(`https://api.telegram.org/bot${telegramToken}/getMe`, {
132
+ signal: AbortSignal.timeout(5000),
133
+ });
134
+ const data = await res.json() as { ok: boolean };
135
+ if (data.ok) {
136
+ results.push({ name: "telegram", status: "pass", message: "Telegram bot token is valid" });
137
+ } else {
138
+ results.push({ name: "telegram", status: "fail", message: "Telegram bot token is invalid" });
139
+ }
140
+ } catch {
141
+ results.push({ name: "telegram", status: "warn", message: "Could not verify Telegram bot token (network error)" });
142
+ }
143
+ }
144
+
145
+ const discordToken = process.env.DISCORD_BOT_TOKEN;
146
+ if (discordToken) {
147
+ try {
148
+ const res = await deps.fetch("https://discord.com/api/v10/users/@me", {
149
+ headers: { Authorization: `Bot ${discordToken}` },
150
+ signal: AbortSignal.timeout(5000),
151
+ });
152
+ if (res.ok) {
153
+ results.push({ name: "discord", status: "pass", message: "Discord bot token is valid" });
154
+ } else {
155
+ results.push({ name: "discord", status: "fail", message: "Discord bot token is invalid" });
156
+ }
157
+ } catch {
158
+ results.push({ name: "discord", status: "warn", message: "Could not verify Discord bot token (network error)" });
159
+ }
160
+ }
161
+
162
+ return results;
163
+ }
164
+
165
+ export function printDiagnostics(results: DiagnosticResult[]): void {
166
+ const icons = { pass: "\u2713", fail: "\u2717", warn: "\u26A0" };
167
+ for (const r of results) {
168
+ process.stdout.write(` ${icons[r.status]} ${r.message}\n`);
169
+ }
170
+ }
171
+
13
172
  export function validateConfig(opts?: { configPath?: string; envPath?: string }): ValidationResult {
14
173
  const configPath = opts?.configPath || process.env.CONFIG_PATH || "./config.json";
15
174
  const envPath = opts?.envPath || "./.env";
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
  });