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,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
|
+
}
|