Merge pull request #4 from memovai/cron

This commit is contained in:
crispyberry
2026-02-17 03:03:55 +08:00
committed by GitHub
16 changed files with 1045 additions and 17 deletions

3
.gitignore vendored
View File

@@ -45,5 +45,4 @@ nanobot/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
references/
references/

View File

@@ -26,6 +26,9 @@ idf_component_register(
"tools/tool_web_search.c" "tools/tool_web_search.c"
"tools/tool_get_time.c" "tools/tool_get_time.c"
"tools/tool_files.c" "tools/tool_files.c"
"tools/tool_cron.c"
"cron/cron_service.c"
"heartbeat/heartbeat.c"
INCLUDE_DIRS INCLUDE_DIRS
"." "."
EMBED_FILES EMBED_FILES

View File

@@ -43,7 +43,10 @@ esp_err_t context_build_system_prompt(char *buf, size_t size)
"- read_file: Read a file from SPIFFS (path must start with /spiffs/).\n" "- read_file: Read a file from SPIFFS (path must start with /spiffs/).\n"
"- write_file: Write/overwrite a file on SPIFFS.\n" "- write_file: Write/overwrite a file on SPIFFS.\n"
"- edit_file: Find-and-replace edit a file on SPIFFS.\n" "- edit_file: Find-and-replace edit a file on SPIFFS.\n"
"- list_dir: List files on SPIFFS, optionally filter by prefix.\n\n" "- list_dir: List files on SPIFFS, optionally filter by prefix.\n"
"- cron_add: Schedule a recurring or one-shot task. The message will trigger an agent turn when the job fires.\n"
"- cron_list: List all scheduled cron jobs.\n"
"- cron_remove: Remove a scheduled cron job by ID.\n\n"
"Use tools when needed. Provide your final answer as text after using tools.\n\n" "Use tools when needed. Provide your final answer as text after using tools.\n\n"
"## Memory\n" "## Memory\n"
"You have persistent memory stored on local flash:\n" "You have persistent memory stored on local flash:\n"
@@ -55,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");

View File

@@ -8,6 +8,7 @@
#define MIMI_CHAN_TELEGRAM "telegram" #define MIMI_CHAN_TELEGRAM "telegram"
#define MIMI_CHAN_WEBSOCKET "websocket" #define MIMI_CHAN_WEBSOCKET "websocket"
#define MIMI_CHAN_CLI "cli" #define MIMI_CHAN_CLI "cli"
#define MIMI_CHAN_SYSTEM "system"
/* Message types on the bus */ /* Message types on the bus */
typedef struct { typedef struct {

View File

@@ -7,6 +7,9 @@
#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 "tools/tool_registry.h"
#include "cron/cron_service.h"
#include "heartbeat/heartbeat.h"
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
@@ -316,6 +319,54 @@ 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;
}
/* --- cron_start command --- */
static int cmd_cron_start(int argc, char **argv)
{
esp_err_t err = cron_service_start();
if (err == ESP_OK) {
printf("Cron service started.\n");
return 0;
}
printf("Failed to start cron service: %s\n", esp_err_to_name(err));
return 1;
}
static int cmd_tool_exec(int argc, char **argv)
{
if (argc < 2) {
printf("Usage: tool_exec <name> [json]\n");
return 1;
}
const char *tool_name = argv[1];
const char *input_json = (argc >= 3) ? argv[2] : "{}";
char *output = calloc(1, 4096);
if (!output) {
printf("Out of memory.\n");
return 1;
}
esp_err_t err = tool_registry_execute(tool_name, input_json, output, 4096);
printf("tool_exec status: %s\n", esp_err_to_name(err));
printf("%s\n", output[0] ? output : "(empty)");
free(output);
return (err == ESP_OK) ? 0 : 1;
}
/* --- restart command --- */ /* --- restart command --- */
static int cmd_restart(int argc, char **argv) static int cmd_restart(int argc, char **argv)
{ {
@@ -505,6 +556,30 @@ 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);
/* cron_start */
esp_console_cmd_t cron_start_cmd = {
.command = "cron_start",
.help = "Start cron scheduler timer now",
.func = &cmd_cron_start,
};
esp_console_cmd_register(&cron_start_cmd);
/* tool_exec */
esp_console_cmd_t tool_exec_cmd = {
.command = "tool_exec",
.help = "Execute a registered tool: tool_exec <name> '{...json...}'",
.func = &cmd_tool_exec,
};
esp_console_cmd_register(&tool_exec_cmd);
/* restart */ /* restart */
esp_console_cmd_t restart_cmd = { esp_console_cmd_t restart_cmd = {
.command = "restart", .command = "restart",

408
main/cron/cron_service.c Normal file
View File

@@ -0,0 +1,408 @@
#include "cron/cron_service.h"
#include "mimi_config.h"
#include "bus/message_bus.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_random.h"
#include "cJSON.h"
static const char *TAG = "cron";
#define MAX_CRON_JOBS MIMI_CRON_MAX_JOBS
static cron_job_t s_jobs[MAX_CRON_JOBS];
static int s_job_count = 0;
static TaskHandle_t s_cron_task = NULL;
/* ── Persistence ──────────────────────────────────────────────── */
static void cron_generate_id(char *id_buf)
{
uint32_t r = esp_random();
snprintf(id_buf, 9, "%08x", (unsigned int)r);
}
static esp_err_t cron_load_jobs(void)
{
FILE *f = fopen(MIMI_CRON_FILE, "r");
if (!f) {
ESP_LOGI(TAG, "No cron file found, starting fresh");
s_job_count = 0;
return ESP_OK;
}
/* Read entire file */
fseek(f, 0, SEEK_END);
long fsize = ftell(f);
fseek(f, 0, SEEK_SET);
if (fsize <= 0 || fsize > 8192) {
ESP_LOGW(TAG, "Cron file invalid size: %ld", fsize);
fclose(f);
s_job_count = 0;
return ESP_OK;
}
char *buf = malloc(fsize + 1);
if (!buf) {
fclose(f);
return ESP_ERR_NO_MEM;
}
size_t n = fread(buf, 1, fsize, f);
buf[n] = '\0';
fclose(f);
/* Parse JSON */
cJSON *root = cJSON_Parse(buf);
free(buf);
if (!root) {
ESP_LOGW(TAG, "Failed to parse cron JSON");
s_job_count = 0;
return ESP_OK;
}
cJSON *jobs_arr = cJSON_GetObjectItem(root, "jobs");
if (!jobs_arr || !cJSON_IsArray(jobs_arr)) {
cJSON_Delete(root);
s_job_count = 0;
return ESP_OK;
}
s_job_count = 0;
cJSON *item;
cJSON_ArrayForEach(item, jobs_arr) {
if (s_job_count >= MAX_CRON_JOBS) break;
cron_job_t *job = &s_jobs[s_job_count];
memset(job, 0, sizeof(cron_job_t));
const char *id = cJSON_GetStringValue(cJSON_GetObjectItem(item, "id"));
const char *name = cJSON_GetStringValue(cJSON_GetObjectItem(item, "name"));
const char *kind_str = cJSON_GetStringValue(cJSON_GetObjectItem(item, "kind"));
const char *message = cJSON_GetStringValue(cJSON_GetObjectItem(item, "message"));
const char *channel = cJSON_GetStringValue(cJSON_GetObjectItem(item, "channel"));
const char *chat_id = cJSON_GetStringValue(cJSON_GetObjectItem(item, "chat_id"));
if (!id || !name || !kind_str || !message) continue;
strncpy(job->id, id, sizeof(job->id) - 1);
strncpy(job->name, name, sizeof(job->name) - 1);
strncpy(job->message, message, sizeof(job->message) - 1);
strncpy(job->channel, channel ? channel : MIMI_CHAN_SYSTEM,
sizeof(job->channel) - 1);
strncpy(job->chat_id, chat_id ? chat_id : "cron",
sizeof(job->chat_id) - 1);
cJSON *enabled_j = cJSON_GetObjectItem(item, "enabled");
job->enabled = enabled_j ? cJSON_IsTrue(enabled_j) : true;
cJSON *delete_j = cJSON_GetObjectItem(item, "delete_after_run");
job->delete_after_run = delete_j ? cJSON_IsTrue(delete_j) : false;
if (strcmp(kind_str, "every") == 0) {
job->kind = CRON_KIND_EVERY;
cJSON *interval = cJSON_GetObjectItem(item, "interval_s");
job->interval_s = (interval && cJSON_IsNumber(interval))
? (uint32_t)interval->valuedouble : 0;
} else if (strcmp(kind_str, "at") == 0) {
job->kind = CRON_KIND_AT;
cJSON *at_epoch = cJSON_GetObjectItem(item, "at_epoch");
job->at_epoch = (at_epoch && cJSON_IsNumber(at_epoch))
? (int64_t)at_epoch->valuedouble : 0;
} else {
continue; /* Unknown kind, skip */
}
cJSON *last_run = cJSON_GetObjectItem(item, "last_run");
job->last_run = (last_run && cJSON_IsNumber(last_run))
? (int64_t)last_run->valuedouble : 0;
cJSON *next_run = cJSON_GetObjectItem(item, "next_run");
job->next_run = (next_run && cJSON_IsNumber(next_run))
? (int64_t)next_run->valuedouble : 0;
s_job_count++;
}
cJSON_Delete(root);
ESP_LOGI(TAG, "Loaded %d cron jobs", s_job_count);
return ESP_OK;
}
static esp_err_t cron_save_jobs(void)
{
cJSON *root = cJSON_CreateObject();
cJSON *jobs_arr = cJSON_CreateArray();
for (int i = 0; i < s_job_count; i++) {
cron_job_t *job = &s_jobs[i];
cJSON *item = cJSON_CreateObject();
cJSON_AddStringToObject(item, "id", job->id);
cJSON_AddStringToObject(item, "name", job->name);
cJSON_AddBoolToObject(item, "enabled", job->enabled);
cJSON_AddStringToObject(item, "kind",
job->kind == CRON_KIND_EVERY ? "every" : "at");
if (job->kind == CRON_KIND_EVERY) {
cJSON_AddNumberToObject(item, "interval_s", job->interval_s);
} else {
cJSON_AddNumberToObject(item, "at_epoch", (double)job->at_epoch);
}
cJSON_AddStringToObject(item, "message", job->message);
cJSON_AddStringToObject(item, "channel", job->channel);
cJSON_AddStringToObject(item, "chat_id", job->chat_id);
cJSON_AddNumberToObject(item, "last_run", (double)job->last_run);
cJSON_AddNumberToObject(item, "next_run", (double)job->next_run);
cJSON_AddBoolToObject(item, "delete_after_run", job->delete_after_run);
cJSON_AddItemToArray(jobs_arr, item);
}
cJSON_AddItemToObject(root, "jobs", jobs_arr);
char *json_str = cJSON_Print(root);
cJSON_Delete(root);
if (!json_str) {
ESP_LOGE(TAG, "Failed to serialize cron jobs");
return ESP_ERR_NO_MEM;
}
FILE *f = fopen(MIMI_CRON_FILE, "w");
if (!f) {
ESP_LOGE(TAG, "Failed to open %s for writing", MIMI_CRON_FILE);
free(json_str);
return ESP_FAIL;
}
size_t len = strlen(json_str);
size_t written = fwrite(json_str, 1, len, f);
fclose(f);
free(json_str);
if (written != len) {
ESP_LOGE(TAG, "Cron save incomplete: %d/%d bytes", (int)written, (int)len);
return ESP_FAIL;
}
ESP_LOGI(TAG, "Saved %d cron jobs to %s", s_job_count, MIMI_CRON_FILE);
return ESP_OK;
}
/* ── Due-job processing ───────────────────────────────────────── */
static void cron_process_due_jobs(void)
{
time_t now = time(NULL);
bool changed = false;
for (int i = 0; i < s_job_count; i++) {
cron_job_t *job = &s_jobs[i];
if (!job->enabled) continue;
if (job->next_run <= 0) continue;
if (job->next_run > now) continue;
/* Job is due — fire it */
ESP_LOGI(TAG, "Cron job firing: %s (%s)", job->name, job->id);
/* Push message to inbound queue */
mimi_msg_t msg;
memset(&msg, 0, sizeof(msg));
strncpy(msg.channel, job->channel, sizeof(msg.channel) - 1);
strncpy(msg.chat_id, job->chat_id, sizeof(msg.chat_id) - 1);
msg.content = strdup(job->message);
if (msg.content) {
esp_err_t err = message_bus_push_inbound(&msg);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Failed to push cron message: %s", esp_err_to_name(err));
free(msg.content);
}
}
/* Update state */
job->last_run = now;
if (job->kind == CRON_KIND_AT) {
/* One-shot: disable or delete */
if (job->delete_after_run) {
/* Remove by shifting array */
ESP_LOGI(TAG, "Deleting one-shot job: %s", job->name);
for (int j = i; j < s_job_count - 1; j++) {
s_jobs[j] = s_jobs[j + 1];
}
s_job_count--;
i--; /* Re-check this index */
} else {
job->enabled = false;
job->next_run = 0;
}
} else {
/* Recurring: compute next run */
job->next_run = now + job->interval_s;
}
changed = true;
}
if (changed) {
cron_save_jobs();
}
}
static void cron_task_main(void *arg)
{
(void)arg;
while (1) {
vTaskDelay(pdMS_TO_TICKS(MIMI_CRON_CHECK_INTERVAL_MS));
cron_process_due_jobs();
}
}
/* ── Compute initial next_run for a new job ───────────────────── */
static void compute_initial_next_run(cron_job_t *job)
{
time_t now = time(NULL);
if (job->kind == CRON_KIND_EVERY) {
job->next_run = now + job->interval_s;
} else if (job->kind == CRON_KIND_AT) {
if (job->at_epoch > now) {
job->next_run = job->at_epoch;
} else {
/* Already in the past */
job->next_run = 0;
job->enabled = false;
}
}
}
/* ── Public API ───────────────────────────────────────────────── */
esp_err_t cron_service_init(void)
{
return cron_load_jobs();
}
esp_err_t cron_service_start(void)
{
if (s_cron_task) {
ESP_LOGW(TAG, "Cron task already running");
return ESP_OK;
}
/* Recompute next_run for all enabled jobs that don't have one */
time_t now = time(NULL);
for (int i = 0; i < s_job_count; i++) {
cron_job_t *job = &s_jobs[i];
if (job->enabled && job->next_run <= 0) {
if (job->kind == CRON_KIND_EVERY) {
job->next_run = now + job->interval_s;
} else if (job->kind == CRON_KIND_AT && job->at_epoch > now) {
job->next_run = job->at_epoch;
}
}
}
BaseType_t ok = xTaskCreate(
cron_task_main,
"cron",
4096,
NULL,
4,
&s_cron_task
);
if (ok != pdPASS || !s_cron_task) {
ESP_LOGE(TAG, "Failed to create cron task");
return ESP_FAIL;
}
ESP_LOGI(TAG, "Cron service started (%d jobs, check every %ds)",
s_job_count, MIMI_CRON_CHECK_INTERVAL_MS / 1000);
return ESP_OK;
}
void cron_service_stop(void)
{
if (s_cron_task) {
vTaskDelete(s_cron_task);
s_cron_task = NULL;
ESP_LOGI(TAG, "Cron service stopped");
}
}
esp_err_t cron_add_job(cron_job_t *job)
{
if (s_job_count >= MAX_CRON_JOBS) {
ESP_LOGW(TAG, "Max cron jobs reached (%d)", MAX_CRON_JOBS);
return ESP_ERR_NO_MEM;
}
/* Generate ID */
cron_generate_id(job->id);
/* Set defaults for channel/chat_id if empty */
if (job->channel[0] == '\0') {
strncpy(job->channel, MIMI_CHAN_SYSTEM, sizeof(job->channel) - 1);
}
if (job->chat_id[0] == '\0') {
strncpy(job->chat_id, "cron", sizeof(job->chat_id) - 1);
}
/* Compute initial next_run */
job->enabled = true;
job->last_run = 0;
compute_initial_next_run(job);
/* Copy into static array */
s_jobs[s_job_count] = *job;
s_job_count++;
cron_save_jobs();
ESP_LOGI(TAG, "Added cron job: %s (%s) kind=%s next_run=%lld",
job->name, job->id,
job->kind == CRON_KIND_EVERY ? "every" : "at",
(long long)job->next_run);
return ESP_OK;
}
esp_err_t cron_remove_job(const char *job_id)
{
for (int i = 0; i < s_job_count; i++) {
if (strcmp(s_jobs[i].id, job_id) == 0) {
ESP_LOGI(TAG, "Removing cron job: %s (%s)", s_jobs[i].name, job_id);
/* Shift remaining jobs down */
for (int j = i; j < s_job_count - 1; j++) {
s_jobs[j] = s_jobs[j + 1];
}
s_job_count--;
cron_save_jobs();
return ESP_OK;
}
}
ESP_LOGW(TAG, "Cron job not found: %s", job_id);
return ESP_ERR_NOT_FOUND;
}
void cron_list_jobs(const cron_job_t **jobs, int *count)
{
*jobs = s_jobs;
*count = s_job_count;
}

63
main/cron/cron_service.h Normal file
View File

@@ -0,0 +1,63 @@
#pragma once
#include "esp_err.h"
#include <stdbool.h>
#include <stdint.h>
/* Schedule types */
typedef enum {
CRON_KIND_EVERY = 0, /* Recurring interval in seconds */
CRON_KIND_AT = 1, /* One-shot at unix timestamp */
} cron_kind_t;
/* A single cron job */
typedef struct {
char id[9]; /* 8-char hex ID + null */
char name[32];
bool enabled;
cron_kind_t kind;
uint32_t interval_s; /* For EVERY: interval in seconds */
int64_t at_epoch; /* For AT: unix timestamp */
char message[256]; /* Message to inject into inbound queue */
char channel[16]; /* Reply channel (default "system") */
char chat_id[32]; /* Reply chat_id (default "cron") */
int64_t last_run; /* Last run epoch */
int64_t next_run; /* Next run epoch */
bool delete_after_run; /* Remove job after firing (for AT jobs) */
} cron_job_t;
/**
* Initialize the cron service. Loads jobs from SPIFFS.
*/
esp_err_t cron_service_init(void);
/**
* Start the cron timer. Call after WiFi is connected and time is synced.
*/
esp_err_t cron_service_start(void);
/**
* Stop the cron timer.
*/
void cron_service_stop(void);
/**
* Add a new cron job.
* @param job Pointer to job struct (id will be generated)
* @return ESP_OK on success, ESP_ERR_NO_MEM if max jobs reached
*/
esp_err_t cron_add_job(cron_job_t *job);
/**
* Remove a cron job by ID.
* @param job_id 8-char job ID
* @return ESP_OK on success, ESP_ERR_NOT_FOUND if not found
*/
esp_err_t cron_remove_job(const char *job_id);
/**
* List all cron jobs.
* @param jobs Output array of job pointers
* @param count Output: number of jobs
*/
void cron_list_jobs(const cron_job_t **jobs, int *count);

164
main/heartbeat/heartbeat.c Normal file
View 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();
}

View File

@@ -0,0 +1,26 @@
#pragma once
#include "esp_err.h"
#include <stdbool.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);

View File

@@ -13,9 +13,13 @@
static const char *TAG = "llm"; static const char *TAG = "llm";
static char s_api_key[128] = {0}; #define LLM_API_KEY_MAX_LEN 256
static char s_model[64] = MIMI_LLM_DEFAULT_MODEL; #define LLM_MODEL_MAX_LEN 64
static char s_provider[16] = MIMI_LLM_PROVIDER_DEFAULT; #define LLM_PROVIDER_MAX_LEN 16
static char s_api_key[LLM_API_KEY_MAX_LEN] = {0};
static char s_model[LLM_MODEL_MAX_LEN] = MIMI_LLM_DEFAULT_MODEL;
static char s_provider[LLM_PROVIDER_MAX_LEN] = MIMI_LLM_PROVIDER_DEFAULT;
static void safe_copy(char *dst, size_t dst_size, const char *src) static void safe_copy(char *dst, size_t dst_size, const char *src)
{ {
@@ -118,20 +122,23 @@ esp_err_t llm_proxy_init(void)
/* NVS overrides take highest priority (set via CLI) */ /* NVS overrides take highest priority (set via CLI) */
nvs_handle_t nvs; nvs_handle_t nvs;
if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) { if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) {
char tmp[128] = {0}; char tmp[LLM_API_KEY_MAX_LEN] = {0};
size_t len = sizeof(tmp); size_t len = sizeof(tmp);
if (nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, tmp, &len) == ESP_OK && tmp[0]) { if (nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, tmp, &len) == ESP_OK && tmp[0]) {
safe_copy(s_api_key, sizeof(s_api_key), tmp); safe_copy(s_api_key, sizeof(s_api_key), tmp);
} }
len = sizeof(tmp);
memset(tmp, 0, sizeof(tmp));
if (nvs_get_str(nvs, MIMI_NVS_KEY_MODEL, tmp, &len) == ESP_OK && tmp[0]) {
safe_copy(s_model, sizeof(s_model), tmp);
} }
len = sizeof(tmp);
memset(tmp, 0, sizeof(tmp)); char model_tmp[LLM_MODEL_MAX_LEN] = {0};
if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, tmp, &len) == ESP_OK && tmp[0]) { len = sizeof(model_tmp);
safe_copy(s_provider, sizeof(s_provider), tmp); if (nvs_get_str(nvs, MIMI_NVS_KEY_MODEL, model_tmp, &len) == ESP_OK && model_tmp[0]) {
safe_copy(s_model, sizeof(s_model), model_tmp);
}
char provider_tmp[LLM_PROVIDER_MAX_LEN] = {0};
len = sizeof(provider_tmp);
if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, provider_tmp, &len) == ESP_OK && provider_tmp[0]) {
safe_copy(s_provider, sizeof(s_provider), provider_tmp);
} }
nvs_close(nvs); nvs_close(nvs);
} }

