zubo 0.1.21 → 0.1.23
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/.playwright-mcp/console-2026-02-16T19-06-21-167Z.log +31 -0
- package/README.md +2 -1
- package/dashboard-chat.png +0 -0
- package/dashboard-followups.png +0 -0
- package/dashboard-history.png +0 -0
- package/dashboard-integrations.png +0 -0
- package/dashboard-knowledge-ok.png +0 -0
- package/dashboard-knowledge.png +0 -0
- package/dashboard-notes-add.png +0 -0
- package/dashboard-notes-improved.png +0 -0
- package/dashboard-notes.png +0 -0
- package/dashboard-overview.png +0 -0
- package/dashboard-preferences.png +0 -0
- package/dashboard-settings-fixed.png +0 -0
- package/dashboard-settings.png +0 -0
- package/dashboard-skills-ok.png +0 -0
- package/dashboard-skills.png +0 -0
- package/dashboard-todos-add.png +0 -0
- package/dashboard-todos-improved.png +0 -0
- package/dashboard-todos-item.png +0 -0
- package/dashboard-todos-priority-badge.png +0 -0
- package/dashboard-todos.png +0 -0
- package/dashboard-topics.png +0 -0
- package/docs/ROADMAP.md +12 -49
- package/migrations/024_personal_features.sql +96 -0
- package/package.json +1 -1
- package/site/docs/index.html +11 -0
- package/site/docs/skills.html +107 -0
- package/site/index.html +9 -1
- package/src/agent/context.ts +3 -3
- package/src/agent/delegate.ts +7 -2
- package/src/agent/loop.ts +6 -6
- package/src/agent/prompts.ts +49 -1
- package/src/agent/workflow-executor.ts +5 -1
- package/src/channels/dashboard.html.ts +558 -6
- package/src/channels/webchat.ts +305 -27
- package/src/llm/claude-code.ts +58 -17
- package/src/llm/codex.ts +59 -18
- package/src/start.ts +12 -0
- package/src/tools/builtin/diagnose.ts +19 -5
- package/src/tools/builtin/follow-ups.ts +189 -0
- package/src/tools/builtin/notes.ts +207 -0
- package/src/tools/builtin/preferences.ts +173 -0
- package/src/tools/builtin/todos.ts +270 -0
- package/src/tools/builtin/topics.ts +166 -0
- package/src/tools/mcp-client.ts +8 -0
- package/src/tools/permissions.ts +7 -0
- package/tests/agent/session.test.ts +43 -45
- package/tests/mcp-registry.test.ts +32 -35
- package/tests/personal-features.test.ts +1251 -0
- package/tests/skill-registry.test.ts +1 -7
- package/tests/db/export.test.ts +0 -219
- package/tests/session.test.ts +0 -58
- package/tests/tools/executor.test.ts +0 -150
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { registerTool } from "../registry";
|
|
2
|
+
import { getDb } from "../../db/connection";
|
|
3
|
+
|
|
4
|
+
function parseDate(input: string): string {
|
|
5
|
+
const now = new Date();
|
|
6
|
+
const lower = input.toLowerCase().trim();
|
|
7
|
+
|
|
8
|
+
if (lower === "today") return now.toISOString().split("T")[0];
|
|
9
|
+
|
|
10
|
+
if (lower === "tomorrow") {
|
|
11
|
+
now.setDate(now.getDate() + 1);
|
|
12
|
+
return now.toISOString().split("T")[0];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (lower === "next week") {
|
|
16
|
+
now.setDate(now.getDate() + 7);
|
|
17
|
+
return now.toISOString().split("T")[0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const days = [
|
|
21
|
+
"sunday",
|
|
22
|
+
"monday",
|
|
23
|
+
"tuesday",
|
|
24
|
+
"wednesday",
|
|
25
|
+
"thursday",
|
|
26
|
+
"friday",
|
|
27
|
+
"saturday",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < days.length; i++) {
|
|
31
|
+
if (lower.includes(days[i])) {
|
|
32
|
+
const current = now.getDay();
|
|
33
|
+
let diff = i - current;
|
|
34
|
+
if (diff <= 0) diff += 7;
|
|
35
|
+
if (lower.includes("next")) diff += 7;
|
|
36
|
+
now.setDate(now.getDate() + diff);
|
|
37
|
+
return now.toISOString().split("T")[0];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parsed = new Date(input);
|
|
42
|
+
if (!isNaN(parsed.getTime())) return parsed.toISOString().split("T")[0];
|
|
43
|
+
|
|
44
|
+
return input;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatDueDate(dueDate: string | null): string {
|
|
48
|
+
if (!dueDate) return "";
|
|
49
|
+
|
|
50
|
+
const today = new Date();
|
|
51
|
+
today.setHours(0, 0, 0, 0);
|
|
52
|
+
const due = new Date(dueDate + "T00:00:00");
|
|
53
|
+
const diffDays = Math.round(
|
|
54
|
+
(due.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (diffDays < 0) return `overdue by ${Math.abs(diffDays)}d`;
|
|
58
|
+
if (diffDays === 0) return "today";
|
|
59
|
+
if (diffDays === 1) return "tomorrow";
|
|
60
|
+
if (diffDays <= 7) return `in ${diffDays} days`;
|
|
61
|
+
return dueDate;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface TodoRow {
|
|
65
|
+
id: number;
|
|
66
|
+
title: string;
|
|
67
|
+
description: string | null;
|
|
68
|
+
priority: string;
|
|
69
|
+
status: string;
|
|
70
|
+
due_date: string | null;
|
|
71
|
+
tags: string;
|
|
72
|
+
created_at: string;
|
|
73
|
+
completed_at: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerTodosTool() {
|
|
77
|
+
registerTool({
|
|
78
|
+
definition: {
|
|
79
|
+
name: "todos",
|
|
80
|
+
description:
|
|
81
|
+
"Manage the user's to-do list. Add, list, complete, remove, or update tasks with priorities, due dates, and tags.",
|
|
82
|
+
input_schema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
action: {
|
|
86
|
+
type: "string",
|
|
87
|
+
enum: ["add", "list", "complete", "remove", "update"],
|
|
88
|
+
description: "The action to perform on the todo list.",
|
|
89
|
+
},
|
|
90
|
+
title: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "Title of the todo item (for add).",
|
|
93
|
+
},
|
|
94
|
+
description: {
|
|
95
|
+
type: "string",
|
|
96
|
+
description: "Optional description (for add/update).",
|
|
97
|
+
},
|
|
98
|
+
priority: {
|
|
99
|
+
type: "string",
|
|
100
|
+
enum: ["low", "medium", "high", "urgent"],
|
|
101
|
+
description: "Priority level (for add/update).",
|
|
102
|
+
},
|
|
103
|
+
due_date: {
|
|
104
|
+
type: "string",
|
|
105
|
+
description:
|
|
106
|
+
'Due date — natural language like "tomorrow", "next friday", or an ISO date (for add/update).',
|
|
107
|
+
},
|
|
108
|
+
tags: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description:
|
|
111
|
+
"Comma-separated tags (for add/update). Example: 'work, urgent'.",
|
|
112
|
+
},
|
|
113
|
+
id: {
|
|
114
|
+
type: "number",
|
|
115
|
+
description: "Todo ID (for complete/remove/update).",
|
|
116
|
+
},
|
|
117
|
+
filter: {
|
|
118
|
+
type: "string",
|
|
119
|
+
description:
|
|
120
|
+
'Filter for list action: "all", "pending", "done", "urgent", "overdue". Defaults to "pending".',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: ["action"],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
execute: async (input) => {
|
|
127
|
+
const db = getDb();
|
|
128
|
+
const action = input.action as string;
|
|
129
|
+
|
|
130
|
+
if (action === "add") {
|
|
131
|
+
const title = input.title as string;
|
|
132
|
+
if (!title) return "Error: title is required to add a todo.";
|
|
133
|
+
|
|
134
|
+
const priority = (input.priority as string) || "medium";
|
|
135
|
+
const description = (input.description as string) || null;
|
|
136
|
+
const tags = input.tags
|
|
137
|
+
? JSON.stringify(
|
|
138
|
+
(input.tags as string).split(",").map((t) => t.trim()).filter((t) => t.length > 0)
|
|
139
|
+
)
|
|
140
|
+
: "[]";
|
|
141
|
+
const dueDate = input.due_date
|
|
142
|
+
? parseDate(input.due_date as string)
|
|
143
|
+
: null;
|
|
144
|
+
|
|
145
|
+
const result = db
|
|
146
|
+
.prepare(
|
|
147
|
+
"INSERT INTO todos (title, description, priority, due_date, tags) VALUES (?, ?, ?, ?, ?)"
|
|
148
|
+
)
|
|
149
|
+
.run(title, description, priority, dueDate, tags);
|
|
150
|
+
|
|
151
|
+
let msg = `Added: #${result.lastInsertRowid} ${title} [${priority}]`;
|
|
152
|
+
if (dueDate) msg += ` due: ${formatDueDate(dueDate)}`;
|
|
153
|
+
return msg;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (action === "list") {
|
|
157
|
+
const filter = (input.filter as string) || "pending";
|
|
158
|
+
|
|
159
|
+
let query = "SELECT * FROM todos";
|
|
160
|
+
const params: string[] = [];
|
|
161
|
+
|
|
162
|
+
if (filter === "pending") {
|
|
163
|
+
query += " WHERE status = 'pending'";
|
|
164
|
+
} else if (filter === "done") {
|
|
165
|
+
query += " WHERE status = 'done'";
|
|
166
|
+
} else if (filter === "urgent") {
|
|
167
|
+
query += " WHERE priority = 'urgent' AND status = 'pending'";
|
|
168
|
+
} else if (filter === "overdue") {
|
|
169
|
+
query +=
|
|
170
|
+
" WHERE due_date < date('now') AND status = 'pending' AND due_date IS NOT NULL";
|
|
171
|
+
}
|
|
172
|
+
// "all" has no WHERE clause
|
|
173
|
+
|
|
174
|
+
query +=
|
|
175
|
+
" ORDER BY CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END, due_date ASC NULLS LAST";
|
|
176
|
+
|
|
177
|
+
const rows = db.prepare(query).all(...params) as TodoRow[];
|
|
178
|
+
|
|
179
|
+
if (rows.length === 0) return `No ${filter} todos found.`;
|
|
180
|
+
|
|
181
|
+
const lines = rows.map((row) => {
|
|
182
|
+
const check = row.status === "done" ? "☑" : "☐";
|
|
183
|
+
let line = `${check} #${row.id} ${row.title} [${row.priority}]`;
|
|
184
|
+
if (row.due_date) line += ` due: ${formatDueDate(row.due_date)}`;
|
|
185
|
+
if (row.tags && row.tags !== "[]") {
|
|
186
|
+
const tagList = JSON.parse(row.tags) as string[];
|
|
187
|
+
if (tagList.length > 0) line += ` {${tagList.join(", ")}}`;
|
|
188
|
+
}
|
|
189
|
+
if (row.status === "done") line += " [done]";
|
|
190
|
+
return line;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return `Todos (${filter} — ${rows.length} items):\n${lines.join("\n")}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (action === "complete") {
|
|
197
|
+
const id = input.id as number;
|
|
198
|
+
if (!id) return "Error: id is required to complete a todo.";
|
|
199
|
+
|
|
200
|
+
const changes = db
|
|
201
|
+
.prepare(
|
|
202
|
+
"UPDATE todos SET status = 'done', completed_at = datetime('now') WHERE id = ?"
|
|
203
|
+
)
|
|
204
|
+
.run(id);
|
|
205
|
+
|
|
206
|
+
if (changes.changes === 0) return `No todo found with id #${id}.`;
|
|
207
|
+
return `Completed todo #${id}.`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (action === "remove") {
|
|
211
|
+
const id = input.id as number;
|
|
212
|
+
if (!id) return "Error: id is required to remove a todo.";
|
|
213
|
+
|
|
214
|
+
const changes = db
|
|
215
|
+
.prepare("DELETE FROM todos WHERE id = ?")
|
|
216
|
+
.run(id);
|
|
217
|
+
|
|
218
|
+
if (changes.changes === 0) return `No todo found with id #${id}.`;
|
|
219
|
+
return `Removed todo #${id}.`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (action === "update") {
|
|
223
|
+
const id = input.id as number;
|
|
224
|
+
if (!id) return "Error: id is required to update a todo.";
|
|
225
|
+
|
|
226
|
+
const updates: string[] = [];
|
|
227
|
+
const values: (string | null)[] = [];
|
|
228
|
+
|
|
229
|
+
if (input.title) {
|
|
230
|
+
updates.push("title = ?");
|
|
231
|
+
values.push(input.title as string);
|
|
232
|
+
}
|
|
233
|
+
if (input.description !== undefined) {
|
|
234
|
+
updates.push("description = ?");
|
|
235
|
+
values.push((input.description as string) || null);
|
|
236
|
+
}
|
|
237
|
+
if (input.priority) {
|
|
238
|
+
updates.push("priority = ?");
|
|
239
|
+
values.push(input.priority as string);
|
|
240
|
+
}
|
|
241
|
+
if (input.due_date) {
|
|
242
|
+
updates.push("due_date = ?");
|
|
243
|
+
values.push(parseDate(input.due_date as string));
|
|
244
|
+
}
|
|
245
|
+
if (input.tags) {
|
|
246
|
+
updates.push("tags = ?");
|
|
247
|
+
values.push(
|
|
248
|
+
JSON.stringify(
|
|
249
|
+
(input.tags as string).split(",").map((t) => t.trim()).filter((t) => t.length > 0)
|
|
250
|
+
)
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (updates.length === 0)
|
|
255
|
+
return "Error: provide at least one field to update.";
|
|
256
|
+
|
|
257
|
+
values.push(String(id));
|
|
258
|
+
|
|
259
|
+
const changes = db
|
|
260
|
+
.prepare(`UPDATE todos SET ${updates.join(", ")} WHERE id = ?`)
|
|
261
|
+
.run(...values);
|
|
262
|
+
|
|
263
|
+
if (changes.changes === 0) return `No todo found with id #${id}.`;
|
|
264
|
+
return `Updated todo #${id}.`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return `Unknown action: ${action}. Use add, list, complete, remove, or update.`;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { registerTool } from "../registry";
|
|
2
|
+
import { getDb } from "../../db/connection";
|
|
3
|
+
|
|
4
|
+
let activeTopic: string | null = null;
|
|
5
|
+
|
|
6
|
+
export function registerTopicsTool() {
|
|
7
|
+
registerTool({
|
|
8
|
+
definition: {
|
|
9
|
+
name: "topics",
|
|
10
|
+
description:
|
|
11
|
+
"Organize conversations into named topics or threads. Each topic scopes the conversation context so you can keep separate discussions organized (e.g. 'work project', 'trip planning'). Use this to create, switch between, list, archive, or check the current topic.",
|
|
12
|
+
input_schema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
action: {
|
|
16
|
+
type: "string",
|
|
17
|
+
enum: ["create", "switch", "list", "archive", "current"],
|
|
18
|
+
description:
|
|
19
|
+
"The action to perform: create a new topic, switch to an existing one, list all active topics, archive a topic, or check the current topic.",
|
|
20
|
+
},
|
|
21
|
+
name: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description:
|
|
24
|
+
"The topic name (for create, switch, and archive). Examples: 'work project', 'trip planning', 'recipe ideas'.",
|
|
25
|
+
},
|
|
26
|
+
description: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Optional description of the topic (for create).",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
required: ["action"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
execute: async (input) => {
|
|
35
|
+
const { action, name, description } = input as {
|
|
36
|
+
action: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
};
|
|
40
|
+
const db = getDb();
|
|
41
|
+
|
|
42
|
+
switch (action) {
|
|
43
|
+
case "create": {
|
|
44
|
+
if (!name) {
|
|
45
|
+
return "Error: 'name' is required to create a topic.";
|
|
46
|
+
}
|
|
47
|
+
db.prepare(
|
|
48
|
+
`INSERT INTO conversation_topics (name, description) VALUES (?, ?)`
|
|
49
|
+
).run(name, description ?? null);
|
|
50
|
+
return `Topic '${name}' created. Switch to it with action 'switch'.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "switch": {
|
|
54
|
+
if (!name) {
|
|
55
|
+
return "Error: 'name' is required to switch topics.";
|
|
56
|
+
}
|
|
57
|
+
const existing = db
|
|
58
|
+
.query<{ name: string }, [string]>(
|
|
59
|
+
`SELECT name FROM conversation_topics WHERE name = ? AND status = 'active'`
|
|
60
|
+
)
|
|
61
|
+
.get(name);
|
|
62
|
+
|
|
63
|
+
if (!existing) {
|
|
64
|
+
db.prepare(
|
|
65
|
+
`INSERT OR IGNORE INTO conversation_topics (name) VALUES (?)`
|
|
66
|
+
).run(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
db.prepare(
|
|
70
|
+
`UPDATE conversation_topics SET last_message_at = datetime('now') WHERE name = ?`
|
|
71
|
+
).run(name);
|
|
72
|
+
activeTopic = name;
|
|
73
|
+
return `Switched to topic: ${name}. Your conversation context is now scoped to this topic.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case "list": {
|
|
77
|
+
const topics = db
|
|
78
|
+
.query<
|
|
79
|
+
{
|
|
80
|
+
name: string;
|
|
81
|
+
description: string | null;
|
|
82
|
+
last_message_at: string;
|
|
83
|
+
},
|
|
84
|
+
[]
|
|
85
|
+
>(
|
|
86
|
+
`SELECT name, description, last_message_at FROM conversation_topics WHERE status = 'active' ORDER BY last_message_at DESC`
|
|
87
|
+
)
|
|
88
|
+
.all();
|
|
89
|
+
|
|
90
|
+
if (topics.length === 0) {
|
|
91
|
+
return `No active topics yet. Create one with action 'create'.\n\nCurrent: ${activeTopic ?? "default"}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lines = topics.map((t) => {
|
|
95
|
+
const ago = formatTimeAgo(t.last_message_at);
|
|
96
|
+
const desc = t.description ? ` — ${t.description}` : "";
|
|
97
|
+
return `- ${t.name}${desc} (last used: ${ago})`;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return `Active topics:\n${lines.join("\n")}\n\nCurrent: ${activeTopic ?? "default"}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
case "archive": {
|
|
104
|
+
if (!name) {
|
|
105
|
+
return "Error: 'name' is required to archive a topic.";
|
|
106
|
+
}
|
|
107
|
+
const result = db.prepare(
|
|
108
|
+
`UPDATE conversation_topics SET status = 'archived' WHERE name = ? AND status = 'active'`
|
|
109
|
+
).run(name);
|
|
110
|
+
if (result.changes === 0) {
|
|
111
|
+
return `No active topic found with name '${name}'.`;
|
|
112
|
+
}
|
|
113
|
+
if (activeTopic === name) {
|
|
114
|
+
activeTopic = null;
|
|
115
|
+
}
|
|
116
|
+
return `Topic '${name}' archived. It will no longer appear in your active topics list.`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case "current": {
|
|
120
|
+
if (!activeTopic) {
|
|
121
|
+
return "default (no topic selected)";
|
|
122
|
+
}
|
|
123
|
+
return activeTopic;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
default:
|
|
127
|
+
return `Unknown action: ${action}. Use one of: create, switch, list, archive, current.`;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Get the current active topic (used by router to determine session key). */
|
|
134
|
+
export function getActiveTopic(): string | null {
|
|
135
|
+
return activeTopic;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Get the session ID for the current topic. */
|
|
139
|
+
export function getTopicSessionId(): string {
|
|
140
|
+
if (!activeTopic) return "owner";
|
|
141
|
+
const safe = activeTopic
|
|
142
|
+
.toLowerCase()
|
|
143
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
144
|
+
.replace(/^-|-$/g, "");
|
|
145
|
+
return `topic-${safe}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function formatTimeAgo(isoDate: string): string {
|
|
149
|
+
const then = new Date(isoDate + "Z").getTime();
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const diffMs = now - then;
|
|
152
|
+
|
|
153
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
154
|
+
if (minutes < 1) return "just now";
|
|
155
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
156
|
+
|
|
157
|
+
const hours = Math.floor(minutes / 60);
|
|
158
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
159
|
+
|
|
160
|
+
const days = Math.floor(hours / 24);
|
|
161
|
+
if (days === 1) return "yesterday";
|
|
162
|
+
if (days < 7) return `${days} days ago`;
|
|
163
|
+
|
|
164
|
+
const weeks = Math.floor(days / 7);
|
|
165
|
+
return `${weeks} week${weeks === 1 ? "" : "s"} ago`;
|
|
166
|
+
}
|
package/src/tools/mcp-client.ts
CHANGED
|
@@ -354,6 +354,12 @@ export class McpClient {
|
|
|
354
354
|
// --- Global MCP management ---
|
|
355
355
|
|
|
356
356
|
const mcpClients = new Map<string, McpClient>();
|
|
357
|
+
const failedMcpServers: { name: string; error: string }[] = [];
|
|
358
|
+
|
|
359
|
+
/** Returns list of MCP servers that failed to initialize. */
|
|
360
|
+
export function getFailedMcpServers(): { name: string; error: string }[] {
|
|
361
|
+
return failedMcpServers;
|
|
362
|
+
}
|
|
357
363
|
|
|
358
364
|
/**
|
|
359
365
|
* Initialize all configured MCP servers and register their tools.
|
|
@@ -361,6 +367,7 @@ const mcpClients = new Map<string, McpClient>();
|
|
|
361
367
|
export async function initMcpServers(
|
|
362
368
|
configs: McpServerConfig[]
|
|
363
369
|
): Promise<void> {
|
|
370
|
+
failedMcpServers.length = 0;
|
|
364
371
|
for (const config of configs) {
|
|
365
372
|
if (config.enabled === false) continue;
|
|
366
373
|
|
|
@@ -372,6 +379,7 @@ export async function initMcpServers(
|
|
|
372
379
|
mcpClients.set(config.name, client);
|
|
373
380
|
} catch (err: any) {
|
|
374
381
|
logger.error(`Failed to start MCP server "${config.name}"`, { error: err.message });
|
|
382
|
+
failedMcpServers.push({ name: config.name, error: err.message });
|
|
375
383
|
}
|
|
376
384
|
}
|
|
377
385
|
}
|
package/src/tools/permissions.ts
CHANGED
|
@@ -33,6 +33,13 @@ const DEFAULT_PERMISSIONS: Record<string, ToolPermission> = {
|
|
|
33
33
|
kg_query: "auto",
|
|
34
34
|
kg_update: "auto",
|
|
35
35
|
|
|
36
|
+
// Personal features — safe (user-facing data management)
|
|
37
|
+
todos: "auto",
|
|
38
|
+
notes: "auto",
|
|
39
|
+
preferences: "auto",
|
|
40
|
+
topics: "auto",
|
|
41
|
+
follow_ups: "auto",
|
|
42
|
+
|
|
36
43
|
// Built-in skills — safe (read-only or low risk)
|
|
37
44
|
web_search: "auto",
|
|
38
45
|
url_fetch: "auto",
|
|
@@ -1,28 +1,29 @@
|
|
|
1
|
-
import { describe, test, expect,
|
|
2
|
-
import { mkdtempSync, rmSync } from "fs";
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, appendFileSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
|
+
import { randomBytes } from "crypto";
|
|
5
6
|
|
|
6
|
-
// Override paths.sessions before importing session module
|
|
7
7
|
import { paths } from "../../src/config/paths";
|
|
8
|
-
|
|
9
|
-
let tempDir: string;
|
|
10
|
-
|
|
11
|
-
// Patch the sessions path to a temp directory before each test
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
tempDir = mkdtempSync(join(tmpdir(), "orba-session-test-"));
|
|
14
|
-
(paths as any).sessions = tempDir;
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
// Import after paths is available (the module reads paths at call time, not import time)
|
|
22
8
|
import { appendMessage, loadSession, sessionExists } from "../../src/agent/session";
|
|
23
9
|
import type { SessionMessage } from "../../src/agent/session";
|
|
24
10
|
|
|
25
11
|
describe("session management", () => {
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
let originalSessionsPath: string;
|
|
14
|
+
const uid = randomBytes(4).toString("hex");
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
tempDir = mkdtempSync(join(tmpdir(), "orba-session-test-"));
|
|
18
|
+
originalSessionsPath = (paths as any).sessions;
|
|
19
|
+
(paths as any).sessions = tempDir;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
(paths as any).sessions = originalSessionsPath;
|
|
24
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
26
27
|
test("appendMessage creates a session file and appends a message", () => {
|
|
27
28
|
const msg: SessionMessage = {
|
|
28
29
|
role: "user",
|
|
@@ -30,22 +31,22 @@ describe("session management", () => {
|
|
|
30
31
|
timestamp: new Date().toISOString(),
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
appendMessage(
|
|
34
|
-
expect(sessionExists(
|
|
34
|
+
appendMessage(`test-session-${uid}`, msg);
|
|
35
|
+
expect(sessionExists(`test-session-${uid}`)).toBe(true);
|
|
35
36
|
});
|
|
36
37
|
|
|
37
38
|
test("loadSession returns messages in order", () => {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
{ role: "assistant", content: "Second", timestamp: "2025-01-01T00:00:01Z" },
|
|
41
|
-
{ role: "user", content: "Third", timestamp: "2025-01-01T00:00:02Z" },
|
|
42
|
-
];
|
|
39
|
+
const id = `ordered-${uid}`;
|
|
40
|
+
const sessionFile = join(tempDir, `${id}.jsonl`);
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
const content = [
|
|
43
|
+
JSON.stringify({ role: "user", content: "First", timestamp: "2025-01-01T00:00:00Z" }),
|
|
44
|
+
JSON.stringify({ role: "assistant", content: "Second", timestamp: "2025-01-01T00:00:01Z" }),
|
|
45
|
+
JSON.stringify({ role: "user", content: "Third", timestamp: "2025-01-01T00:00:02Z" }),
|
|
46
|
+
].join("\n") + "\n";
|
|
47
|
+
writeFileSync(sessionFile, content);
|
|
47
48
|
|
|
48
|
-
const loaded = loadSession(
|
|
49
|
+
const loaded = loadSession(id);
|
|
49
50
|
expect(loaded).toHaveLength(3);
|
|
50
51
|
expect(loaded[0].content).toBe("First");
|
|
51
52
|
expect(loaded[1].content).toBe("Second");
|
|
@@ -53,24 +54,23 @@ describe("session management", () => {
|
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
test("loadSession respects the limit parameter", () => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
const id = `limited-${uid}`;
|
|
58
|
+
const sessionFile = join(tempDir, `${id}.jsonl`);
|
|
59
|
+
|
|
60
|
+
const lines = Array.from({ length: 10 }, (_, i) =>
|
|
61
|
+
JSON.stringify({ role: "user", content: `Message ${i}`, timestamp: new Date().toISOString() })
|
|
62
|
+
).join("\n") + "\n";
|
|
63
|
+
writeFileSync(sessionFile, lines);
|
|
63
64
|
|
|
64
|
-
const loaded = loadSession(
|
|
65
|
+
const loaded = loadSession(id, 3);
|
|
65
66
|
expect(loaded).toHaveLength(3);
|
|
66
|
-
// Should return the last 3 messages
|
|
67
67
|
expect(loaded[0].content).toBe("Message 7");
|
|
68
68
|
expect(loaded[1].content).toBe("Message 8");
|
|
69
69
|
expect(loaded[2].content).toBe("Message 9");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
test("loadSession returns empty array for nonexistent session", () => {
|
|
73
|
-
const loaded = loadSession(
|
|
73
|
+
const loaded = loadSession(`nonexistent-${uid}`);
|
|
74
74
|
expect(loaded).toEqual([]);
|
|
75
75
|
});
|
|
76
76
|
|
|
@@ -95,14 +95,12 @@ describe("session management", () => {
|
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
test("session ID allows colons for channel:userId format", () => {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
};
|
|
98
|
+
const id = `discord:user-${uid}`;
|
|
99
|
+
const sessionFile = join(tempDir, `${id}.jsonl`);
|
|
100
|
+
const msg = { role: "user", content: "Hello from channel", timestamp: new Date().toISOString() };
|
|
101
|
+
writeFileSync(sessionFile, JSON.stringify(msg) + "\n");
|
|
103
102
|
|
|
104
|
-
|
|
105
|
-
const loaded = loadSession("discord:user123");
|
|
103
|
+
const loaded = loadSession(id);
|
|
106
104
|
expect(loaded).toHaveLength(1);
|
|
107
105
|
expect(loaded[0].content).toBe("Hello from channel");
|
|
108
106
|
});
|