this repo has no description
1/**
2 * Wins tools for Letta agents
3 *
4 * Provides tools for recording and summarizing tiny wins:
5 * - record_tiny_win: Record a small accomplishment
6 * - delete_tiny_win: Delete a win if recorded by mistake
7 * - get_wins_by_day: Get wins for a specific day (today, yesterday, or date)
8 * - get_wins_summary: Get a summary of recent wins
9 */
10
11import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
12import { db, schema } from '../db';
13import { registerTool, type ToolDefinition } from './dispatcher';
14
15/**
16 * Win categories
17 */
18export type WinCategory = 'task' | 'habit' | 'self_care' | 'social' | 'work' | 'creative' | 'other';
19
20/**
21 * Win magnitude levels
22 */
23export type WinMagnitude = 'tiny' | 'small' | 'medium' | 'big';
24
25/**
26 * Arguments for record_tiny_win tool
27 */
28export interface RecordTinyWinArgs {
29 /** What the user accomplished */
30 content: string;
31 /** Category of the win */
32 category?: WinCategory;
33 /** How significant the win is */
34 magnitude?: WinMagnitude;
35}
36
37/**
38 * Result from record_tiny_win tool
39 */
40export interface RecordTinyWinResult {
41 /** ID of saved win */
42 id: string;
43 /** Celebratory message */
44 message: string;
45 /** Running total of wins today */
46 todayCount: number;
47}
48
49/**
50 * Arguments for delete_tiny_win tool
51 */
52export interface DeleteTinyWinArgs {
53 /** ID of the win to delete */
54 id: string;
55}
56
57/**
58 * Result from delete_tiny_win tool
59 */
60export interface DeleteTinyWinResult {
61 /** Whether deletion was successful */
62 success: boolean;
63 /** Message describing the result */
64 message: string;
65 /** The deleted win content (for confirmation) */
66 deletedContent?: string;
67}
68
69/**
70 * Arguments for get_wins_by_day tool
71 */
72export interface GetWinsByDayArgs {
73 /** Period to get wins for: "today", "yesterday", or a specific date in YYYY-MM-DD format */
74 period: string;
75 /** Filter by category (optional) */
76 category?: WinCategory;
77}
78
79/**
80 * A win with timestamp
81 */
82export interface WinEntry {
83 id: string;
84 content: string;
85 category: string;
86 magnitude: string;
87 createdAt: string;
88 time: string;
89}
90
91/**
92 * Result from get_wins_by_day tool
93 */
94export interface GetWinsByDayResult {
95 /** The date being queried (YYYY-MM-DD) */
96 date: string;
97 /** Human-readable label (e.g., "Today", "Yesterday", "Monday, Dec 9") */
98 label: string;
99 /** Total wins on this day */
100 totalWins: number;
101 /** List of wins */
102 wins: WinEntry[];
103 /** Breakdown by category */
104 byCategory: Record<string, number>;
105}
106
107/**
108 * Arguments for get_wins_summary tool
109 */
110export interface GetWinsSummaryArgs {
111 /** Number of days to look back (default 7) */
112 days?: number;
113 /** Filter by category (optional) */
114 category?: WinCategory;
115 /** Maximum number of wins to return */
116 limit?: number;
117}
118
119/**
120 * Result from get_wins_summary tool
121 */
122export interface GetWinsSummaryResult {
123 /** Total wins in period */
124 totalWins: number;
125 /** Wins grouped by category */
126 byCategory: Record<string, number>;
127 /** Wins grouped by magnitude */
128 byMagnitude: Record<string, number>;
129 /** Recent wins list */
130 recentWins: {
131 id: string;
132 content: string;
133 category: string;
134 magnitude: string;
135 createdAt: string;
136 }[];
137 /** Streak info */
138 streak: {
139 currentDays: number;
140 message: string;
141 };
142}
143
144/**
145 * record_tiny_win tool - Record a small accomplishment
146 */
147export const recordTinyWinTool: ToolDefinition<RecordTinyWinArgs, RecordTinyWinResult> = registerTool({
148 name: 'record_tiny_win',
149 description:
150 'Record a tiny win or small accomplishment. Use this to celebrate even the smallest victories - getting out of bed, sending an email, drinking water. Every win counts!',
151 parameters: {
152 type: 'object',
153 properties: {
154 content: {
155 type: 'string',
156 description: 'What the user accomplished (e.g., "Got out of bed", "Replied to that email", "Ate breakfast")',
157 },
158 category: {
159 type: 'string',
160 enum: ['task', 'habit', 'self_care', 'social', 'work', 'creative', 'other'],
161 description:
162 'Category of the win: task (completed something), habit (daily routine), self_care (health/wellness), social (people interaction), work (job related), creative (making things), other',
163 },
164 magnitude: {
165 type: 'string',
166 enum: ['tiny', 'small', 'medium', 'big'],
167 description:
168 'How significant: tiny (just did it), small (took some effort), medium (meaningful achievement), big (major milestone)',
169 },
170 },
171 required: ['content'],
172 },
173 handler: async (args, context) => {
174 const id = crypto.randomUUID();
175
176 // Insert the win
177 await db.insert(schema.wins).values({
178 id,
179 userId: context.userId,
180 content: args.content,
181 category: args.category ?? 'other',
182 magnitude: args.magnitude ?? 'tiny',
183 });
184
185 // Count wins today
186 const todayStart = new Date();
187 todayStart.setHours(0, 0, 0, 0);
188
189 const todayWins = await db
190 .select({ count: sql<number>`count(*)` })
191 .from(schema.wins)
192 .where(and(eq(schema.wins.userId, context.userId), gte(schema.wins.createdAt, todayStart)));
193
194 const todayCount = todayWins[0]?.count ?? 1;
195
196 // Generate celebratory message based on magnitude and count
197 const messages = {
198 tiny: ['Nice! Every little step counts!', "That's a win!", 'You did it!'],
199 small: ['Good job! Keep that momentum going!', "Well done! You're on a roll!"],
200 medium: ['Impressive! That took real effort!', 'Amazing work! You should feel proud!'],
201 big: ['WOW! That is a major accomplishment!', "Incredible! You're crushing it!"],
202 };
203
204 const magnitude = args.magnitude ?? 'tiny';
205 const messageList = messages[magnitude];
206 const baseMessage = messageList[Math.floor(Math.random() * messageList.length)] ?? 'Great job!';
207
208 // Add streak bonus message if they have multiple wins today
209 let streakBonus = '';
210 if (todayCount >= 5) {
211 streakBonus = ` You've logged ${String(todayCount)} wins today - you're on fire!`;
212 } else if (todayCount >= 3) {
213 streakBonus = ` ${String(todayCount)} wins today - great momentum!`;
214 }
215
216 return {
217 id,
218 message: baseMessage + streakBonus,
219 todayCount,
220 };
221 },
222});
223
224/**
225 * delete_tiny_win tool - Delete a win recorded by mistake
226 */
227export const deleteTinyWinTool: ToolDefinition<DeleteTinyWinArgs, DeleteTinyWinResult> = registerTool({
228 name: 'delete_tiny_win',
229 description:
230 'Delete a tiny win that was recorded by mistake. Use this if the user wants to remove an incorrectly logged win.',
231 parameters: {
232 type: 'object',
233 properties: {
234 id: {
235 type: 'string',
236 description: 'The ID of the win to delete (from the wins list)',
237 },
238 },
239 required: ['id'],
240 },
241 handler: async (args, context) => {
242 // First, fetch the win to verify it exists and belongs to this user
243 const existing = await db
244 .select()
245 .from(schema.wins)
246 .where(and(eq(schema.wins.id, args.id), eq(schema.wins.userId, context.userId)))
247 .limit(1);
248
249 if (existing.length === 0) {
250 return {
251 success: false,
252 message: 'Win not found or you do not have permission to delete it.',
253 };
254 }
255
256 const win = existing[0];
257 if (!win) {
258 return {
259 success: false,
260 message: 'Win not found.',
261 };
262 }
263
264 // Delete the win
265 await db.delete(schema.wins).where(and(eq(schema.wins.id, args.id), eq(schema.wins.userId, context.userId)));
266
267 return {
268 success: true,
269 message: `Deleted win: "${win.content}"`,
270 deletedContent: win.content,
271 };
272 },
273});
274
275/**
276 * Helper function to format a date as YYYY-MM-DD in local timezone
277 */
278function formatLocalDate(date: Date): string {
279 const year = String(date.getFullYear());
280 const month = String(date.getMonth() + 1).padStart(2, '0');
281 const day = String(date.getDate()).padStart(2, '0');
282 return `${year}-${month}-${day}`;
283}
284
285/**
286 * Helper function to parse period string into date range
287 */
288function parsePeriodToDateRange(period: string): { start: Date; end: Date; label: string; dateStr: string } {
289 const now = new Date();
290 const today = new Date(now);
291 today.setHours(0, 0, 0, 0);
292
293 const periodLower = period.toLowerCase().trim();
294
295 if (periodLower === 'today') {
296 const end = new Date(today);
297 end.setDate(end.getDate() + 1);
298 return {
299 start: today,
300 end,
301 label: 'Today',
302 dateStr: formatLocalDate(today),
303 };
304 }
305
306 if (periodLower === 'yesterday') {
307 const start = new Date(today);
308 start.setDate(start.getDate() - 1);
309 return {
310 start,
311 end: today,
312 label: 'Yesterday',
313 dateStr: formatLocalDate(start),
314 };
315 }
316
317 // Try to parse as YYYY-MM-DD (use periodLower for consistent whitespace handling)
318 const dateMatch = /^\d{4}-\d{2}-\d{2}$/.exec(periodLower);
319 if (dateMatch) {
320 const start = new Date(periodLower + 'T00:00:00');
321
322 // Validate the date is actually valid (e.g., reject "2024-13-45")
323 if (isNaN(start.getTime())) {
324 // Invalid date, fall through to default (today)
325 } else {
326 const end = new Date(start);
327 end.setDate(end.getDate() + 1);
328
329 // Generate human-readable label
330 const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
331 const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
332 const dayOfWeek = dayNames[start.getDay()] ?? '';
333 const month = monthNames[start.getMonth()] ?? '';
334 const dayNum = start.getDate();
335
336 return {
337 start,
338 end,
339 label: `${dayOfWeek}, ${month} ${String(dayNum)}`,
340 dateStr: periodLower,
341 };
342 }
343 }
344
345 // Default to today if invalid
346 const end = new Date(today);
347 end.setDate(end.getDate() + 1);
348 return {
349 start: today,
350 end,
351 label: 'Today',
352 dateStr: formatLocalDate(today),
353 };
354}
355
356/**
357 * get_wins_by_day tool - Get wins for a specific day
358 */
359export const getWinsByDayTool: ToolDefinition<GetWinsByDayArgs, GetWinsByDayResult> = registerTool({
360 name: 'get_wins_by_day',
361 description:
362 'Get all wins for a specific day with timestamps. Use "today", "yesterday", or a date in YYYY-MM-DD format.',
363 parameters: {
364 type: 'object',
365 properties: {
366 period: {
367 type: 'string',
368 description: 'The day to get wins for: "today", "yesterday", or a specific date in YYYY-MM-DD format',
369 },
370 category: {
371 type: 'string',
372 enum: ['task', 'habit', 'self_care', 'social', 'work', 'creative', 'other'],
373 description: 'Filter by category (optional)',
374 },
375 },
376 required: ['period'],
377 },
378 handler: async (args, context) => {
379 const { start, end, label, dateStr } = parsePeriodToDateRange(args.period);
380
381 // Build where conditions
382 const conditions = [
383 eq(schema.wins.userId, context.userId),
384 gte(schema.wins.createdAt, start),
385 lt(schema.wins.createdAt, end),
386 ];
387
388 if (args.category) {
389 conditions.push(eq(schema.wins.category, args.category));
390 }
391
392 // Get wins for this day
393 const wins = await db
394 .select()
395 .from(schema.wins)
396 .where(and(...conditions))
397 .orderBy(desc(schema.wins.createdAt));
398
399 // Calculate by category
400 const byCategory: Record<string, number> = {};
401 for (const win of wins) {
402 byCategory[win.category] = (byCategory[win.category] ?? 0) + 1;
403 }
404
405 // Format wins with time
406 const formattedWins: WinEntry[] = wins.map((win) => {
407 const time = win.createdAt.toLocaleTimeString('en-US', {
408 hour: 'numeric',
409 minute: '2-digit',
410 hour12: true,
411 });
412 return {
413 id: win.id,
414 content: win.content,
415 category: win.category,
416 magnitude: win.magnitude,
417 createdAt: win.createdAt.toISOString(),
418 time,
419 };
420 });
421
422 return {
423 date: dateStr,
424 label,
425 totalWins: wins.length,
426 wins: formattedWins,
427 byCategory,
428 };
429 },
430});
431
432/**
433 * get_wins_summary tool - Get a summary of recent wins
434 */
435export const getWinsSummaryTool: ToolDefinition<GetWinsSummaryArgs, GetWinsSummaryResult> = registerTool({
436 name: 'get_wins_summary',
437 description:
438 'Get a summary of recent wins to see progress and patterns. Use this to remind the user of their accomplishments when they feel down or need motivation.',
439 parameters: {
440 type: 'object',
441 properties: {
442 days: {
443 type: 'integer',
444 minimum: 1,
445 maximum: 30,
446 description: 'Number of days to look back (default 7, max 30)',
447 },
448 category: {
449 type: 'string',
450 enum: ['task', 'habit', 'self_care', 'social', 'work', 'creative', 'other'],
451 description: 'Filter by category (optional)',
452 },
453 limit: {
454 type: 'integer',
455 minimum: 1,
456 maximum: 50,
457 description: 'Maximum number of wins to return in the list (default 10, max 50)',
458 },
459 },
460 required: [],
461 },
462 handler: async (args, context) => {
463 const days = args.days ?? 7;
464 const limit = args.limit ?? 10;
465
466 // Calculate date range
467 const startDate = new Date();
468 startDate.setDate(startDate.getDate() - days);
469 startDate.setHours(0, 0, 0, 0);
470
471 // Build where conditions
472 const conditions = [eq(schema.wins.userId, context.userId), gte(schema.wins.createdAt, startDate)];
473
474 if (args.category) {
475 conditions.push(eq(schema.wins.category, args.category));
476 }
477
478 // Get all wins in period
479 const wins = await db
480 .select()
481 .from(schema.wins)
482 .where(and(...conditions))
483 .orderBy(desc(schema.wins.createdAt));
484
485 // Calculate by category
486 const byCategory: Record<string, number> = {};
487 const byMagnitude: Record<string, number> = {};
488
489 for (const win of wins) {
490 byCategory[win.category] = (byCategory[win.category] ?? 0) + 1;
491 byMagnitude[win.magnitude] = (byMagnitude[win.magnitude] ?? 0) + 1;
492 }
493
494 // Calculate streak (consecutive days with at least one win)
495 const daysWithWins = new Set<string>();
496 for (const win of wins) {
497 const dateStr = win.createdAt.toISOString().split('T')[0];
498 if (dateStr !== undefined && dateStr !== '') {
499 daysWithWins.add(dateStr);
500 }
501 }
502
503 let currentStreak = 0;
504 const today = new Date();
505 for (let i = 0; i < days; i++) {
506 const checkDate = new Date(today);
507 checkDate.setDate(today.getDate() - i);
508 const dateStr = checkDate.toISOString().split('T')[0];
509 if (dateStr !== undefined && dateStr !== '' && daysWithWins.has(dateStr)) {
510 currentStreak++;
511 } else if (i > 0) {
512 // Allow today to not have wins yet (they might be adding one now)
513 break;
514 }
515 }
516
517 // Generate streak message
518 let streakMessage = 'Start your streak by logging wins each day!';
519 if (currentStreak >= 7) {
520 streakMessage = `Amazing! ${String(currentStreak)}-day streak! You're building great habits!`;
521 } else if (currentStreak >= 3) {
522 streakMessage = `Nice ${String(currentStreak)}-day streak going! Keep it up!`;
523 } else if (currentStreak >= 1) {
524 streakMessage = `${String(currentStreak)} day${currentStreak > 1 ? 's' : ''} with wins - building momentum!`;
525 }
526
527 // Format recent wins
528 const recentWins = wins.slice(0, limit).map((win) => ({
529 id: win.id,
530 content: win.content,
531 category: win.category,
532 magnitude: win.magnitude,
533 createdAt: win.createdAt.toISOString(),
534 }));
535
536 return {
537 totalWins: wins.length,
538 byCategory,
539 byMagnitude,
540 recentWins,
541 streak: {
542 currentDays: currentStreak,
543 message: streakMessage,
544 },
545 };
546 },
547});