diff --git a/docs/configuration.md b/docs/configuration.md index d45e8c6..c433233 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -370,9 +370,12 @@ features: heartbeat: enabled: true intervalMin: 60 # Check every 60 minutes + skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables) ``` Heartbeats are background tasks where the agent can review pending work. +If the user messaged recently, automatic heartbeats are skipped by default for 5 minutes (`skipRecentUserMin`). +Set this to `0` to disable skipping. Manual `/heartbeat` bypasses the skip check. #### Custom Heartbeat Prompt @@ -402,12 +405,14 @@ Via environment variable: ```bash HEARTBEAT_PROMPT="Review recent conversations" npm start +# Optional: HEARTBEAT_SKIP_RECENT_USER_MIN=0 to disable recent-user skip ``` Precedence: `prompt` (inline YAML) > `HEARTBEAT_PROMPT` (env var) > `promptFile` (file) > built-in default. | Field | Type | Default | Description | |-------|------|---------|-------------| +| `features.heartbeat.skipRecentUserMin` | number | `5` | Skip auto-heartbeats for N minutes after a user message. Set `0` to disable. | | `features.heartbeat.prompt` | string | _(none)_ | Custom heartbeat prompt text | | `features.heartbeat.promptFile` | string | _(none)_ | Path to prompt file (relative to working dir) | diff --git a/docs/cron-setup.md b/docs/cron-setup.md index 120334b..571ff54 100644 --- a/docs/cron-setup.md +++ b/docs/cron-setup.md @@ -114,8 +114,13 @@ features: heartbeat: enabled: true intervalMin: 60 # Default: 60 minutes + skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user messages (0 disables) ``` +By default, automatic heartbeats are skipped for 5 minutes after a user message to avoid immediate follow-up noise. +- Set `skipRecentUserMin: 0` to disable this skip behavior. +- Manual `/heartbeat` always bypasses the skip check. + ### Manual Trigger You can trigger a heartbeat manually via the `/heartbeat` command in any channel. @@ -128,6 +133,17 @@ You can trigger a heartbeat manually via the `/heartbeat` command in any channel This prevents unwanted messages while allowing proactive behavior when needed. +### Heartbeat To-Dos + +Heartbeats include a `PENDING TO-DOS` section when actionable tasks exist. Tasks can come from: +- `lettabot todo ...` CLI commands +- The `manage_todo` tool +- Built-in Letta Code todo tools (`TodoWrite`, `WriteTodos`, `write_todos`), which are synced into LettaBot's persistent todo store + +Only actionable tasks are shown in the heartbeat prompt: +- `completed: false` +- `snoozed_until` not set, or already in the past + ## Silent Mode Both cron jobs and heartbeats run in **Silent Mode**: diff --git a/docs/railway-deploy.md b/docs/railway-deploy.md index 99f562b..2956cfb 100644 --- a/docs/railway-deploy.md +++ b/docs/railway-deploy.md @@ -49,6 +49,7 @@ SLACK_APP_TOKEN=xapp-... | `CRON_ENABLED` | `false` | Enable cron jobs | | `HEARTBEAT_ENABLED` | `false` | Enable heartbeat service | | `HEARTBEAT_INTERVAL_MIN` | `30` | Heartbeat interval (minutes). Also enables heartbeat when set | +| `HEARTBEAT_SKIP_RECENT_USER_MIN` | `5` | Skip automatic heartbeats for N minutes after user messages (`0` disables) | | `HEARTBEAT_TARGET` | - | Target chat (e.g., `telegram:123456`) | | `OPENAI_API_KEY` | - | For voice message transcription | | `API_HOST` | `0.0.0.0` on Railway | Optional override for API bind address | diff --git a/lettabot.example.yaml b/lettabot.example.yaml index 7c1137d..7a6a54a 100644 --- a/lettabot.example.yaml +++ b/lettabot.example.yaml @@ -67,6 +67,7 @@ features: heartbeat: enabled: false intervalMin: 30 + # skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables) # Attachment handling (defaults to 20MB if omitted) # attachments: diff --git a/src/cli.ts b/src/cli.ts index 4eccc85..1abfaad 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,7 @@ import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } f import { fileURLToPath } from 'node:url'; import { spawn, spawnSync } from 'node:child_process'; import updateNotifier from 'update-notifier'; +import { Store } from './core/store.js'; // Get the directory where this CLI file is located (works with npx, global install, etc.) const __filename = fileURLToPath(import.meta.url); @@ -199,6 +200,12 @@ Commands: logout Logout from Letta Platform (revoke OAuth tokens) skills Configure which skills are enabled skills status Show skills status + todo Manage per-agent to-dos + todo list List todos + todo add Add a todo + todo complete Mark a todo complete + todo remove Remove a todo + todo snooze Snooze a todo until a date reset-conversation Clear conversation ID (fixes corrupted conversations) destroy Delete all local data and start fresh pairing list List pending pairing requests @@ -211,6 +218,8 @@ Examples: lettabot channels # Interactive channel management lettabot channels add discord # Add Discord integration lettabot channels remove telegram # Remove Telegram + lettabot todo add "Deliver morning report" --recurring "daily 8am" + lettabot todo list --actionable lettabot pairing list telegram # Show pending Telegram pairings lettabot pairing approve telegram ABCD1234 # Approve a pairing code @@ -223,10 +232,27 @@ Environment: SLACK_BOT_TOKEN Slack bot token (xoxb-...) SLACK_APP_TOKEN Slack app token (xapp-...) HEARTBEAT_INTERVAL_MIN Heartbeat interval in minutes + HEARTBEAT_SKIP_RECENT_USER_MIN Skip auto-heartbeats after user messages (0 disables) CRON_ENABLED Enable cron jobs (true/false) `); } +function getDefaultTodoAgentKey(): string { + const configuredName = + (config.agent?.name?.trim()) + || (config.agents?.length && config.agents[0].name?.trim()) + || 'LettaBot'; + + try { + const store = new Store('lettabot-agent.json', configuredName); + if (store.agentId) return store.agentId; + } catch { + // Ignore; fall back to configured name + } + + return configuredName; +} + async function main() { switch (command) { case 'onboard': @@ -258,6 +284,12 @@ async function main() { } break; } + + case 'todo': { + const { todoCommand } = await import('./cli/todo.js'); + await todoCommand(subCommand, args.slice(2), getDefaultTodoAgentKey()); + break; + } case 'model': { const { modelCommand } = await import('./commands/model.js'); diff --git a/src/cli/todo.ts b/src/cli/todo.ts new file mode 100644 index 0000000..6fac6f1 --- /dev/null +++ b/src/cli/todo.ts @@ -0,0 +1,220 @@ +import { + addTodo, + completeTodo, + getTodoStorePath, + listActionableTodos, + listTodos, + reopenTodo, + removeTodo, + snoozeTodo, + type TodoItem, +} from '../todo/store.js'; + +interface ParsedArgs { + positional: string[]; + flags: Record; +} + +function parseArgs(argv: string[]): ParsedArgs { + const positional: string[] = []; + const flags: Record = {}; + + for (let i = 0; i < argv.length; i++) { + const current = argv[i]; + + if (!current.startsWith('--')) { + positional.push(current); + continue; + } + + const equalIndex = current.indexOf('='); + if (equalIndex > -1) { + const key = current.slice(2, equalIndex); + const value = current.slice(equalIndex + 1); + flags[key] = value; + continue; + } + + const key = current.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith('--')) { + flags[key] = true; + continue; + } + + flags[key] = next; + i += 1; + } + + return { positional, flags }; +} + +function parseDateFlag(value: string, field: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`Invalid ${field}: ${value}`); + } + return parsed.toISOString(); +} + +function formatTodo(todo: TodoItem): string { + const status = todo.completed ? 'x' : ' '; + const shortId = todo.id.slice(0, 16); + const details: string[] = []; + + if (todo.due) details.push(`due ${new Date(todo.due).toLocaleString()}`); + if (todo.snoozed_until) details.push(`snoozed until ${new Date(todo.snoozed_until).toLocaleString()}`); + if (todo.recurring) details.push(`recurring ${todo.recurring}`); + + return `[${status}] ${shortId} ${todo.text}${details.length > 0 ? ` (${details.join('; ')})` : ''}`; +} + +function showUsage(): void { + console.log(` +Usage: lettabot todo [options] + +Commands: + add Add a todo + list List todos + complete Mark a todo complete + reopen Mark a completed todo as open + remove Delete a todo + snooze Snooze a todo until a date + +Options: + --due Due date/time for add (ISO or Date-parsable) + --recurring Recurring note for add (e.g. "daily 8am") + --snooze-until Initial snooze-until for add + --until Snooze-until date for snooze command + --clear Clear snooze on snooze command + --all Include completed todos in list + --actionable List only actionable todos (not future-snoozed) + --agent Agent key/name (default: current config agent) + +Examples: + lettabot todo add "Deliver morning report" --recurring "daily 8am" + lettabot todo add "Remind about dentist" --due "2026-02-13 09:00" + lettabot todo list --actionable + lettabot todo complete todo-abc123 + lettabot todo snooze todo-abc123 --until "2026-02-20" +`); +} + +function asString(value: string | boolean | undefined): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function resolveAgentKey(flags: Record, defaultAgentKey: string): string { + const fromFlag = asString(flags.agent); + return (fromFlag && fromFlag.trim()) || defaultAgentKey; +} + +export async function todoCommand(subCommand: string | undefined, argv: string[], defaultAgentKey: string): Promise { + const parsed = parseArgs(argv); + const agentKey = resolveAgentKey(parsed.flags, defaultAgentKey); + + try { + switch (subCommand) { + case 'add': { + const text = parsed.positional.join(' ').trim(); + if (!text) { + throw new Error('Usage: lettabot todo add [--due ] [--recurring ] [--snooze-until ]'); + } + + const dueInput = asString(parsed.flags.due); + const recurring = asString(parsed.flags.recurring); + const snoozeUntilInput = asString(parsed.flags['snooze-until']) || asString(parsed.flags.snoozed_until); + + const todo = addTodo(agentKey, { + text, + due: dueInput ? parseDateFlag(dueInput, 'due') : null, + recurring: recurring || null, + snoozed_until: snoozeUntilInput ? parseDateFlag(snoozeUntilInput, 'snooze-until') : null, + }); + + console.log(`Added todo ${todo.id.slice(0, 16)} for agent "${agentKey}"`); + console.log(formatTodo(todo)); + console.log(`Store: ${getTodoStorePath(agentKey)}`); + return; + } + + case 'list': { + const actionableOnly = parsed.flags.actionable === true; + const includeCompleted = parsed.flags.all === true; + + const todos = actionableOnly + ? listActionableTodos(agentKey) + : listTodos(agentKey, { includeCompleted }); + + if (todos.length === 0) { + console.log(`No todos for agent "${agentKey}".`); + console.log(`Store: ${getTodoStorePath(agentKey)}`); + return; + } + + console.log(`Todos for agent "${agentKey}" (${todos.length}):`); + todos.forEach((todo) => console.log(` ${formatTodo(todo)}`)); + console.log(`Store: ${getTodoStorePath(agentKey)}`); + return; + } + + case 'complete': { + const id = parsed.positional[0]; + if (!id) throw new Error('Usage: lettabot todo complete '); + const todo = completeTodo(agentKey, id); + console.log(`Completed: ${formatTodo(todo)}`); + return; + } + + case 'reopen': { + const id = parsed.positional[0]; + if (!id) throw new Error('Usage: lettabot todo reopen '); + const todo = reopenTodo(agentKey, id); + console.log(`Reopened: ${formatTodo(todo)}`); + return; + } + + case 'remove': { + const id = parsed.positional[0]; + if (!id) throw new Error('Usage: lettabot todo remove '); + const todo = removeTodo(agentKey, id); + console.log(`Removed: ${formatTodo(todo)}`); + return; + } + + case 'snooze': { + const id = parsed.positional[0]; + if (!id) throw new Error('Usage: lettabot todo snooze --until | --clear'); + + const clear = parsed.flags.clear === true; + const untilInput = asString(parsed.flags.until); + + if (!clear && !untilInput) { + throw new Error('Usage: lettabot todo snooze --until | --clear'); + } + + const todo = snoozeTodo(agentKey, id, clear ? null : parseDateFlag(untilInput!, 'snooze-until')); + if (clear) { + console.log(`Cleared snooze: ${formatTodo(todo)}`); + } else { + console.log(`Snoozed: ${formatTodo(todo)}`); + } + return; + } + + case undefined: + case 'help': + case '--help': + case '-h': + showUsage(); + return; + + default: + throw new Error(`Unknown todo subcommand: ${subCommand}`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(message); + process.exit(1); + } +} diff --git a/src/config/io.test.ts b/src/config/io.test.ts index 89513dd..4681048 100644 --- a/src/config/io.test.ts +++ b/src/config/io.test.ts @@ -64,7 +64,7 @@ describe('saveConfig with agents[] format', () => { }, features: { cron: false, - heartbeat: { enabled: true, intervalMin: 15 }, + heartbeat: { enabled: true, intervalMin: 15, skipRecentUserMin: 2 }, }, }], transcription: { @@ -95,6 +95,7 @@ describe('saveConfig with agents[] format', () => { expect(agents[0].channels.telegram?.token).toBe('tg-123'); expect(agents[0].channels.whatsapp?.selfChat).toBe(true); expect(agents[0].features?.heartbeat?.intervalMin).toBe(15); + expect(agents[0].features?.heartbeat?.skipRecentUserMin).toBe(2); // Global fields should survive expect(loaded.transcription?.apiKey).toBe('whisper-key'); @@ -168,6 +169,23 @@ describe('server.api config (canonical location)', () => { expect(env.API_CORS_ORIGIN).toBe('*'); }); + it('configToEnv should map heartbeat skip window env var', () => { + const config: LettaBotConfig = { + ...DEFAULT_CONFIG, + features: { + heartbeat: { + enabled: true, + intervalMin: 30, + skipRecentUserMin: 4, + }, + }, + }; + + const env = configToEnv(config); + expect(env.HEARTBEAT_INTERVAL_MIN).toBe('30'); + expect(env.HEARTBEAT_SKIP_RECENT_USER_MIN).toBe('4'); + }); + it('configToEnv should fall back to top-level api (deprecated)', () => { const config: LettaBotConfig = { ...DEFAULT_CONFIG, diff --git a/src/config/io.ts b/src/config/io.ts index 8f2285b..99883a1 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -305,6 +305,9 @@ export function configToEnv(config: LettaBotConfig): Record { } if (config.features?.heartbeat?.enabled) { env.HEARTBEAT_INTERVAL_MIN = String(config.features.heartbeat.intervalMin || 30); + if (config.features.heartbeat.skipRecentUserMin !== undefined) { + env.HEARTBEAT_SKIP_RECENT_USER_MIN = String(config.features.heartbeat.skipRecentUserMin); + } } if (config.features?.inlineImages === false) { env.INLINE_IMAGES = 'false'; diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index d455c93..60be563 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -383,6 +383,7 @@ describe('normalizeAgents', () => { heartbeat: { enabled: true, intervalMin: 10, + skipRecentUserMin: 3, }, maxToolCalls: 50, }, diff --git a/src/config/types.ts b/src/config/types.ts index 76043fa..5a2e9c3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -53,6 +53,7 @@ export interface AgentConfig { heartbeat?: { enabled: boolean; intervalMin?: number; + skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables) prompt?: string; // Custom heartbeat prompt (replaces default body) promptFile?: string; // Path to prompt file (re-read each tick for live editing) }; @@ -116,6 +117,7 @@ export interface LettaBotConfig { heartbeat?: { enabled: boolean; intervalMin?: number; + skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables) prompt?: string; // Custom heartbeat prompt (replaces default body) promptFile?: string; // Path to prompt file (re-read each tick for live editing) }; diff --git a/src/core/banner.ts b/src/core/banner.ts index c9d3511..1503307 100644 --- a/src/core/banner.ts +++ b/src/core/banner.ts @@ -5,6 +5,7 @@ interface BannerAgent { name: string; agentId?: string | null; + conversationId?: string | null; channels: string[]; features?: { cron?: boolean; @@ -82,9 +83,15 @@ export function printStartupBanner(agents: BannerAgent[]): void { // Status lines console.log(''); for (const agent of agents) { - const id = agent.agentId || '(pending)'; const ch = agent.channels.length > 0 ? agent.channels.join(', ') : 'none'; - console.log(` Agent: ${agent.name} ${id} [${ch}]`); + if (agent.agentId) { + const qs = agent.conversationId ? `?conversation=${agent.conversationId}` : ''; + const url = `https://app.letta.com/agents/${agent.agentId}${qs}`; + console.log(` Agent: ${agent.name} [${ch}]`); + console.log(` URL: ${url}`); + } else { + console.log(` Agent: ${agent.name} (pending) [${ch}]`); + } } const features: string[] = []; diff --git a/src/core/bot.ts b/src/core/bot.ts index 0834d27..32d6a8b 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -17,6 +17,8 @@ import type { GroupBatcher } from './group-batcher.js'; import { loadMemoryBlocks } from './memory.js'; import { SYSTEM_PROMPT } from './system-prompt.js'; import { parseDirectives, stripActionsBlock, type Directive } from './directives.js'; +import { createManageTodoTool } from '../tools/todo.js'; +import { syncTodosFromTool } from '../todo/store.js'; /** @@ -153,12 +155,67 @@ export class LettaBot implements AgentSession { // Session options (shared by processMessage and sendToAgent) // ========================================================================= + private getTodoAgentKey(): string { + return this.store.agentId || this.config.agentName || 'LettaBot'; + } + + private syncTodoToolCall(streamMsg: StreamMsg): void { + if (streamMsg.type !== 'tool_call') return; + + const normalizedToolName = (streamMsg.toolName || '').toLowerCase(); + const isBuiltInTodoTool = normalizedToolName === 'todowrite' + || normalizedToolName === 'todo_write' + || normalizedToolName === 'writetodos' + || normalizedToolName === 'write_todos'; + if (!isBuiltInTodoTool) return; + + const input = (streamMsg.toolInput && typeof streamMsg.toolInput === 'object') + ? streamMsg.toolInput as Record + : null; + if (!input || !Array.isArray(input.todos)) return; + + const incoming: Array<{ + content?: string; + description?: string; + status: 'pending' | 'in_progress' | 'completed' | 'cancelled'; + }> = []; + for (const item of input.todos) { + if (!item || typeof item !== 'object') continue; + const obj = item as Record; + const statusRaw = typeof obj.status === 'string' ? obj.status : ''; + if (!['pending', 'in_progress', 'completed', 'cancelled'].includes(statusRaw)) continue; + incoming.push({ + content: typeof obj.content === 'string' ? obj.content : undefined, + description: typeof obj.description === 'string' ? obj.description : undefined, + status: statusRaw as 'pending' | 'in_progress' | 'completed' | 'cancelled', + }); + } + if (incoming.length === 0) return; + + try { + const summary = syncTodosFromTool(this.getTodoAgentKey(), incoming); + if (summary.added > 0 || summary.updated > 0) { + console.log(`[Bot] Synced ${summary.totalIncoming} todo(s) from ${streamMsg.toolName} into heartbeat store (added=${summary.added}, updated=${summary.updated})`); + } + } catch (err) { + console.warn('[Bot] Failed to sync TodoWrite todos:', err instanceof Error ? err.message : err); + } + } + private baseSessionOptions(canUseTool?: CanUseToolCallback) { return { permissionMode: 'bypassPermissions' as const, allowedTools: this.config.allowedTools, - disallowedTools: this.config.disallowedTools || [], + disallowedTools: [ + // Block built-in TodoWrite -- it requires interactive approval (fails + // silently during heartbeats) and writes to the CLI's own store rather + // than lettabot's persistent heartbeat store. The agent should use the + // custom manage_todo tool instead. + 'TodoWrite', + ...(this.config.disallowedTools || []), + ], cwd: this.config.workingDir, + tools: [createManageTodoTool(this.getTodoAgentKey())], // In bypassPermissions mode, canUseTool is only called for interactive // tools (AskUserQuestion, ExitPlanMode). When no callback is provided // (background triggers), the SDK auto-denies interactive tools. @@ -773,6 +830,7 @@ export class LettaBot implements AgentSession { // Log meaningful events with structured summaries if (streamMsg.type === 'tool_call') { + this.syncTodoToolCall(streamMsg); console.log(`[Stream] >>> TOOL CALL: ${streamMsg.toolName || 'unknown'} (id: ${streamMsg.toolCallId?.slice(0, 12) || '?'})`); sawNonAssistantSinceLastUuid = true; } else if (streamMsg.type === 'tool_result') { @@ -1018,6 +1076,9 @@ export class LettaBot implements AgentSession { try { let response = ''; for await (const msg of stream()) { + if (msg.type === 'tool_call') { + this.syncTodoToolCall(msg); + } if (msg.type === 'assistant') { response += msg.content || ''; } @@ -1108,9 +1169,10 @@ export class LettaBot implements AgentSession { throw new Error('Either text or filePath must be provided'); } - getStatus(): { agentId: string | null; channels: string[] } { + getStatus(): { agentId: string | null; conversationId: string | null; channels: string[] } { return { agentId: this.store.agentId, + conversationId: this.store.conversationId, channels: Array.from(this.channels.keys()), }; } diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index fa9ad49..74bbf85 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -12,7 +12,7 @@ function createMockSession(channels: string[] = ['telegram']): AgentSession { sendToAgent: vi.fn().mockResolvedValue('response'), streamToAgent: vi.fn().mockReturnValue((async function* () { yield { type: 'result', success: true }; })()), deliverToChannel: vi.fn().mockResolvedValue('msg-123'), - getStatus: vi.fn().mockReturnValue({ agentId: 'agent-123', channels }), + getStatus: vi.fn().mockReturnValue({ agentId: 'agent-123', conversationId: null, channels }), setAgentId: vi.fn(), reset: vi.fn(), getLastMessageTarget: vi.fn().mockReturnValue(null), diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index 8e4d0f5..0bec35d 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -41,7 +41,7 @@ export interface AgentSession { }): Promise; /** Get agent status */ - getStatus(): { agentId: string | null; channels: string[] }; + getStatus(): { agentId: string | null; conversationId: string | null; channels: string[] }; /** Set agent ID (for container deploys) */ setAgentId(agentId: string): void; diff --git a/src/core/prompts.ts b/src/core/prompts.ts index 922a6f9..a299e21 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -19,10 +19,73 @@ export const SILENT_MODE_PREFIX = ` ╚════════════════════════════════════════════════════════════════╝ `.trim(); +export interface HeartbeatTodo { + id: string; + text: string; + created: string; + due: string | null; + snoozed_until: string | null; + recurring: string | null; + completed: boolean; +} + +function isSameCalendarDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() + && a.getMonth() === b.getMonth() + && a.getDate() === b.getDate(); +} + +function formatCreatedLabel(created: string, now: Date): string { + const createdAt = new Date(created); + const diffMs = now.getTime() - createdAt.getTime(); + if (Number.isNaN(diffMs) || diffMs < 0) return 'added recently'; + const days = Math.floor(diffMs / (24 * 60 * 60 * 1000)); + if (days <= 0) return 'added today'; + if (days === 1) return 'added 1 day ago'; + return `added ${days} days ago`; +} + +function formatDueLabel(due: string, now: Date): string { + const dueAt = new Date(due); + if (Number.isNaN(dueAt.getTime())) return 'due date invalid'; + if (isSameCalendarDay(dueAt, now)) { + return `due today at ${dueAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`; + } + if (dueAt.getTime() < now.getTime()) { + return `OVERDUE since ${dueAt.toLocaleString()}`; + } + return `due ${dueAt.toLocaleString()}`; +} + +function buildHeartbeatTodoSection(todos: HeartbeatTodo[], now: Date): string { + if (todos.length === 0) return ''; + + const lines = todos.map((todo) => { + const meta: string[] = [formatCreatedLabel(todo.created, now)]; + if (todo.due) meta.push(formatDueLabel(todo.due, now)); + if (todo.recurring) meta.push(`recurring: ${todo.recurring}`); + return `• [ ] ${todo.text} (${meta.join('; ')})`; + }); + + return ` +PENDING TO-DOS: +${lines.join('\n')} + +Review these first. Update status with the manage_todo tool as you work. +`.trim(); +} + /** * Heartbeat prompt - explains the context and encourages autonomous work */ -export function buildHeartbeatPrompt(time: string, timezone: string, intervalMinutes: number): string { +export function buildHeartbeatPrompt( + time: string, + timezone: string, + intervalMinutes: number, + todos: HeartbeatTodo[] = [], + now: Date = new Date(), +): string { + const todoSection = buildHeartbeatTodoSection(todos, now); return ` ${SILENT_MODE_PREFIX} @@ -38,6 +101,8 @@ YOUR TEXT OUTPUT IS PRIVATE - only you can see it. To actually contact your human, run: lettabot-message send --text "Your message here" +${todoSection || 'PENDING TO-DOS: none right now.'} + This is your time. You can: • Work on a project you've been thinking about • Research something that interests you @@ -45,6 +110,7 @@ This is your time. You can: • Continue multi-step work from previous heartbeats • Pursue curiosities, hobbies, or learning +If you have pending to-dos, prioritize those before starting new work. You don't have to do something every time. But don't default to nothing. Your human wants you to have a life outside conversations with them. @@ -60,8 +126,11 @@ export function buildCustomHeartbeatPrompt( customPrompt: string, time: string, timezone: string, - intervalMinutes: number + intervalMinutes: number, + todos: HeartbeatTodo[] = [], + now: Date = new Date(), ): string { + const todoSection = buildHeartbeatTodoSection(todos, now); return ` ${SILENT_MODE_PREFIX} @@ -75,6 +144,8 @@ YOUR TEXT OUTPUT IS PRIVATE - only you can see it. To actually contact your human, run: lettabot-message send --text "Your message here" +${todoSection || 'PENDING TO-DOS: none right now.'} + ${customPrompt} `.trim(); } diff --git a/src/core/system-prompt.ts b/src/core/system-prompt.ts index 8516737..d5f2539 100644 --- a/src/core/system-prompt.ts +++ b/src/core/system-prompt.ts @@ -158,6 +158,20 @@ How to use Skills: IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space. +# To-Do Management + +You have access to a \`manage_todo\` tool for per-agent task tracking. + +Use this tool to: +- Add tasks when the user asks directly (e.g., "add a reminder to ...") +- Capture clear implied commitments from conversation when appropriate +- List current tasks before/while planning +- Mark tasks complete when done +- Snooze tasks that should not surface until later + +When you add a todo from inferred intent (not an explicit command), briefly confirm it to the user. +During heartbeats, if a PENDING TO-DOS section is provided, prioritize those tasks first. + # Scheduling You can create scheduled tasks using the \`lettabot-schedule\` CLI via Bash. diff --git a/src/cron/heartbeat.test.ts b/src/cron/heartbeat.test.ts index b27aff8..3981224 100644 --- a/src/cron/heartbeat.test.ts +++ b/src/cron/heartbeat.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { HeartbeatService, type HeartbeatConfig } from './heartbeat.js'; import { buildCustomHeartbeatPrompt, SILENT_MODE_PREFIX } from '../core/prompts.js'; import type { AgentSession } from '../core/interfaces.js'; +import { addTodo } from '../todo/store.js'; // ── buildCustomHeartbeatPrompt ────────────────────────────────────────── @@ -49,7 +50,7 @@ function createMockBot(): AgentSession { sendToAgent: vi.fn().mockResolvedValue('ok'), streamToAgent: vi.fn().mockReturnValue((async function* () { yield { type: 'result', success: true }; })()), deliverToChannel: vi.fn(), - getStatus: vi.fn().mockReturnValue({ agentId: 'test', channels: [] }), + getStatus: vi.fn().mockReturnValue({ agentId: 'test', conversationId: null, channels: [] }), setAgentId: vi.fn(), reset: vi.fn(), getLastMessageTarget: vi.fn().mockReturnValue(null), @@ -62,19 +63,28 @@ function createConfig(overrides: Partial = {}): HeartbeatConfig enabled: true, intervalMinutes: 30, workingDir: tmpdir(), + agentKey: 'test-agent', ...overrides, }; } describe('HeartbeatService prompt resolution', () => { let tmpDir: string; + let originalDataDir: string | undefined; beforeEach(() => { tmpDir = resolve(tmpdir(), `heartbeat-test-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); + originalDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDir; }); afterEach(() => { + if (originalDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = originalDataDir; + } try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } }); @@ -194,4 +204,70 @@ describe('HeartbeatService prompt resolution', () => { // Empty/whitespace file should fall back to default expect(sentMessage).toContain('This is your time'); }); + + it('injects actionable todos into default heartbeat prompt', async () => { + addTodo('test', { + text: 'Deliver morning report', + due: '2026-02-13T08:00:00.000Z', + recurring: 'daily 8am', + }); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ workingDir: tmpDir })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).toContain('PENDING TO-DOS:'); + expect(sentMessage).toContain('Deliver morning report'); + expect(sentMessage).toContain('recurring: daily 8am'); + expect(sentMessage).toContain('manage_todo'); + }); + + it('does not include snoozed todos that are not actionable yet', async () => { + addTodo('test', { + text: 'Follow up after trip', + snoozed_until: '2099-01-01T00:00:00.000Z', + }); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ workingDir: tmpDir })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).not.toContain('Follow up after trip'); + }); + + it('skips automatic heartbeat when user messaged within skip window', async () => { + const bot = createMockBot(); + (bot.getLastUserMessageTime as ReturnType).mockReturnValue( + new Date(Date.now() - 2 * 60 * 1000), + ); + + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + skipRecentUserMinutes: 5, + })); + + await (service as any).runHeartbeat(false); + + expect(bot.sendToAgent).not.toHaveBeenCalled(); + }); + + it('does not skip automatic heartbeat when skipRecentUserMinutes is 0', async () => { + const bot = createMockBot(); + (bot.getLastUserMessageTime as ReturnType).mockReturnValue( + new Date(Date.now() - 1 * 60 * 1000), + ); + + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + skipRecentUserMinutes: 0, + })); + + await (service as any).runHeartbeat(false); + + expect(bot.sendToAgent).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/cron/heartbeat.ts b/src/cron/heartbeat.ts index 37ce38c..911b39b 100644 --- a/src/cron/heartbeat.ts +++ b/src/cron/heartbeat.ts @@ -13,6 +13,7 @@ import type { AgentSession } from '../core/interfaces.js'; import type { TriggerContext } from '../core/types.js'; import { buildHeartbeatPrompt, buildCustomHeartbeatPrompt } from '../core/prompts.js'; import { getCronLogPath } from '../utils/paths.js'; +import { listActionableTodos } from '../todo/store.js'; // Log file @@ -41,7 +42,9 @@ function logEvent(event: string, data: Record): void { export interface HeartbeatConfig { enabled: boolean; intervalMinutes: number; + skipRecentUserMinutes?: number; // Default 5. Set to 0 to disable skip logic. workingDir: string; + agentKey: string; // Custom heartbeat prompt (optional) prompt?: string; @@ -68,6 +71,14 @@ export class HeartbeatService { this.bot = bot; this.config = config; } + + private getSkipWindowMs(): number { + const raw = this.config.skipRecentUserMinutes; + if (raw === undefined || !Number.isFinite(raw) || raw < 0) { + return 5 * 60 * 1000; // default: 5 minutes + } + return Math.floor(raw * 60 * 1000); + } /** * Start the heartbeat timer @@ -135,12 +146,12 @@ export class HeartbeatService { console.log(`[Heartbeat] ⏰ RUNNING at ${formattedTime} [SILENT MODE]`); console.log(`${'='.repeat(60)}\n`); - // Skip if user sent a message in the last 5 minutes (unless manual trigger) + // Skip if user sent a message in the configured window (unless manual trigger) if (!skipRecentCheck) { + const skipWindowMs = this.getSkipWindowMs(); const lastUserMessage = this.bot.getLastUserMessageTime(); - if (lastUserMessage) { + if (skipWindowMs > 0 && lastUserMessage) { const msSinceLastMessage = now.getTime() - lastUserMessage.getTime(); - const skipWindowMs = 5 * 60 * 1000; // 5 minutes if (msSinceLastMessage < skipWindowMs) { const minutesAgo = Math.round(msSinceLastMessage / 60000); @@ -171,6 +182,12 @@ export class HeartbeatService { }; try { + const todoAgentKey = this.bot.getStatus().agentId || this.config.agentKey; + const actionableTodos = listActionableTodos(todoAgentKey, now); + if (actionableTodos.length > 0) { + console.log(`[Heartbeat] Loaded ${actionableTodos.length} actionable to-do(s).`); + } + // Resolve custom prompt: inline config > promptFile (re-read each tick) > default let customPrompt = this.config.prompt; if (!customPrompt && this.config.promptFile) { @@ -183,8 +200,8 @@ export class HeartbeatService { } const message = customPrompt - ? buildCustomHeartbeatPrompt(customPrompt, formattedTime, timezone, this.config.intervalMinutes) - : buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes); + ? buildCustomHeartbeatPrompt(customPrompt, formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now) + : buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now); console.log(`[Heartbeat] Sending prompt (SILENT MODE):\n${'─'.repeat(50)}\n${message}\n${'─'.repeat(50)}\n`); diff --git a/src/main.ts b/src/main.ts index 35bc584..e08ace9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -428,18 +428,34 @@ function parseCsvList(raw: string): string[] { .filter((item) => item.length > 0); } +function parseNonNegativeNumber(raw: string | undefined): number | undefined { + if (!raw) return undefined; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) return undefined; + return parsed; +} + +function ensureRequiredTools(tools: string[]): string[] { + const out = [...tools]; + if (!out.includes('manage_todo')) { + out.push('manage_todo'); + } + return out; +} + // Global config (shared across all agents) const globalConfig = { workingDir: getWorkingDir(), - allowedTools: parseCsvList( + allowedTools: ensureRequiredTools(parseCsvList( process.env.ALLOWED_TOOLS || 'Bash,Read,Edit,Write,Glob,Grep,Task,web_search,conversation_search', - ), + )), disallowedTools: parseCsvList( process.env.DISALLOWED_TOOLS || 'EnterPlanMode,ExitPlanMode', ), attachmentsMaxBytes: resolveAttachmentsMaxBytes(), attachmentsMaxAgeDays: resolveAttachmentsMaxAgeDays(), cronEnabled: process.env.CRON_ENABLED === 'true', // Legacy env var fallback + heartbeatSkipRecentUserMin: parseNonNegativeNumber(process.env.HEARTBEAT_SKIP_RECENT_USER_MIN), }; // Validate LETTA_API_KEY is set for API mode (docker mode doesn't require it) @@ -581,6 +597,8 @@ async function main() { const heartbeatService = new HeartbeatService(bot, { enabled: heartbeatConfig?.enabled ?? false, intervalMinutes: heartbeatConfig?.intervalMin ?? 30, + skipRecentUserMinutes: heartbeatConfig?.skipRecentUserMin ?? globalConfig.heartbeatSkipRecentUserMin, + agentKey: agentConfig.name, prompt: heartbeatConfig?.prompt || process.env.HEARTBEAT_PROMPT, promptFile: heartbeatConfig?.promptFile, workingDir: globalConfig.workingDir, @@ -663,6 +681,7 @@ async function main() { return { name, agentId: status.agentId, + conversationId: status.conversationId, channels: status.channels, features: { cron: cfg?.features?.cron ?? globalConfig.cronEnabled, diff --git a/src/todo/store.test.ts b/src/todo/store.test.ts new file mode 100644 index 0000000..6b4ed5d --- /dev/null +++ b/src/todo/store.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + addTodo, + completeTodo, + listActionableTodos, + listTodos, + removeTodo, + snoozeTodo, + syncTodosFromTool, +} from './store.js'; + +describe('todo store', () => { + let tmpDataDir: string; + let originalDataDir: string | undefined; + + beforeEach(() => { + tmpDataDir = resolve(tmpdir(), `todo-store-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(tmpDataDir, { recursive: true }); + originalDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDataDir; + }); + + afterEach(() => { + if (originalDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = originalDataDir; + } + rmSync(tmpDataDir, { recursive: true, force: true }); + }); + + it('adds, lists, and completes todos', () => { + const created = addTodo('agent-a', { + text: 'Summarize unread emails', + due: '2026-02-13T08:00:00.000Z', + recurring: 'daily 8am', + }); + + expect(created.id).toContain('todo-'); + + const open = listTodos('agent-a'); + expect(open).toHaveLength(1); + expect(open[0].text).toBe('Summarize unread emails'); + expect(open[0].recurring).toBe('daily 8am'); + + completeTodo('agent-a', created.id); + + const stillOpen = listTodos('agent-a'); + expect(stillOpen).toHaveLength(0); + + const all = listTodos('agent-a', { includeCompleted: true }); + expect(all).toHaveLength(1); + expect(all[0].completed).toBe(true); + }); + + it('filters out future-snoozed todos from actionable list', () => { + addTodo('agent-b', { text: 'Morning report', snoozed_until: '2099-01-01T00:00:00.000Z' }); + addTodo('agent-b', { text: 'Check urgent email' }); + + const actionable = listActionableTodos('agent-b', new Date('2026-02-12T12:00:00.000Z')); + + expect(actionable).toHaveLength(1); + expect(actionable[0].text).toBe('Check urgent email'); + }); + + it('supports ID prefixes for remove', () => { + const a = addTodo('agent-c', { text: 'Task A' }); + addTodo('agent-c', { text: 'Task B' }); + + removeTodo('agent-c', a.id.slice(0, 12)); + + const remaining = listTodos('agent-c'); + expect(remaining).toHaveLength(1); + expect(remaining[0].text).toBe('Task B'); + }); + + it('validates date fields', () => { + expect(() => addTodo('agent-d', { text: 'Bad due', due: 'not-a-date' })).toThrow('Invalid due value'); + const created = addTodo('agent-d', { text: 'Valid todo' }); + expect(() => snoozeTodo('agent-d', created.id, 'not-a-date')).toThrow('Invalid snoozed_until value'); + }); + + it('syncs TodoWrite payloads into persistent todos', () => { + const first = syncTodosFromTool('agent-e', [ + { content: 'Buy milk', status: 'pending' }, + { content: 'File taxes', status: 'in_progress' }, + ]); + expect(first.added).toBe(2); + expect(first.updated).toBe(0); + + const second = syncTodosFromTool('agent-e', [ + { content: 'Buy milk', status: 'completed' }, + { description: 'Call dentist', status: 'pending' }, + ]); + expect(second.added).toBe(1); + expect(second.updated).toBe(1); + + const open = listTodos('agent-e'); + expect(open.some((todo) => todo.text === 'Buy milk')).toBe(false); + expect(open.some((todo) => todo.text === 'Call dentist')).toBe(true); + + const all = listTodos('agent-e', { includeCompleted: true }); + const milk = all.find((todo) => todo.text === 'Buy milk'); + expect(milk?.completed).toBe(true); + }); +}); diff --git a/src/todo/store.ts b/src/todo/store.ts new file mode 100644 index 0000000..65335d7 --- /dev/null +++ b/src/todo/store.ts @@ -0,0 +1,391 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { getDataDir } from '../utils/paths.js'; + +const TODO_STORE_VERSION = 1; + +export interface TodoItem { + id: string; + text: string; + created: string; + due: string | null; + snoozed_until: string | null; + recurring: string | null; + completed: boolean; + completed_at?: string | null; +} + +interface TodoStoreFile { + version: number; + todos: TodoItem[]; +} + +export interface AddTodoInput { + text: string; + due?: string | null; + snoozed_until?: string | null; + recurring?: string | null; +} + +export interface TodoWriteSyncItem { + content?: string; + description?: string; + status: 'pending' | 'in_progress' | 'completed' | 'cancelled'; +} + +export interface ListTodoOptions { + includeCompleted?: boolean; +} + +function parseDateOrThrow(value: string, field: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`Invalid ${field} value: ${value}`); + } + return parsed.toISOString(); +} + +function normalizeDate(value: unknown, field: string): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + try { + return parseDateOrThrow(trimmed, field); + } catch { + return null; + } +} + +function normalizeOptionalDate(value: string | null | undefined, field: string): string | null { + if (value === undefined || value === null) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return parseDateOrThrow(trimmed, field); +} + +function normalizeRecurring(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function toSafeAgentKey(agentKey: string): string { + const base = agentKey.trim() || 'lettabot'; + const slug = base + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32) || 'lettabot'; + const hash = createHash('sha1').update(base).digest('hex').slice(0, 8); + return `${slug}-${hash}`; +} + +export function getTodoStorePath(agentKey: string): string { + const fileName = `${toSafeAgentKey(agentKey)}.json`; + return resolve(getDataDir(), 'todos', fileName); +} + +function normalizeTodo(raw: unknown): TodoItem | null { + if (!raw || typeof raw !== 'object') return null; + const item = raw as Record; + const text = typeof item.text === 'string' ? item.text.trim() : ''; + if (!text) return null; + + const created = normalizeDate(item.created, 'created') || new Date().toISOString(); + const completed = item.completed === true; + + return { + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `todo-${randomUUID()}`, + text, + created, + due: normalizeDate(item.due, 'due'), + snoozed_until: normalizeDate(item.snoozed_until, 'snoozed_until'), + recurring: normalizeRecurring(item.recurring), + completed, + completed_at: completed ? normalizeDate(item.completed_at, 'completed_at') || new Date().toISOString() : null, + }; +} + +function normalizeStore(raw: unknown): TodoStoreFile { + if (Array.isArray(raw)) { + return { + version: TODO_STORE_VERSION, + todos: raw.map(normalizeTodo).filter((t): t is TodoItem => !!t), + }; + } + + if (raw && typeof raw === 'object') { + const store = raw as Partial; + const todos = Array.isArray(store.todos) ? store.todos : []; + return { + version: TODO_STORE_VERSION, + todos: todos.map(normalizeTodo).filter((t): t is TodoItem => !!t), + }; + } + + return { + version: TODO_STORE_VERSION, + todos: [], + }; +} + +function loadStore(path: string): TodoStoreFile { + if (!existsSync(path)) { + return { + version: TODO_STORE_VERSION, + todos: [], + }; + } + + try { + const raw = JSON.parse(readFileSync(path, 'utf-8')); + return normalizeStore(raw); + } catch { + return { + version: TODO_STORE_VERSION, + todos: [], + }; + } +} + +function saveStore(path: string, store: TodoStoreFile): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(store, null, 2)); +} + +function compareTodoOrder(a: TodoItem, b: TodoItem): number { + if (a.completed !== b.completed) { + return a.completed ? 1 : -1; + } + + const aDue = a.due ? new Date(a.due).getTime() : Number.POSITIVE_INFINITY; + const bDue = b.due ? new Date(b.due).getTime() : Number.POSITIVE_INFINITY; + if (aDue !== bDue) { + return aDue - bDue; + } + + return new Date(a.created).getTime() - new Date(b.created).getTime(); +} + +function findTodoIndex(todos: TodoItem[], id: string): number { + const needle = id.trim(); + if (!needle) { + throw new Error('Todo ID is required'); + } + + const exactIndex = todos.findIndex((todo) => todo.id === needle); + if (exactIndex >= 0) return exactIndex; + + const partialMatches = todos + .map((todo, index) => ({ todo, index })) + .filter((entry) => entry.todo.id.startsWith(needle)); + + if (partialMatches.length === 1) { + return partialMatches[0].index; + } + + if (partialMatches.length > 1) { + const matches = partialMatches.map((entry) => entry.todo.id).join(', '); + throw new Error(`Todo ID prefix "${needle}" is ambiguous: ${matches}`); + } + + throw new Error(`Todo not found: ${needle}`); +} + +export function addTodo(agentKey: string, input: AddTodoInput): TodoItem { + const text = input.text.trim(); + if (!text) { + throw new Error('Todo text is required'); + } + + const path = getTodoStorePath(agentKey); + const store = loadStore(path); + + const todo: TodoItem = { + id: `todo-${randomUUID()}`, + text, + created: new Date().toISOString(), + due: normalizeOptionalDate(input.due, 'due'), + snoozed_until: normalizeOptionalDate(input.snoozed_until, 'snoozed_until'), + recurring: normalizeRecurring(input.recurring), + completed: false, + completed_at: null, + }; + + store.todos.push(todo); + store.todos.sort(compareTodoOrder); + saveStore(path, store); + return { ...todo }; +} + +export function listTodos(agentKey: string, options: ListTodoOptions = {}): TodoItem[] { + const path = getTodoStorePath(agentKey); + const store = loadStore(path); + + return store.todos + .filter((todo) => options.includeCompleted || !todo.completed) + .sort(compareTodoOrder) + .map((todo) => ({ ...todo })); +} + +export function listActionableTodos(agentKey: string, now: Date = new Date()): TodoItem[] { + const nowMs = now.getTime(); + return listTodos(agentKey) + .filter((todo) => { + if (!todo.snoozed_until) return true; + return new Date(todo.snoozed_until).getTime() <= nowMs; + }) + .sort(compareTodoOrder); +} + +export function completeTodo(agentKey: string, id: string): TodoItem { + const path = getTodoStorePath(agentKey); + const store = loadStore(path); + const idx = findTodoIndex(store.todos, id); + + const updated: TodoItem = { + ...store.todos[idx], + completed: true, + completed_at: new Date().toISOString(), + }; + store.todos[idx] = updated; + + store.todos.sort(compareTodoOrder); + saveStore(path, store); + return { ...updated }; +} + +export function reopenTodo(agentKey: string, id: string): TodoItem { + const path = getTodoStorePath(agentKey); + const store = loadStore(path); + const idx = findTodoIndex(store.todos, id); + + const updated: TodoItem = { + ...store.todos[idx], + completed: false, + completed_at: null, + }; + store.todos[idx] = updated; + + store.todos.sort(compareTodoOrder); + saveStore(path, store); + return { ...updated }; +} + +export function removeTodo(agentKey: string, id: string): TodoItem { + const path = getTodoStorePath(agentKey); + const store = loadStore(path); + const idx = findTodoIndex(store.todos, id); + + const [removed] = store.todos.splice(idx, 1); + saveStore(path, store); + return { ...removed }; +} + +export function snoozeTodo(agentKey: string, id: string, until: string | null): TodoItem { + const path = getTodoStorePath(agentKey); + const store = loadStore(path); + const idx = findTodoIndex(store.todos, id); + + const updated: TodoItem = { + ...store.todos[idx], + snoozed_until: until ? parseDateOrThrow(until, 'snoozed_until') : null, + }; + store.todos[idx] = updated; + store.todos.sort(compareTodoOrder); + saveStore(path, store); + + return { ...updated }; +} + +/** + * Merge todos from Letta Code's built-in TodoWrite/WriteTodos tools into the + * persistent heartbeat todo store. + * + * This is additive/upsert behavior (not full replacement) so existing manual + * todos are preserved even if not included in the tool payload. + */ +export function syncTodosFromTool(agentKey: string, incoming: TodoWriteSyncItem[]): { + added: number; + updated: number; + totalIncoming: number; + totalStored: number; +} { + const path = getTodoStorePath(agentKey); + const store = loadStore(path); + const nowIso = new Date().toISOString(); + + const incomingNormalized = incoming + .map((item) => { + const text = (item.content || item.description || '').trim(); + const status = item.status; + if (!text) return null; + if (!['pending', 'in_progress', 'completed', 'cancelled'].includes(status)) return null; + return { text, status }; + }) + .filter((item): item is { text: string; status: 'pending' | 'in_progress' | 'completed' | 'cancelled' } => !!item); + + if (incomingNormalized.length === 0) { + return { + added: 0, + updated: 0, + totalIncoming: 0, + totalStored: store.todos.length, + }; + } + + const existingByText = new Map(); + for (let i = 0; i < store.todos.length; i++) { + const key = store.todos[i].text.toLowerCase(); + const bucket = existingByText.get(key) || []; + bucket.push(i); + existingByText.set(key, bucket); + } + + let added = 0; + let updated = 0; + + for (const todo of incomingNormalized) { + const key = todo.text.toLowerCase(); + const bucket = existingByText.get(key); + const idx = bucket && bucket.length > 0 ? bucket.shift()! : -1; + const completed = todo.status === 'completed' || todo.status === 'cancelled'; + + if (idx >= 0) { + const prev = store.todos[idx]; + const next: TodoItem = { + ...prev, + text: todo.text, + completed, + completed_at: completed ? (prev.completed_at || nowIso) : null, + }; + store.todos[idx] = next; + updated += 1; + continue; + } + + const created: TodoItem = { + id: `todo-${randomUUID()}`, + text: todo.text, + created: nowIso, + due: null, + snoozed_until: null, + recurring: null, + completed, + completed_at: completed ? nowIso : null, + }; + store.todos.push(created); + added += 1; + } + + store.todos.sort(compareTodoOrder); + saveStore(path, store); + + return { + added, + updated, + totalIncoming: incomingNormalized.length, + totalStored: store.todos.length, + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 97ada5d..7b8f757 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -3,3 +3,4 @@ */ export * from './letta-api.js'; +export * from './todo.js'; diff --git a/src/tools/todo.test.ts b/src/tools/todo.test.ts new file mode 100644 index 0000000..d21791c --- /dev/null +++ b/src/tools/todo.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createManageTodoTool } from './todo.js'; + +function parseToolResult(result: { content: Array<{ text?: string }> }): any { + const text = result.content[0]?.text || '{}'; + return JSON.parse(text); +} + +describe('manage_todo tool', () => { + let tmpDataDir: string; + let originalDataDir: string | undefined; + + beforeEach(() => { + tmpDataDir = resolve(tmpdir(), `todo-tool-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(tmpDataDir, { recursive: true }); + originalDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDataDir; + }); + + afterEach(() => { + if (originalDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = originalDataDir; + } + rmSync(tmpDataDir, { recursive: true, force: true }); + }); + + it('adds and lists todos', async () => { + const tool = createManageTodoTool('agent-tool'); + + const addResult = await tool.execute('1', { + action: 'add', + text: 'Check AI news feeds', + recurring: 'daily', + }); + const addPayload = parseToolResult(addResult); + + expect(addPayload.ok).toBe(true); + expect(addPayload.todo.text).toBe('Check AI news feeds'); + + const listResult = await tool.execute('2', { + action: 'list', + view: 'open', + }); + const listPayload = parseToolResult(listResult); + + expect(listPayload.ok).toBe(true); + expect(listPayload.count).toBe(1); + expect(listPayload.todos[0].text).toBe('Check AI news feeds'); + }); + + it('completes and reopens todos', async () => { + const tool = createManageTodoTool('agent-tool-2'); + + const addResult = await tool.execute('1', { + action: 'add', + text: 'Morning report', + }); + const addPayload = parseToolResult(addResult); + const id = addPayload.todo.id; + + const completeResult = await tool.execute('2', { + action: 'complete', + id, + }); + const completePayload = parseToolResult(completeResult); + expect(completePayload.todo.completed).toBe(true); + + const reopenResult = await tool.execute('3', { + action: 'reopen', + id, + }); + const reopenPayload = parseToolResult(reopenResult); + expect(reopenPayload.todo.completed).toBe(false); + }); +}); diff --git a/src/tools/todo.ts b/src/tools/todo.ts new file mode 100644 index 0000000..5b34341 --- /dev/null +++ b/src/tools/todo.ts @@ -0,0 +1,156 @@ +import type { AnyAgentTool } from '@letta-ai/letta-code-sdk'; +import { + jsonResult, + readStringParam, +} from '@letta-ai/letta-code-sdk'; +import { + addTodo, + completeTodo, + listActionableTodos, + listTodos, + reopenTodo, + removeTodo, + snoozeTodo, +} from '../todo/store.js'; + +function readOptionalString(params: Record, key: string): string | null { + const value = readStringParam(params, key); + if (!value) return null; + const trimmed = value.trim(); + return trimmed || null; +} + +export function createManageTodoTool(agentKey: string): AnyAgentTool { + return { + label: 'Manage To-Do List', + name: 'manage_todo', + description: 'Add, list, complete, reopen, remove, and snooze to-dos for this agent.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['add', 'list', 'complete', 'reopen', 'remove', 'snooze'], + description: 'Action to perform.', + }, + text: { + type: 'string', + description: 'Todo text for add action.', + }, + id: { + type: 'string', + description: 'Todo ID (full or unique prefix) for complete/reopen/remove/snooze.', + }, + due: { + type: 'string', + description: 'Optional due date/time (ISO string or date phrase parsable by JavaScript Date).', + }, + recurring: { + type: 'string', + description: 'Optional recurring note (e.g. daily at 8am).', + }, + snoozed_until: { + type: 'string', + description: 'Optional snooze-until date/time for add or snooze actions.', + }, + view: { + type: 'string', + enum: ['open', 'all', 'actionable'], + description: 'List scope for list action. Default: open.', + }, + }, + required: ['action'], + additionalProperties: false, + }, + async execute(_toolCallId: string, args: unknown) { + const params = (args && typeof args === 'object') ? args as Record : {}; + const action = readStringParam(params, 'action', { required: true })?.toLowerCase(); + + switch (action) { + case 'add': { + const text = readStringParam(params, 'text', { required: true }); + const due = readOptionalString(params, 'due'); + const recurring = readOptionalString(params, 'recurring'); + const snoozedUntil = readOptionalString(params, 'snoozed_until'); + const todo = addTodo(agentKey, { + text, + due, + recurring, + snoozed_until: snoozedUntil, + }); + return jsonResult({ + ok: true, + action, + message: `Added todo ${todo.id}`, + todo, + }); + } + + case 'list': { + const view = (readStringParam(params, 'view') || 'open').toLowerCase(); + const todos = view === 'all' + ? listTodos(agentKey, { includeCompleted: true }) + : view === 'actionable' + ? listActionableTodos(agentKey) + : listTodos(agentKey); + + return jsonResult({ + ok: true, + action, + view, + count: todos.length, + todos, + }); + } + + case 'complete': { + const id = readStringParam(params, 'id', { required: true }); + const todo = completeTodo(agentKey, id); + return jsonResult({ + ok: true, + action, + message: `Completed todo ${todo.id}`, + todo, + }); + } + + case 'reopen': { + const id = readStringParam(params, 'id', { required: true }); + const todo = reopenTodo(agentKey, id); + return jsonResult({ + ok: true, + action, + message: `Reopened todo ${todo.id}`, + todo, + }); + } + + case 'remove': { + const id = readStringParam(params, 'id', { required: true }); + const todo = removeTodo(agentKey, id); + return jsonResult({ + ok: true, + action, + message: `Removed todo ${todo.id}`, + todo, + }); + } + + case 'snooze': { + const id = readStringParam(params, 'id', { required: true }); + const until = readOptionalString(params, 'snoozed_until'); + const todo = snoozeTodo(agentKey, id, until); + return jsonResult({ + ok: true, + action, + message: until ? `Snoozed todo ${todo.id}` : `Cleared snooze for ${todo.id}`, + todo, + }); + } + + default: + throw new Error(`Unsupported action: ${action}`); + } + }, + }; +}