feat: per-agent todo system with heartbeat integration (#288)

This commit is contained in:
Cameron
2026-02-12 10:23:14 -08:00
committed by GitHub
parent dcd428d598
commit 01ed38a15d
24 changed files with 1319 additions and 17 deletions

View File

@@ -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) |

View File

@@ -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**:

View File

@@ -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 |

View File

@@ -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:

View File

@@ -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 <text> Add a todo
todo complete <id> Mark a todo complete
todo remove <id> Remove a todo
todo snooze <id> Snooze a todo until a date
reset-conversation Clear conversation ID (fixes corrupted conversations)
destroy Delete all local data and start fresh
pairing list <ch> 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':
@@ -259,6 +285,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');
await modelCommand(subCommand, args[2]);

220
src/cli/todo.ts Normal file
View File

@@ -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<string, string | boolean>;
}
function parseArgs(argv: string[]): ParsedArgs {
const positional: string[] = [];
const flags: Record<string, string | boolean> = {};
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 <command> [options]
Commands:
add <text> Add a todo
list List todos
complete <id> Mark a todo complete
reopen <id> Mark a completed todo as open
remove <id> Delete a todo
snooze <id> Snooze a todo until a date
Options:
--due <date> Due date/time for add (ISO or Date-parsable)
--recurring <text> Recurring note for add (e.g. "daily 8am")
--snooze-until <date> Initial snooze-until for add
--until <date> 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 <name> 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<string, string | boolean>, 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<void> {
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 <text> [--due <date>] [--recurring <text>] [--snooze-until <date>]');
}
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 <id>');
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 <id>');
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 <id>');
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 <id> --until <date> | --clear');
const clear = parsed.flags.clear === true;
const untilInput = asString(parsed.flags.until);
if (!clear && !untilInput) {
throw new Error('Usage: lettabot todo snooze <id> --until <date> | --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);
}
}

View File

@@ -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,

View File

@@ -305,6 +305,9 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
}
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';

View File

@@ -383,6 +383,7 @@ describe('normalizeAgents', () => {
heartbeat: {
enabled: true,
intervalMin: 10,
skipRecentUserMin: 3,
},
maxToolCalls: 50,
},

View File

@@ -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)
};

View File

@@ -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[] = [];

View File

@@ -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<string, unknown>
: 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<string, unknown>;
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()),
};
}

View File

@@ -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),

View File

@@ -41,7 +41,7 @@ export interface AgentSession {
}): Promise<string | undefined>;
/** 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;

View File

@@ -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();
}

View File

@@ -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.

View File

@@ -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> = {}): 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
});
});

View File

@@ -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<string, unknown>): 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;
@@ -69,6 +72,14 @@ export class HeartbeatService {
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`);

View File

@@ -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,

109
src/todo/store.test.ts Normal file
View File

@@ -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);
});
});

391
src/todo/store.ts Normal file
View File

@@ -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<string, unknown>;
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<TodoStoreFile>;
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<string, number[]>();
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,
};
}

View File

@@ -3,3 +3,4 @@
*/
export * from './letta-api.js';
export * from './todo.js';

80
src/tools/todo.test.ts Normal file
View File

@@ -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);
});
});

156
src/tools/todo.ts Normal file
View File

@@ -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<string, unknown>, 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<string, unknown> : {};
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}`);
}
},
};
}