feat: per-agent todo system with heartbeat integration (#288)
This commit is contained in:
@@ -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) |
|
||||
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
32
src/cli.ts
32
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 <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
220
src/cli/todo.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -383,6 +383,7 @@ describe('normalizeAgents', () => {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalMin: 10,
|
||||
skipRecentUserMin: 3,
|
||||
},
|
||||
maxToolCalls: 50,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
23
src/main.ts
23
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,
|
||||
|
||||
109
src/todo/store.test.ts
Normal file
109
src/todo/store.test.ts
Normal 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
391
src/todo/store.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -3,3 +3,4 @@
|
||||
*/
|
||||
|
||||
export * from './letta-api.js';
|
||||
export * from './todo.js';
|
||||
|
||||
80
src/tools/todo.test.ts
Normal file
80
src/tools/todo.test.ts
Normal 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
156
src/tools/todo.ts
Normal 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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user