@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/bin/ove.ts +37 -1
- package/package.json +1 -1
- package/public/index.html +164 -3
- package/public/metrics.html +716 -0
- package/public/status.html +43 -0
- package/public/trace.html +127 -0
- package/src/adapters/github.test.ts +8 -0
- package/src/adapters/github.ts +3 -2
- package/src/adapters/http.test.ts +597 -1
- package/src/adapters/http.ts +222 -3
- package/src/adapters/slack.test.ts +233 -0
- package/src/adapters/types.ts +1 -1
- package/src/adapters/whatsapp.test.ts +102 -0
- package/src/adapters/wiring.test.ts +2 -0
- package/src/diagnostics.test.ts +375 -0
- package/src/handlers.test.ts +553 -0
- package/src/handlers.ts +46 -7
- package/src/index.ts +1 -0
- package/src/queue.test.ts +174 -0
- package/src/queue.ts +85 -7
- package/src/router.test.ts +151 -1
- package/src/router.ts +95 -34
- package/src/sessions.test.ts +41 -0
- package/src/sessions.ts +27 -0
- package/src/setup.ts +160 -1
- package/src/smoke.test.ts +31 -1
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
|
|
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.
|
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/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
|
});
|