this repo has no description
0
fork

Configure Feed

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

feat(wins): add delete_tiny_win and get_wins_by_day tools (#2)

* feat(wins): add delete_tiny_win and get_wins_by_day tools

Add two new Letta tools for the tiny wins system:

- delete_tiny_win: Allows deleting a win by ID if recorded by mistake
- get_wins_by_day: Get wins for a specific day with timestamps
- Supports "today", "yesterday", or YYYY-MM-DD date format
- Returns wins with time of day, category breakdown
- Human-readable date labels (e.g., "Monday, Dec 9")

The existing createdAt timestamps are now exposed in the API results,
enabling per-day breakdown views for tracking progress over time.

* docs: add tiny wins documentation to README and CLAUDE.md

- Add Features section to README with tiny wins tool reference
- Document all 4 wins tools (record, delete, get_wins_by_day, get_wins_summary)
- Add wins.ts to project structure
- Mark M4 milestone as complete
- Add Tiny Wins Tools section to CLAUDE.md for AI agent reference

* fix(wins): use local timezone for date string formatting

Fix timezone bug where dateStr used UTC via toISOString() but date
boundaries used local time. In positive UTC offset zones, early morning
queries could return a dateStr from the previous day.

Added formatLocalDate() helper that formats dates as YYYY-MM-DD using
local timezone components (getFullYear, getMonth, getDate) instead of
converting to UTC.

* fix(wins): validate date format and handle whitespace consistently

Address two bugbot review comments:

1. Invalid YYYY-MM-DD dates: Added isNaN(start.getTime()) check to
reject dates like "2024-13-45" that match the regex pattern but
create Invalid Date objects. Falls back to today on invalid dates.

2. Inconsistent whitespace handling: Changed regex to use periodLower
(trimmed input) instead of raw period, so " 2024-12-15" is handled
the same as "2024-12-15" rather than falling back to today.

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by

Alice
Claude
and committed by
GitHub
94554865 4307375e

+315 -3
+19
CLAUDE.md
··· 208 208 209 209 --- 210 210 211 + ## Tiny Wins Tools 212 + 213 + The bot includes tools for tracking small accomplishments in `src/tools/wins.ts`: 214 + 215 + | Tool | Purpose | Key Parameters | 216 + |------|---------|----------------| 217 + | `record_tiny_win` | Log an accomplishment | `content` (required), `category`, `magnitude` | 218 + | `delete_tiny_win` | Remove a mistaken entry | `id` (required) | 219 + | `get_wins_by_day` | Query wins for a specific day | `period` ("today", "yesterday", "YYYY-MM-DD"), `category` | 220 + | `get_wins_summary` | Get overview with streaks | `days` (1-30), `category`, `limit` | 221 + 222 + **Categories:** `task`, `habit`, `self_care`, `social`, `work`, `creative`, `other` 223 + 224 + **Magnitudes:** `tiny`, `small`, `medium`, `big` 225 + 226 + **Database:** Wins are stored in the `wins` table with timestamps (`createdAt`). 227 + 228 + --- 229 + 211 230 ## Code Quality (MANDATORY) 212 231 213 232 **CRITICAL**: All code changes MUST pass these checks before completion:
+27 -2
README.md
··· 213 213 │ ├── capture.ts # parse_brain_dump tool 214 214 │ ├── breakdown.ts # break_down_task tool 215 215 │ ├── items.ts # save_item, update_item tools 216 - │ └── context.ts # get_open_items tool 216 + │ ├── context.ts # get_open_items tool 217 + │ └── wins.ts # Tiny wins tools (record, delete, query) 217 218 ├── scripts/ 218 219 │ ├── setup-letta-provider.ts # Setup verification 219 220 │ └── cleanup-agents.ts # Delete stale Letta agents ··· 225 226 └── .env.example 226 227 ``` 227 228 229 + ## Features 230 + 231 + ### Tiny Wins 232 + 233 + The bot tracks small accomplishments to build momentum and combat ADHD-related feelings of underachievement. Every win counts! 234 + 235 + **Available tools:** 236 + 237 + | Tool | Description | 238 + |------|-------------| 239 + | `record_tiny_win` | Record an accomplishment with category and magnitude | 240 + | `delete_tiny_win` | Remove a win recorded by mistake | 241 + | `get_wins_by_day` | Get wins for today, yesterday, or a specific date | 242 + | `get_wins_summary` | Get summary with streaks and category breakdown | 243 + 244 + **Categories:** task, habit, self_care, social, work, creative, other 245 + 246 + **Magnitudes:** tiny (just did it), small (took effort), medium (meaningful), big (milestone) 247 + 248 + **Example interactions:** 249 + - "I drank water today" → Records a tiny self_care win 250 + - "What did I accomplish yesterday?" → Shows yesterday's wins with timestamps 251 + - "Delete that last win, I made a mistake" → Removes the incorrect entry 252 + 228 253 ## Milestones 229 254 230 255 - [x] **M0**: Infrastructure (Docker, config, health, Letta client) 231 256 - [x] **M1**: E2E Chat (Telegram bot, basic message flow) 232 257 - [x] **M2**: Tools + Items (database, capture, breakdown) 233 258 - [ ] **M3**: Tone + Detection (overwhelm, self-bullying) 234 - - [ ] **M4**: Tiny Wins (win tracking) 259 + - [x] **M4**: Tiny Wins (win tracking, daily breakdown, delete) 235 260 - [ ] **M5**: Threading (focus, deviations) 236 261 - [ ] **M6**: Hardening (idempotency, retries, tests) 237 262
+269 -1
src/tools/wins.ts
··· 3 3 * 4 4 * Provides tools for recording and summarizing tiny wins: 5 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) 6 8 * - get_wins_summary: Get a summary of recent wins 7 9 */ 8 10 9 - import { and, desc, eq, gte, sql } from 'drizzle-orm'; 11 + import { and, desc, eq, gte, lt, sql } from 'drizzle-orm'; 10 12 import { db, schema } from '../db'; 11 13 import { registerTool, type ToolDefinition } from './dispatcher'; 12 14 ··· 42 44 message: string; 43 45 /** Running total of wins today */ 44 46 todayCount: number; 47 + } 48 + 49 + /** 50 + * Arguments for delete_tiny_win tool 51 + */ 52 + export interface DeleteTinyWinArgs { 53 + /** ID of the win to delete */ 54 + id: string; 55 + } 56 + 57 + /** 58 + * Result from delete_tiny_win tool 59 + */ 60 + export 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 + */ 72 + export 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 + */ 82 + export 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 + */ 94 + export 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>; 45 105 } 46 106 47 107 /** ··· 157 217 id, 158 218 message: baseMessage + streakBonus, 159 219 todayCount, 220 + }; 221 + }, 222 + }); 223 + 224 + /** 225 + * delete_tiny_win tool - Delete a win recorded by mistake 226 + */ 227 + export 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 + */ 278 + function 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 + */ 288 + function 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 + */ 359 + export 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, 160 428 }; 161 429 }, 162 430 });