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
.DS_Store
Thumbs.db
references/
references/

View File

@@ -26,6 +26,9 @@ idf_component_register(
"tools/tool_web_search.c"
"tools/tool_get_time.c"
"tools/tool_files.c"
"tools/tool_cron.c"
"cron/cron_service.c"
"heartbeat/heartbeat.c"
INCLUDE_DIRS
"."
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"
"- write_file: Write/overwrite 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"
"## Memory\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"
"- 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");

View File

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

View File

@@ -7,6 +7,9 @@
#include "memory/session_mgr.h"
#include "proxy/http_proxy.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 <stdio.h>
@@ -316,6 +319,54 @@ 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;
}
/* --- 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 --- */
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);
/* 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 */
esp_console_cmd_t restart_cmd = {
.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 char s_api_key[128] = {0};
static char s_model[64] = MIMI_LLM_DEFAULT_MODEL;
static char s_provider[16] = MIMI_LLM_PROVIDER_DEFAULT;
#define LLM_API_KEY_MAX_LEN 256
#define LLM_MODEL_MAX_LEN 64
#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)
{
@@ -118,20 +122,23 @@ esp_err_t llm_proxy_init(void)
/* NVS overrides take highest priority (set via CLI) */
nvs_handle_t nvs;
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);
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);
}
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));
if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, tmp, &len) == ESP_OK && tmp[0]) {
safe_copy(s_provider, sizeof(s_provider), tmp);
char model_tmp[LLM_MODEL_MAX_LEN] = {0};
len = sizeof(model_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);
}

View File

@@ -26,6 +26,8 @@
#include "ui/config_screen.h"
#include "imu/imu_manager.h"
#include "rgb/rgb.h"
#include "cron/cron_service.h"
#include "heartbeat/heartbeat.h"
static const char *TAG = "mimi";
@@ -77,6 +79,8 @@ static void outbound_dispatch_task(void *arg)
telegram_send_message(msg.chat_id, msg.content);
} else if (strcmp(msg.channel, MIMI_CHAN_WEBSOCKET) == 0) {
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 {
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(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) */
@@ -141,6 +147,8 @@ void app_main(void)
/* Start network-dependent services */
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 */

View File

@@ -84,6 +84,15 @@
#define MIMI_CONTEXT_BUF_SIZE (16 * 1024)
#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 */
#define MIMI_WS_PORT 18789
#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 <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <dirent.h>
#include <sys/stat.h>
#include "esp_log.h"

View File

@@ -2,6 +2,7 @@
#include "tools/tool_web_search.h"
#include "tools/tool_get_time.h"
#include "tools/tool_files.h"
#include "tools/tool_cron.h"
#include <string.h>
#include "esp_log.h"
@@ -9,7 +10,7 @@
static const char *TAG = "tools";
#define MAX_TOOLS 8
#define MAX_TOOLS 12
static mimi_tool_t s_tools[MAX_TOOLS];
static int s_tool_count = 0;
@@ -130,6 +131,50 @@ esp_err_t tool_registry_init(void)
};
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();
ESP_LOGI(TAG, "Tool registry initialized");