diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 533533e..91cb174 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -28,6 +28,7 @@ idf_component_register( "tools/tool_files.c" "tools/tool_cron.c" "cron/cron_service.c" + "heartbeat/heartbeat.c" INCLUDE_DIRS "." EMBED_FILES diff --git a/main/agent/context_builder.c b/main/agent/context_builder.c index 787c230..7365f7e 100644 --- a/main/agent/context_builder.c +++ b/main/agent/context_builder.c @@ -58,7 +58,12 @@ esp_err_t context_build_system_prompt(char *buf, size_t size) "- Always read_file MEMORY.md before writing, so you can edit_file to update without losing existing content.\n" "- Use get_current_time to know today's date before writing daily notes.\n" "- Keep MEMORY.md concise and organized — summarize, don't dump raw conversation.\n" - "- You should proactively save memory without being asked. If the user tells you their name, preferences, or important facts, persist them immediately.\n"); + "- You should proactively save memory without being asked. If the user tells you their name, preferences, or important facts, persist them immediately.\n\n" + "## Heartbeat\n" + "The file /spiffs/config/HEARTBEAT.md contains periodic tasks.\n" + "When triggered by heartbeat, read the file and execute any pending tasks.\n" + "If nothing needs attention, reply with just: HEARTBEAT_OK\n" + "You can also write to HEARTBEAT.md to schedule tasks for yourself.\n"); /* Bootstrap files */ off = append_file(buf, size, off, MIMI_SOUL_FILE, "Personality"); diff --git a/main/cli/serial_cli.c b/main/cli/serial_cli.c index 29a0eb0..2ae63cc 100644 --- a/main/cli/serial_cli.c +++ b/main/cli/serial_cli.c @@ -7,6 +7,7 @@ #include "memory/session_mgr.h" #include "proxy/http_proxy.h" #include "tools/tool_web_search.h" +#include "heartbeat/heartbeat.h" #include #include @@ -316,6 +317,18 @@ static int cmd_config_reset(int argc, char **argv) return 0; } +/* --- heartbeat_trigger command --- */ +static int cmd_heartbeat_trigger(int argc, char **argv) +{ + printf("Checking HEARTBEAT.md...\n"); + if (heartbeat_trigger()) { + printf("Heartbeat: agent prompted with pending tasks.\n"); + } else { + printf("Heartbeat: no actionable tasks found.\n"); + } + return 0; +} + /* --- restart command --- */ static int cmd_restart(int argc, char **argv) { @@ -505,6 +518,14 @@ esp_err_t serial_cli_init(void) }; esp_console_cmd_register(&config_reset_cmd); + /* heartbeat_trigger */ + esp_console_cmd_t heartbeat_cmd = { + .command = "heartbeat_trigger", + .help = "Manually trigger a heartbeat check", + .func = &cmd_heartbeat_trigger, + }; + esp_console_cmd_register(&heartbeat_cmd); + /* restart */ esp_console_cmd_t restart_cmd = { .command = "restart", diff --git a/main/heartbeat/heartbeat.c b/main/heartbeat/heartbeat.c new file mode 100644 index 0000000..e680201 --- /dev/null +++ b/main/heartbeat/heartbeat.c @@ -0,0 +1,164 @@ +#include "heartbeat/heartbeat.h" +#include "mimi_config.h" +#include "bus/message_bus.h" + +#include +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/timers.h" +#include "esp_log.h" + +static const char *TAG = "heartbeat"; + +#define HEARTBEAT_PROMPT \ + "Read " MIMI_HEARTBEAT_FILE " and follow any instructions or tasks listed there. " \ + "If nothing needs attention, reply with just: HEARTBEAT_OK" + +static TimerHandle_t s_heartbeat_timer = NULL; + +/* ── Content check ────────────────────────────────────────────── */ + +/** + * Check if HEARTBEAT.md has actionable content. + * Returns true if any line is NOT: + * - empty / whitespace-only + * - a markdown header (starts with #) + * - a completed checkbox (- [x] or * [x]) + */ +static bool heartbeat_has_tasks(void) +{ + FILE *f = fopen(MIMI_HEARTBEAT_FILE, "r"); + if (!f) { + return false; + } + + char line[256]; + bool found_task = false; + + while (fgets(line, sizeof(line), f)) { + /* Skip leading whitespace */ + const char *p = line; + while (*p && isspace((unsigned char)*p)) { + p++; + } + + /* Skip empty lines */ + if (*p == '\0') { + continue; + } + + /* Skip markdown headers */ + if (*p == '#') { + continue; + } + + /* Skip completed checkboxes: "- [x]" or "* [x]" */ + if ((*p == '-' || *p == '*') && *(p + 1) == ' ' && *(p + 2) == '[') { + char mark = *(p + 3); + if ((mark == 'x' || mark == 'X') && *(p + 4) == ']') { + continue; + } + } + + /* Found an actionable line */ + found_task = true; + break; + } + + fclose(f); + return found_task; +} + +/* ── Send heartbeat to agent ──────────────────────────────────── */ + +static bool heartbeat_send(void) +{ + if (!heartbeat_has_tasks()) { + ESP_LOGD(TAG, "No actionable tasks in HEARTBEAT.md"); + return false; + } + + mimi_msg_t msg; + memset(&msg, 0, sizeof(msg)); + strncpy(msg.channel, MIMI_CHAN_SYSTEM, sizeof(msg.channel) - 1); + strncpy(msg.chat_id, "heartbeat", sizeof(msg.chat_id) - 1); + msg.content = strdup(HEARTBEAT_PROMPT); + + if (!msg.content) { + ESP_LOGE(TAG, "Failed to allocate heartbeat prompt"); + return false; + } + + esp_err_t err = message_bus_push_inbound(&msg); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to push heartbeat message: %s", esp_err_to_name(err)); + free(msg.content); + return false; + } + + ESP_LOGI(TAG, "Triggered agent check"); + return true; +} + +/* ── Timer callback ───────────────────────────────────────────── */ + +static void heartbeat_timer_callback(TimerHandle_t xTimer) +{ + (void)xTimer; + heartbeat_send(); +} + +/* ── Public API ───────────────────────────────────────────────── */ + +esp_err_t heartbeat_init(void) +{ + ESP_LOGI(TAG, "Heartbeat service initialized (file: %s, interval: %ds)", + MIMI_HEARTBEAT_FILE, MIMI_HEARTBEAT_INTERVAL_MS / 1000); + return ESP_OK; +} + +esp_err_t heartbeat_start(void) +{ + if (s_heartbeat_timer) { + ESP_LOGW(TAG, "Heartbeat timer already running"); + return ESP_OK; + } + + s_heartbeat_timer = xTimerCreate( + "heartbeat", + pdMS_TO_TICKS(MIMI_HEARTBEAT_INTERVAL_MS), + pdTRUE, /* auto-reload */ + NULL, + heartbeat_timer_callback + ); + + if (!s_heartbeat_timer) { + ESP_LOGE(TAG, "Failed to create heartbeat timer"); + return ESP_FAIL; + } + + if (xTimerStart(s_heartbeat_timer, pdMS_TO_TICKS(1000)) != pdPASS) { + ESP_LOGE(TAG, "Failed to start heartbeat timer"); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Heartbeat started (every %d min)", MIMI_HEARTBEAT_INTERVAL_MS / 60000); + return ESP_OK; +} + +void heartbeat_stop(void) +{ + if (s_heartbeat_timer) { + xTimerStop(s_heartbeat_timer, pdMS_TO_TICKS(1000)); + xTimerDelete(s_heartbeat_timer, pdMS_TO_TICKS(1000)); + s_heartbeat_timer = NULL; + ESP_LOGI(TAG, "Heartbeat stopped"); + } +} + +bool heartbeat_trigger(void) +{ + return heartbeat_send(); +} diff --git a/main/heartbeat/heartbeat.h b/main/heartbeat/heartbeat.h new file mode 100644 index 0000000..e0af53b --- /dev/null +++ b/main/heartbeat/heartbeat.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esp_err.h" + +/** + * Initialize the heartbeat service (logs ready state). + */ +esp_err_t heartbeat_init(void); + +/** + * Start the heartbeat timer. Checks HEARTBEAT.md periodically + * and sends a prompt to the agent if actionable tasks are found. + */ +esp_err_t heartbeat_start(void); + +/** + * Stop and delete the heartbeat timer. + */ +void heartbeat_stop(void); + +/** + * Manually trigger a heartbeat check (for CLI testing). + * Returns true if the agent was prompted, false if no tasks found. + */ +bool heartbeat_trigger(void); diff --git a/main/mimi.c b/main/mimi.c index 60f4a4e..770b52c 100644 --- a/main/mimi.c +++ b/main/mimi.c @@ -27,6 +27,7 @@ #include "imu/imu_manager.h" #include "rgb/rgb.h" #include "cron/cron_service.h" +#include "heartbeat/heartbeat.h" static const char *TAG = "mimi"; @@ -128,6 +129,7 @@ void app_main(void) ESP_ERROR_CHECK(llm_proxy_init()); ESP_ERROR_CHECK(tool_registry_init()); ESP_ERROR_CHECK(cron_service_init()); + ESP_ERROR_CHECK(heartbeat_init()); ESP_ERROR_CHECK(agent_loop_init()); /* Start Serial CLI first (works without WiFi) */ @@ -146,6 +148,7 @@ void app_main(void) ESP_ERROR_CHECK(telegram_bot_start()); ESP_ERROR_CHECK(agent_loop_start()); cron_service_start(); + heartbeat_start(); ESP_ERROR_CHECK(ws_server_start()); /* Outbound dispatch task */ diff --git a/main/mimi_config.h b/main/mimi_config.h index 6f90dfe..9fcb2a8 100644 --- a/main/mimi_config.h +++ b/main/mimi_config.h @@ -89,6 +89,10 @@ #define MIMI_CRON_CHECK_INTERVAL_MS (30 * 1000) #define MIMI_CRON_MAX_JOBS 8 +/* Heartbeat */ +#define MIMI_HEARTBEAT_FILE "/spiffs/config/HEARTBEAT.md" +#define MIMI_HEARTBEAT_INTERVAL_MS (30 * 60 * 1000) + /* WebSocket Gateway */ #define MIMI_WS_PORT 18789 #define MIMI_WS_MAX_CLIENTS 4