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.
Files changed (54) hide show
  1. package/.playwright-mcp/console-2026-02-16T19-06-21-167Z.log +31 -0
  2. package/README.md +2 -1
  3. package/dashboard-chat.png +0 -0
  4. package/dashboard-followups.png +0 -0
  5. package/dashboard-history.png +0 -0
  6. package/dashboard-integrations.png +0 -0
  7. package/dashboard-knowledge-ok.png +0 -0
  8. package/dashboard-knowledge.png +0 -0
  9. package/dashboard-notes-add.png +0 -0
  10. package/dashboard-notes-improved.png +0 -0
  11. package/dashboard-notes.png +0 -0
  12. package/dashboard-overview.png +0 -0
  13. package/dashboard-preferences.png +0 -0
  14. package/dashboard-settings-fixed.png +0 -0
  15. package/dashboard-settings.png +0 -0
  16. package/dashboard-skills-ok.png +0 -0
  17. package/dashboard-skills.png +0 -0
  18. package/dashboard-todos-add.png +0 -0
  19. package/dashboard-todos-improved.png +0 -0
  20. package/dashboard-todos-item.png +0 -0
  21. package/dashboard-todos-priority-badge.png +0 -0
  22. package/dashboard-todos.png +0 -0
  23. package/dashboard-topics.png +0 -0
  24. package/docs/ROADMAP.md +12 -49
  25. package/migrations/024_personal_features.sql +96 -0
  26. package/package.json +1 -1
  27. package/site/docs/index.html +11 -0
  28. package/site/docs/skills.html +107 -0
  29. package/site/index.html +9 -1
  30. package/src/agent/context.ts +3 -3
  31. package/src/agent/delegate.ts +7 -2
  32. package/src/agent/loop.ts +6 -6
  33. package/src/agent/prompts.ts +49 -1
  34. package/src/agent/workflow-executor.ts +5 -1
  35. package/src/channels/dashboard.html.ts +558 -6
  36. package/src/channels/webchat.ts +305 -27
  37. package/src/llm/claude-code.ts +58 -17
  38. package/src/llm/codex.ts +59 -18
  39. package/src/start.ts +12 -0
  40. package/src/tools/builtin/diagnose.ts +19 -5
  41. package/src/tools/builtin/follow-ups.ts +189 -0
  42. package/src/tools/builtin/notes.ts +207 -0
  43. package/src/tools/builtin/preferences.ts +173 -0
  44. package/src/tools/builtin/todos.ts +270 -0
  45. package/src/tools/builtin/topics.ts +166 -0
  46. package/src/tools/mcp-client.ts +8 -0
  47. package/src/tools/permissions.ts +7 -0
  48. package/tests/agent/session.test.ts +43 -45
  49. package/tests/mcp-registry.test.ts +32 -35
  50. package/tests/personal-features.test.ts +1251 -0
  51. package/tests/skill-registry.test.ts +1 -7
  52. package/tests/db/export.test.ts +0 -219
  53. package/tests/session.test.ts +0 -58
  54. package/tests/tools/executor.test.ts +0 -150