View File

@@ -26,6 +26,8 @@
#include "ui/config_screen.h" #include "ui/config_screen.h"
#include "imu/imu_manager.h" #include "imu/imu_manager.h"
#include "rgb/rgb.h" #include "rgb/rgb.h"
#include "cron/cron_service.h"
#include "heartbeat/heartbeat.h"
static const char *TAG = "mimi"; static const char *TAG = "mimi";
@@ -77,6 +79,8 @@ static void outbound_dispatch_task(void *arg)
telegram_send_message(msg.chat_id, msg.content); telegram_send_message(msg.chat_id, msg.content);
} else if (strcmp(msg.channel, MIMI_CHAN_WEBSOCKET) == 0) { } else if (strcmp(msg.channel, MIMI_CHAN_WEBSOCKET) == 0) {
ws_server_send(msg.chat_id, msg.content); ws_server_send(msg.chat_id, msg.content);
} else if (strcmp(msg.channel, MIMI_CHAN_SYSTEM) == 0) {
ESP_LOGI(TAG, "System message [%s]: %.128s", msg.chat_id, msg.content);
} else { } else {
ESP_LOGW(TAG, "Unknown channel: %s", msg.channel); ESP_LOGW(TAG, "Unknown channel: %s", msg.channel);
} }
@@ -124,6 +128,8 @@ void app_main(void)
ESP_ERROR_CHECK(telegram_bot_init()); ESP_ERROR_CHECK(telegram_bot_init());
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(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) */
@@ -141,6 +147,8 @@ void app_main(void)
/* Start network-dependent services */ /* Start network-dependent services */
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();
heartbeat_start();
ESP_ERROR_CHECK(ws_server_start()); ESP_ERROR_CHECK(ws_server_start());
/* Outbound dispatch task */ /* Outbound dispatch task */

