this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 547 lines 16 kB view raw
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});