@@ -0,0 +1,189 @@
1
+ import { registerTool } from "../registry";
2
+ import { addCronJob, removeCronJob } from "../../scheduler/cron";
3
+ import { parseNaturalSchedule } from "../../scheduler/natural-cron";
4
+ import type { MessageRouter } from "../../channels/router";
5
+ import type { ZuboConfig } from "../../config/schema";
6
+ import type { LlmProvider } from "../../llm/provider";
7
+ import type { Database } from "bun:sqlite";
8
+
9
+ export function registerFollowUpsTool(
10
+ db: Database,
11
+ router: MessageRouter,
12
+ config: ZuboConfig,
13
+ llm?: LlmProvider
14
+ ) {
15
+ registerTool({
16
+ definition: {
17
+ name: "follow_ups",
18
+ description:
19
+ "Schedule, list, or cancel follow-up messages. Use this when you want to proactively check in with the user about something later — e.g., after they mention a dentist appointment, interview, or task they're working on. The follow-up fires as a proactive message at the scheduled time.",
20
+ input_schema: {
21
+ type: "object",
22
+ properties: {
23
+ action: {
24
+ type: "string",
25
+ enum: ["schedule", "list", "cancel"],
26
+ description:
27
+ "Action to perform: schedule (create a follow-up), list (show pending), cancel (remove one)",
28
+ },
29
+ context: {
30
+ type: "string",
31
+ description:
32
+ 'What the follow-up is about (for schedule). E.g., "dentist appointment", "job interview at Google"',
33
+ },
34
+ message: {
35
+ type: "string",
36
+ description:
37
+ 'The message to send when following up (for schedule). E.g., "Hey! How did the dentist appointment go?"',
38
+ },
39
+ delay: {
40
+ type: "string",
41
+ description:
42
+ 'When to follow up (for schedule). E.g., "in 2 hours", "tomorrow morning", "in 3 days"',
43
+ },
44
+ id: {
45
+ type: "number",
46
+ description: "Follow-up ID to cancel (for cancel)",
47
+ },
48
+ },
49
+ required: ["action"],
50
+ },
51
+ },
52
+ execute: async (input) => {
53
+ const { action, context, message, delay, id } = input as {
54
+ action: string;
55
+ context?: string;
56
+ message?: string;
57
+ delay?: string;
58
+ id?: number;
59
+ };
60
+
61
+ if (action === "schedule") {
62
+ if (!context || !message || !delay) {
63
+ return "Error: context, message, and delay are required to schedule a follow-up.";
64
+ }
65
+
66
+ // Parse the delay into a one-shot cron schedule
67
+ const delayText = delay.toLowerCase().startsWith("in ")
68
+ ? delay
69
+ : `in ${delay}`;
70
+ const parsed = parseNaturalSchedule(delayText);
71
+
72
+ if (typeof parsed === "string" || !parsed.once) {
73
+ return `Error: "${delay}" does not look like a one-shot delay. Use formats like "in 2 hours", "in 30 minutes", or "in 3 days".`;
74
+ }
75
+
76
+ const followUpAt = parsed.cron; // ISO 8601 string
77
+
78
+ // Insert the follow-up record
79
+ const result = db
80
+ .prepare(
81
+ "INSERT INTO follow_ups (context, message, follow_up_at) VALUES (?, ?, ?)"
82
+ )
83
+ .run(context, message, followUpAt);
84
+
85
+ const followUpId = Number(result.lastInsertRowid);
86
+ const cronName = `followup-${followUpId}`;
87
+
88
+ // Create a one-shot cron job to fire the proactive message
89
+ try {
90
+ addCronJob(
91
+ db,
92
+ cronName,
93
+ followUpAt,
94
+ message,
95
+ router,
96
+ config,
97
+ undefined,
98
+ llm,
99
+ true
100
+ );
101
+ } catch (err: any) {
102
+ // Clean up the follow-up record if cron creation fails
103
+ db.prepare("DELETE FROM follow_ups WHERE id = ?").run(followUpId);
104
+ return `Error scheduling follow-up: ${err.message}`;
105
+ }
106
+
107
+ const fireDate = new Date(followUpAt);
108
+ const fireTime = fireDate.toLocaleTimeString([], {
109
+ hour: "2-digit",
110
+ minute: "2-digit",
111
+ });
112
+ const fireDay = fireDate.toLocaleDateString([], {
113
+ weekday: "short",
114
+ month: "short",
115
+ day: "numeric",
116
+ });
117
+
118
+ return `Follow-up scheduled for ${fireDay} at ${fireTime}: "${message}"`;
119
+ }
120
+
121
+ if (action === "list") {
122
+ const rows = db
123
+ .query(
124
+ "SELECT * FROM follow_ups WHERE status = 'pending' ORDER BY follow_up_at"
125
+ )
126
+ .all() as Array<{
127
+ id: number;
128
+ context: string;
129
+ message: string;
130
+ follow_up_at: string;
131
+ }>;
132
+
133
+ if (rows.length === 0) return "No pending follow-ups.";
134
+
135
+ return rows
136
+ .map((row) => {
137
+ const relative = formatRelativeTime(new Date(row.follow_up_at));
138
+ return `${row.id}. ${row.context} — ${relative}\n Message: "${row.message}"`;
139
+ })
140
+ .join("\n\n");
141
+ }
142
+
143
+ if (action === "cancel") {
144
+ if (!id) {
145
+ return "Error: id is required to cancel a follow-up.";
146
+ }
147
+
148
+ const row = db
149
+ .query("SELECT * FROM follow_ups WHERE id = ? AND status = 'pending'")
150
+ .get(id) as { id: number } | null;
151
+
152
+ if (!row) {
153
+ return `No pending follow-up found with ID ${id}.`;
154
+ }
155
+
156
+ db.prepare(
157
+ "UPDATE follow_ups SET status = 'cancelled' WHERE id = ?"
158
+ ).run(id);
159
+
160
+ // Try to remove the corresponding cron job
161
+ const cronName = `followup-${id}`;
162
+ removeCronJob(db, cronName);
163
+
164
+ return `Follow-up #${id} cancelled.`;
165
+ }
166
+
167
+ return `Unknown action: ${action}. Use schedule, list, or cancel.`;
168
+ },
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Format a future date as a human-readable relative time string.
174
+ */
175
+ function formatRelativeTime(date: Date): string {
176
+ const now = Date.now();
177
+ const diffMs = date.getTime() - now;
178
+
179
+ if (diffMs < 0) return "overdue";
180
+
181
+ const minutes = Math.floor(diffMs / 60_000);
182
+ const hours = Math.floor(diffMs / 3_600_000);
183
+ const days = Math.floor(diffMs / 86_400_000);
184
+
185
+ if (minutes < 1) return "any moment now";
186
+ if (minutes < 60) return `in ${minutes} minute${minutes === 1 ? "" : "s"}`;
187
+ if (hours < 24) return `in ${hours} hour${hours === 1 ? "" : "s"}`;
188
+ return `in ${days} day${days === 1 ? "" : "s"}`;
189
+ }
@@ -0,0 +1,207 @@
1
+ import { registerTool } from "../registry";
2
+ import { getDb } from "../../db/connection";
3
+
4
+ interface NoteRow {
5
+ id: number;
6
+ title: string;
7
+ content: string;
8
+ tags: string;
9
+ pinned: number;
10
+ created_at: string;
11
+ updated_at: string;
12
+ }
13
+
14
+ export function registerNotesTool() {
15
+ registerTool({
16
+ definition: {
17
+ name: "notes",
18
+ description:
19
+ "Manage the user's notes. Save, search, list, update, delete, or pin notes with tags and full-text search.",
20
+ input_schema: {
21
+ type: "object",
22
+ properties: {
23
+ action: {
24
+ type: "string",
25
+ enum: ["save", "search", "list", "update", "delete", "pin"],
26
+ description: "The action to perform on notes.",
27
+ },
28
+ title: {
29
+ type: "string",
30
+ description: "Title of the note (for save).",
31
+ },
32
+ content: {
33
+ type: "string",
34
+ description: "Content of the note (for save/update).",
35
+ },
36
+ tags: {
37
+ type: "string",
38
+ description:
39
+ "Comma-separated tags (for save/update). Example: 'work, ideas'.",
40
+ },
41
+ query: {
42
+ type: "string",
43
+ description: "Search query for full-text search (for search).",
44
+ },
45
+ id: {
46
+ type: "number",
47
+ description: "Note ID (for update/delete/pin).",
48
+ },
49
+ limit: {
50
+ type: "number",
51
+ description: "Maximum number of results to return (for list/search). Default: 10.",
52
+ },
53
+ },
54
+ required: ["action"],
55
+ },
56
+ },
57
+ execute: async (input) => {
58
+ const db = getDb();
59
+ const action = input.action as string;
60
+
61
+ if (action === "save") {
62
+ const title = input.title as string;
63
+ const content = input.content as string;
64
+ if (!title) return "Error: title is required to save a note.";
65
+ if (!content) return "Error: content is required to save a note.";
66
+
67
+ const tags = input.tags
68
+ ? JSON.stringify(
69
+ (input.tags as string).split(",").map((t) => t.trim()).filter((t) => t.length > 0)
70
+ )
71
+ : "[]";
72
+
73
+ const result = db
74
+ .prepare(
75
+ "INSERT INTO notes (title, content, tags) VALUES (?, ?, ?)"
76
+ )
77
+ .run(title, content, tags);
78
+
79
+ return `Note saved: #${result.lastInsertRowid} ${title}`;
80
+ }
81
+
82
+ if (action === "search") {
83
+ const query = input.query as string;
84
+ if (!query) return "Error: query is required for search.";
85
+
86
+ const limit = (input.limit as number) || 10;
87
+
88
+ const rows = db
89
+ .prepare(
90
+ `SELECT n.* FROM notes n
91
+ JOIN notes_fts f ON n.id = f.rowid
92
+ WHERE notes_fts MATCH ?
93
+ ORDER BY rank
94
+ LIMIT ?`
95
+ )
96
+ .all(query, limit) as NoteRow[];
97
+
98
+ if (rows.length === 0) return `No notes found matching "${query}".`;
99
+
100
+ const lines = rows.map((row) => formatNote(row));
101
+ return `Search results (${rows.length} found):\n${lines.join("\n")}`;
102
+ }
103
+
104
+ if (action === "list") {
105
+ const limit = (input.limit as number) || 10;
106
+
107
+ const rows = db
108
+ .prepare(
109
+ "SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC LIMIT ?"
110
+ )
111
+ .all(limit) as NoteRow[];
112
+
113
+ if (rows.length === 0) return "No notes found.";
114
+
115
+ const lines = rows.map((row) => formatNote(row));
116
+ return `Notes (${rows.length} total):\n${lines.join("\n")}`;
117
+ }
118
+
119
+ if (action === "update") {
120
+ const id = input.id as number;
121
+ if (!id) return "Error: id is required to update a note.";
122
+
123
+ const updates: string[] = [];
124
+ const values: (string | null)[] = [];
125
+
126
+ if (input.title) {
127
+ updates.push("title = ?");
128
+ values.push(input.title as string);
129
+ }
130
+ if (input.content) {
131
+ updates.push("content = ?");
132
+ values.push(input.content as string);
133
+ }
134
+ if (input.tags !== undefined) {
135
+ updates.push("tags = ?");
136
+ values.push(
137
+ JSON.stringify(
138
+ (input.tags as string).split(",").map((t) => t.trim()).filter((t) => t.length > 0)
139
+ )
140
+ );
141
+ }
142
+
143
+ if (updates.length === 0)
144
+ return "Error: provide at least one field to update.";
145
+
146
+ updates.push("updated_at = datetime('now')");
147
+ values.push(String(id));
148
+
149
+ const changes = db
150
+ .prepare(`UPDATE notes SET ${updates.join(", ")} WHERE id = ?`)
151
+ .run(...values);
152
+
153
+ if (changes.changes === 0) return `No note found with id #${id}.`;
154
+ return `Updated note #${id}.`;
155
+ }
156
+
157
+ if (action === "delete") {
158
+ const id = input.id as number;
159
+ if (!id) return "Error: id is required to delete a note.";
160
+
161
+ const changes = db
162
+ .prepare("DELETE FROM notes WHERE id = ?")
163
+ .run(id);
164
+
165
+ if (changes.changes === 0) return `No note found with id #${id}.`;
166
+ return `Deleted note #${id}.`;
167
+ }
168
+
169
+ if (action === "pin") {
170
+ const id = input.id as number;
171
+ if (!id) return "Error: id is required to pin/unpin a note.";
172
+
173
+ const changes = db
174
+ .prepare("UPDATE notes SET pinned = NOT pinned WHERE id = ?")
175
+ .run(id);
176
+
177
+ if (changes.changes === 0) return `No note found with id #${id}.`;
178
+
179
+ const row = db
180
+ .prepare("SELECT pinned FROM notes WHERE id = ?")
181
+ .get(id) as { pinned: number } | null;
182
+
183
+ const state = row?.pinned ? "pinned" : "unpinned";
184
+ return `Note #${id} is now ${state}.`;
185
+ }
186
+
187
+ return `Unknown action: ${action}. Use save, search, list, update, delete, or pin.`;
188
+ },
189
+ });
190
+ }
191
+
192
+ function formatNote(row: NoteRow): string {
193
+ const pin = row.pinned ? "📌 " : "";
194
+ const preview =
195
+ row.content.length > 80
196
+ ? row.content.slice(0, 80) + "..."
197
+ : row.content;
198
+ let line = `${pin}#${row.id} ${row.title}`;
199
+
200
+ if (row.tags && row.tags !== "[]") {
201
+ const tagList = JSON.parse(row.tags) as string[];
202
+ if (tagList.length > 0) line += ` [${tagList.join(", ")}]`;
203
+ }
204
+
205
+ line += ` — ${preview}`;
206
+ return line;
207
+ }
@@ -0,0 +1,173 @@
1
+ import { registerTool } from "../registry";
2
+
3
+ export function registerPreferencesTool() {
4
+ registerTool({
5
+ definition: {
6
+ name: "preferences",
7
+ description:
8
+ "Manage user preferences. Use this to remember things the user likes, prefers, or wants you to know about them. Preferences are organized by category (communication, work, coding, food, schedule, general, etc.) and stored as key-value pairs.",
9
+ input_schema: {
10
+ type: "object",
11
+ properties: {
12
+ action: {
13
+ type: "string",
14
+ enum: ["set", "get", "list", "remove"],
15
+ description:
16
+ "Action to perform: set (save a preference), get (retrieve one), list (show all), remove (delete one)",
17
+ },
18
+ category: {
19
+ type: "string",
20
+ description:
21
+ 'Category for the preference (e.g., "communication", "work", "coding", "food", "schedule", "general")',
22
+ },
23
+ key: {
24
+ type: "string",
25
+ description:
26
+ 'Preference key (e.g., "preferred_language", "meeting_time", "coffee_order")',
27
+ },
28
+ value: {
29
+ type: "string",
30
+ description: "Preference value (required for set)",
31
+ },
32
+ confidence: {
33
+ type: "number",
34
+ description:
35
+ "Confidence level 0-1. Defaults to 0.9 for explicit user-set preferences.",
36
+ },
37
+ },
38
+ required: ["action"],
39
+ },
40
+ },
41
+ execute: async (input) => {
42
+ const { action, category, key, value, confidence } = input as {
43
+ action: string;
44
+ category?: string;
45
+ key?: string;
46
+ value?: string;
47
+ confidence?: number;
48
+ };
49
+
50
+ const { getDb } = await import("../../db/connection");
51
+ const db = getDb();
52
+
53
+ if (action === "set") {
54
+ if (!category || !key || !value) {
55
+ return "Error: category, key, and value are required to set a preference.";
56
+ }
57
+ const conf = confidence ?? 0.9;
58
+ db.prepare(
59
+ `INSERT OR REPLACE INTO user_preferences (category, key, value, confidence, source, updated_at)
60
+ VALUES (?, ?, ?, ?, 'explicit', datetime('now'))`
61
+ ).run(category, key, value, conf);
62
+ return `Preference saved: ${category}/${key} = ${value}`;
63
+ }
64
+
65
+ if (action === "get") {
66
+ if (!category || !key) {
67
+ return "Error: category and key are required to get a preference.";
68
+ }
69
+ const row = db
70
+ .query("SELECT value FROM user_preferences WHERE category = ? AND key = ?")
71
+ .get(category, key) as { value: string } | null;
72
+ if (!row) return "No preference found.";
73
+ return row.value;
74
+ }
75
+
76
+ if (action === "list") {
77
+ const rows = db
78
+ .query("SELECT * FROM user_preferences ORDER BY category, key")
79
+ .all() as Array<{
80
+ category: string;
81
+ key: string;
82
+ value: string;
83
+ confidence: number;
84
+ }>;
85
+
86
+ if (rows.length === 0) return "No preferences saved yet.";
87
+
88
+ let result = "";
89
+ let currentCategory = "";
90
+ for (const row of rows) {
91
+ if (row.category !== currentCategory) {
92
+ currentCategory = row.category;
93
+ result += `\n## ${capitalize(currentCategory)}\n`;
94
+ }
95
+ result += `- ${row.key}: ${row.value} (confidence: ${row.confidence})\n`;
96
+ }
97
+ return result.trim();
98
+ }
99
+
100
+ if (action === "remove") {
101
+ if (!category || !key) {
102
+ return "Error: category and key are required to remove a preference.";
103
+ }
104
+ const changes = db
105
+ .prepare("DELETE FROM user_preferences WHERE category = ? AND key = ?")
106
+ .run(category, key).changes;
107
+ if (changes > 0) return `Preference removed: ${category}/${key}`;
108
+ return `No preference found for ${category}/${key}.`;
109
+ }
110
+
111
+ return `Unknown action: ${action}. Use set, get, list, or remove.`;
112
+ },
113
+ });
114
+ }
115
+
116
+ function capitalize(s: string): string {
117
+ return s.charAt(0).toUpperCase() + s.slice(1);
118
+ }
119
+
120
+ /**
121
+ * Detect obvious preference signals in a user message.
122
+ * This is intentionally conservative — the LLM should handle nuanced
123
+ * preference extraction by calling the preferences tool directly.
124
+ */
125
+ export function extractPreferences(
126
+ message: string
127
+ ): Array<{ category: string; key: string; value: string }> {
128
+ const prefs: Array<{ category: string; key: string; value: string }> = [];
129
+ const _lower = message.toLowerCase();
130
+
131
+ // "I prefer X" / "I like X" / "I always use X"
132
+ const _preferPatterns = [
133
+ /i (?:prefer|like|love|always use|usually use|tend to use)\s+(.+?)(?:\s+(?:for|when|over|instead)\b|[.,!]|$)/gi,
134
+ /my (?:favorite|preferred|go-to)\s+(\w+)\s+is\s+(.+?)(?:[.,!]|$)/gi,
135
+ /i'm a (\w+)\s+person/gi,
136
+ /(?:call me|my name is|i'm|i am)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/g,
137
+ ];
138
+
139
+ // Don't try to be too smart — just detect obvious signals
140
+ // The LLM should handle the actual preference extraction via the tool
141
+ return prefs;
142
+ }
143
+
144
+ /**
145
+ * Load all user preferences into a formatted string suitable for
146
+ * injection into the system prompt, giving the agent context about the user.
147
+ */
148
+ export async function loadPreferencesContext(): Promise<string> {
149
+ try {
150
+ const { getDb } = await import("../../db/connection");
151
+ const db = getDb();
152
+ const rows = db
153
+ .query(
154
+ "SELECT category, key, value FROM user_preferences ORDER BY category, key"
155
+ )
156
+ .all() as Array<{ category: string; key: string; value: string }>;
157
+
158
+ if (rows.length === 0) return "";
159
+
160
+ let context = "## User preferences\n";
161
+ let currentCat = "";
162
+ for (const row of rows) {
163
+ if (row.category !== currentCat) {
164
+ currentCat = row.category;
165
+ context += `\n**${currentCat}:**\n`;
166
+ }
167
+ context += `- ${row.key}: ${row.value}\n`;
168
+ }
169
+ return context;
170
+ } catch {
171
+ return "";
172
+ }
173
+ }