View File

@@ -84,6 +84,15 @@
#define MIMI_CONTEXT_BUF_SIZE (16 * 1024) #define MIMI_CONTEXT_BUF_SIZE (16 * 1024)
#define MIMI_SESSION_MAX_MSGS 20 #define MIMI_SESSION_MAX_MSGS 20
/* Cron Service */
#define MIMI_CRON_FILE "/spiffs/config/cron.json"
#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 */ /* WebSocket Gateway */
#define MIMI_WS_PORT 18789 #define MIMI_WS_PORT 18789
#define MIMI_WS_MAX_CLIENTS 4 #define MIMI_WS_MAX_CLIENTS 4

189
main/tools/tool_cron.c Normal file
View File

@@ -0,0 +1,189 @@
#include "tools/tool_cron.h"
#include "cron/cron_service.h"
#include <string.h>
#include <time.h>
#include "esp_log.h"
#include "cJSON.h"
static const char *TAG = "tool_cron";
/* ── cron_add ─────────────────────────────────────────────────── */
esp_err_t tool_cron_add_execute(const char *input_json, char *output, size_t output_size)
{
cJSON *root = cJSON_Parse(input_json);
if (!root) {
snprintf(output, output_size, "Error: invalid JSON input");
return ESP_ERR_INVALID_ARG;
}
const char *name = cJSON_GetStringValue(cJSON_GetObjectItem(root, "name"));
const char *schedule_type = cJSON_GetStringValue(cJSON_GetObjectItem(root, "schedule_type"));
const char *message = cJSON_GetStringValue(cJSON_GetObjectItem(root, "message"));
if (!name || !schedule_type || !message) {
snprintf(output, output_size, "Error: missing required fields (name, schedule_type, message)");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
if (strlen(message) == 0) {
snprintf(output, output_size, "Error: message must not be empty");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
cron_job_t job;
memset(&job, 0, sizeof(job));
strncpy(job.name, name, sizeof(job.name) - 1);
strncpy(job.message, message, sizeof(job.message) - 1);
/* Optional channel and chat_id */
const char *channel = cJSON_GetStringValue(cJSON_GetObjectItem(root, "channel"));
const char *chat_id = cJSON_GetStringValue(cJSON_GetObjectItem(root, "chat_id"));
if (channel) strncpy(job.channel, channel, sizeof(job.channel) - 1);
if (chat_id) strncpy(job.chat_id, chat_id, sizeof(job.chat_id) - 1);
if (strcmp(schedule_type, "every") == 0) {
job.kind = CRON_KIND_EVERY;
cJSON *interval = cJSON_GetObjectItem(root, "interval_s");
if (!interval || !cJSON_IsNumber(interval) || interval->valuedouble <= 0) {
snprintf(output, output_size, "Error: 'every' schedule requires positive 'interval_s'");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
job.interval_s = (uint32_t)interval->valuedouble;
job.delete_after_run = false;
} else if (strcmp(schedule_type, "at") == 0) {
job.kind = CRON_KIND_AT;
cJSON *at_epoch = cJSON_GetObjectItem(root, "at_epoch");
if (!at_epoch || !cJSON_IsNumber(at_epoch)) {
snprintf(output, output_size, "Error: 'at' schedule requires 'at_epoch' (unix timestamp)");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
job.at_epoch = (int64_t)at_epoch->valuedouble;
/* Check if already in the past */
time_t now = time(NULL);
if (job.at_epoch <= now) {
snprintf(output, output_size, "Error: at_epoch %lld is in the past (now=%lld)",
(long long)job.at_epoch, (long long)now);
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
/* Default: delete one-shot jobs after run */
cJSON *delete_j = cJSON_GetObjectItem(root, "delete_after_run");
job.delete_after_run = delete_j ? cJSON_IsTrue(delete_j) : true;
} else {
snprintf(output, output_size, "Error: schedule_type must be 'every' or 'at'");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
cJSON_Delete(root);
esp_err_t err = cron_add_job(&job);
if (err != ESP_OK) {
snprintf(output, output_size, "Error: failed to add job (%s)", esp_err_to_name(err));
return err;
}
/* Format success response */
if (job.kind == CRON_KIND_EVERY) {
snprintf(output, output_size,
"OK: Added recurring job '%s' (id=%s), runs every %lu seconds. Next run at epoch %lld.",
job.name, job.id, (unsigned long)job.interval_s, (long long)job.next_run);
} else {
snprintf(output, output_size,
"OK: Added one-shot job '%s' (id=%s), fires at epoch %lld.%s",
job.name, job.id, (long long)job.at_epoch,
job.delete_after_run ? " Will be deleted after firing." : "");
}
ESP_LOGI(TAG, "cron_add: %s", output);
return ESP_OK;
}
/* ── cron_list ────────────────────────────────────────────────── */
esp_err_t tool_cron_list_execute(const char *input_json, char *output, size_t output_size)
{
(void)input_json;
const cron_job_t *jobs;
int count;
cron_list_jobs(&jobs, &count);
if (count == 0) {
snprintf(output, output_size, "No cron jobs scheduled.");
return ESP_OK;
}
size_t off = 0;
off += snprintf(output + off, output_size - off,
"Scheduled jobs (%d):\n", count);
for (int i = 0; i < count && off < output_size - 1; i++) {
const cron_job_t *j = &jobs[i];
if (j->kind == CRON_KIND_EVERY) {
off += snprintf(output + off, output_size - off,
" %d. [%s] \"%s\" — every %lus, %s, next=%lld, last=%lld, ch=%s:%s\n",
i + 1, j->id, j->name,
(unsigned long)j->interval_s,
j->enabled ? "enabled" : "disabled",
(long long)j->next_run, (long long)j->last_run,
j->channel, j->chat_id);
} else {
off += snprintf(output + off, output_size - off,
" %d. [%s] \"%s\" — at %lld, %s, last=%lld, ch=%s:%s%s\n",
i + 1, j->id, j->name,
(long long)j->at_epoch,
j->enabled ? "enabled" : "disabled",
(long long)j->last_run,
j->channel, j->chat_id,
j->delete_after_run ? " (auto-delete)" : "");
}
}
ESP_LOGI(TAG, "cron_list: %d jobs", count);
return ESP_OK;
}
/* ── cron_remove ──────────────────────────────────────────────── */
esp_err_t tool_cron_remove_execute(const char *input_json, char *output, size_t output_size)
{
cJSON *root = cJSON_Parse(input_json);
if (!root) {
snprintf(output, output_size, "Error: invalid JSON input");
return ESP_ERR_INVALID_ARG;
}
const char *job_id = cJSON_GetStringValue(cJSON_GetObjectItem(root, "job_id"));
if (!job_id || strlen(job_id) == 0) {
snprintf(output, output_size, "Error: missing 'job_id' field");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
char job_id_copy[16] = {0};
strncpy(job_id_copy, job_id, sizeof(job_id_copy) - 1);
esp_err_t err = cron_remove_job(job_id_copy);
cJSON_Delete(root);
if (err == ESP_OK) {
snprintf(output, output_size, "OK: Removed cron job %s", job_id_copy);
} else if (err == ESP_ERR_NOT_FOUND) {
snprintf(output, output_size, "Error: job '%s' not found", job_id_copy);
} else {
snprintf(output, output_size, "Error: failed to remove job (%s)", esp_err_to_name(err));
}
ESP_LOGI(TAG, "cron_remove: %s -> %s", job_id_copy, esp_err_to_name(err));
return err;
}

22
main/tools/tool_cron.h Normal file
View File

@@ -0,0 +1,22 @@
#pragma once
#include "esp_err.h"
#include <stddef.h>
/**
* Add a scheduled cron job.
* Input JSON: { name, schedule_type ("every"/"at"), interval_s, at_epoch, message, channel?, chat_id? }
*/
esp_err_t tool_cron_add_execute(const char *input_json, char *output, size_t output_size);
/**
* List all scheduled cron jobs.
* Input JSON: {} (no required fields)
*/
esp_err_t tool_cron_list_execute(const char *input_json, char *output, size_t output_size);
/**
* Remove a scheduled cron job by ID.
* Input JSON: { job_id }
*/
esp_err_t tool_cron_remove_execute(const char *input_json, char *output, size_t output_size);

View File

@@ -4,6 +4,7 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <stdbool.h>
#include <dirent.h> #include <dirent.h>
#include <sys/stat.h> #include <sys/stat.h>
#include "esp_log.h" #include "esp_log.h"

View File

@@ -2,6 +2,7 @@
#include "tools/tool_web_search.h" #include "tools/tool_web_search.h"
#include "tools/tool_get_time.h" #include "tools/tool_get_time.h"
#include "tools/tool_files.h" #include "tools/tool_files.h"
#include "tools/tool_cron.h"
#include <string.h> #include <string.h>
#include "esp_log.h" #include "esp_log.h"
@@ -9,7 +10,7 @@
static const char *TAG = "tools"; static const char *TAG = "tools";
#define MAX_TOOLS 8 #define MAX_TOOLS 12
static mimi_tool_t s_tools[MAX_TOOLS]; static mimi_tool_t s_tools[MAX_TOOLS];
static int s_tool_count = 0; static int s_tool_count = 0;
@@ -130,6 +131,50 @@ esp_err_t tool_registry_init(void)
}; };
register_tool(&ld); register_tool(&ld);
/* Register cron_add */
mimi_tool_t ca = {
.name = "cron_add",
.description = "Schedule a recurring or one-shot task. The message will trigger an agent turn when the job fires.",
.input_schema_json =
"{\"type\":\"object\","
"\"properties\":{"
"\"name\":{\"type\":\"string\",\"description\":\"Short name for the job\"},"
"\"schedule_type\":{\"type\":\"string\",\"description\":\"'every' for recurring interval or 'at' for one-shot at a unix timestamp\"},"
"\"interval_s\":{\"type\":\"integer\",\"description\":\"Interval in seconds (required for 'every')\"},"
"\"at_epoch\":{\"type\":\"integer\",\"description\":\"Unix timestamp to fire at (required for 'at')\"},"
"\"message\":{\"type\":\"string\",\"description\":\"Message to inject when the job fires, triggering an agent turn\"},"
"\"channel\":{\"type\":\"string\",\"description\":\"Optional reply channel (e.g. 'telegram'). Defaults to 'system'\"},"
"\"chat_id\":{\"type\":\"string\",\"description\":\"Optional reply chat_id. Defaults to 'cron'\"}"
"},"
"\"required\":[\"name\",\"schedule_type\",\"message\"]}",
.execute = tool_cron_add_execute,
};
register_tool(&ca);
/* Register cron_list */
mimi_tool_t cl = {
.name = "cron_list",
.description = "List all scheduled cron jobs with their status, schedule, and IDs.",
.input_schema_json =
"{\"type\":\"object\","
"\"properties\":{},"
"\"required\":[]}",
.execute = tool_cron_list_execute,
};
register_tool(&cl);
/* Register cron_remove */
mimi_tool_t cr = {
.name = "cron_remove",
.description = "Remove a scheduled cron job by its ID.",
.input_schema_json =
"{\"type\":\"object\","
"\"properties\":{\"job_id\":{\"type\":\"string\",\"description\":\"The 8-character job ID to remove\"}},"
"\"required\":[\"job_id\"]}",
.execute = tool_cron_remove_execute,
};
register_tool(&cr);
build_tools_json(); build_tools_json();
ESP_LOGI(TAG, "Tool registry initialized"); ESP_LOGI(TAG, "Tool registry initialized");