feat: add heartbeat service for periodic task checking
Adds a heartbeat timer that reads /spiffs/config/HEARTBEAT.md every 30 minutes and sends a prompt to the agent if actionable tasks are found. Skips empty lines, headers, and completed checkboxes. Includes a heartbeat_trigger CLI command for manual testing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ idf_component_register(
|
|||||||
"tools/tool_files.c"
|
"tools/tool_files.c"
|
||||||
"tools/tool_cron.c"
|
"tools/tool_cron.c"
|
||||||
"cron/cron_service.c"
|
"cron/cron_service.c"
|
||||||
|
"heartbeat/heartbeat.c"
|
||||||
INCLUDE_DIRS
|
INCLUDE_DIRS
|
||||||
"."
|
"."
|
||||||
EMBED_FILES
|
EMBED_FILES
|
||||||
|
|||||||
@@ -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"
|
"- 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"
|
"- 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"
|
"- 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 */
|
/* Bootstrap files */
|
||||||
off = append_file(buf, size, off, MIMI_SOUL_FILE, "Personality");
|
off = append_file(buf, size, off, MIMI_SOUL_FILE, "Personality");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "memory/session_mgr.h"
|
#include "memory/session_mgr.h"
|
||||||
#include "proxy/http_proxy.h"
|
#include "proxy/http_proxy.h"
|
||||||
#include "tools/tool_web_search.h"
|
#include "tools/tool_web_search.h"
|
||||||
|
#include "heartbeat/heartbeat.h"
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
@@ -316,6 +317,18 @@ static int cmd_config_reset(int argc, char **argv)
|
|||||||
return 0;
|
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 --- */
|
/* --- restart command --- */
|
||||||
static int cmd_restart(int argc, char **argv)
|
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);
|
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 */
|
/* restart */
|
||||||
esp_console_cmd_t restart_cmd = {
|
esp_console_cmd_t restart_cmd = {
|
||||||
.command = "restart",
|
.command = "restart",
|
||||||
|
|||||||
164
main/heartbeat/heartbeat.c
Normal file
164
main/heartbeat/heartbeat.c
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
#include "heartbeat/heartbeat.h"
|
||||||
|
#include "mimi_config.h"
|
||||||
|
#include "bus/message_bus.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <ctype.h>
|
||||||
|
#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();
|
||||||
|
}
|
||||||
25
main/heartbeat/heartbeat.h
Normal file
25
main/heartbeat/heartbeat.h
Normal file
@@ -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);
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
#include "imu/imu_manager.h"
|
#include "imu/imu_manager.h"
|
||||||
#include "rgb/rgb.h"
|
#include "rgb/rgb.h"
|
||||||
#include "cron/cron_service.h"
|
#include "cron/cron_service.h"
|
||||||
|
#include "heartbeat/heartbeat.h"
|
||||||
|
|
||||||
static const char *TAG = "mimi";
|
static const char *TAG = "mimi";
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ void app_main(void)
|
|||||||
ESP_ERROR_CHECK(llm_proxy_init());
|
ESP_ERROR_CHECK(llm_proxy_init());
|
||||||
ESP_ERROR_CHECK(tool_registry_init());
|
ESP_ERROR_CHECK(tool_registry_init());
|
||||||
ESP_ERROR_CHECK(cron_service_init());
|
ESP_ERROR_CHECK(cron_service_init());
|
||||||
|
ESP_ERROR_CHECK(heartbeat_init());
|
||||||
ESP_ERROR_CHECK(agent_loop_init());
|
ESP_ERROR_CHECK(agent_loop_init());
|
||||||
|
|
||||||
/* Start Serial CLI first (works without WiFi) */
|
/* Start Serial CLI first (works without WiFi) */
|
||||||
@@ -146,6 +148,7 @@ void app_main(void)
|
|||||||
ESP_ERROR_CHECK(telegram_bot_start());
|
ESP_ERROR_CHECK(telegram_bot_start());
|
||||||
ESP_ERROR_CHECK(agent_loop_start());
|
ESP_ERROR_CHECK(agent_loop_start());
|
||||||
cron_service_start();
|
cron_service_start();
|
||||||
|
heartbeat_start();
|
||||||
ESP_ERROR_CHECK(ws_server_start());
|
ESP_ERROR_CHECK(ws_server_start());
|
||||||
|
|
||||||
/* Outbound dispatch task */
|
/* Outbound dispatch task */
|
||||||
|
|||||||
@@ -89,6 +89,10 @@
|
|||||||
#define MIMI_CRON_CHECK_INTERVAL_MS (30 * 1000)
|
#define MIMI_CRON_CHECK_INTERVAL_MS (30 * 1000)
|
||||||
#define MIMI_CRON_MAX_JOBS 8
|
#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 */
|
/* WebSocket Gateway */
|
||||||
#define MIMI_WS_PORT 18789
|
#define MIMI_WS_PORT 18789
|
||||||
#define MIMI_WS_MAX_CLIENTS 4
|
#define MIMI_WS_MAX_CLIENTS 4
|
||||||
|
|||||||
Reference in New Issue
Block a user