From 31b15aa1f9d43051cd0272dcf561db9943950e3d Mon Sep 17 00:00:00 2001 From: crispyberry Date: Sat, 7 Feb 2026 17:54:52 +0800 Subject: [PATCH] feat: add file tools (read/write/edit/list_dir) for agent memory writes Enable the agent to persist memories by adding 4 SPIFFS file tools (read_file, write_file, edit_file, list_dir) with path validation, and update the system prompt with memory guidelines pointing to /spiffs/memory/MEMORY.md and daily notes. Co-Authored-By: Claude Opus 4.6 --- main/CMakeLists.txt | 1 + main/agent/context_builder.c | 13 +- main/tools/tool_files.c | 259 +++++++++++++++++++++++++++++++++++ main/tools/tool_files.h | 28 ++++ main/tools/tool_registry.c | 52 +++++++ 5 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 main/tools/tool_files.c create mode 100644 main/tools/tool_files.h diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index ae5c514..490a733 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -16,6 +16,7 @@ idf_component_register( "tools/tool_registry.c" "tools/tool_web_search.c" "tools/tool_get_time.c" + "tools/tool_files.c" INCLUDE_DIRS "." REQUIRES diff --git a/main/agent/context_builder.c b/main/agent/context_builder.c index 7a89f46..c6fb159 100644 --- a/main/agent/context_builder.c +++ b/main/agent/context_builder.c @@ -39,8 +39,17 @@ esp_err_t context_build_system_prompt(char *buf, size_t size) "- web_search: Search the web for current information. " "Use this when you need up-to-date facts, news, weather, or anything beyond your training data.\n" "- get_current_time: Get the current date and time. " - "You do NOT have an internal clock — always use this tool when you need to know the time or date.\n\n" - "Use tools when needed. Provide your final answer as text after using tools.\n"); + "You do NOT have an internal clock — always use this tool when you need to know the time or date.\n" + "- 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" + "Use tools when needed. Provide your final answer as text after using tools.\n\n" + "## Memory Guidelines\n" + "Your long-term memory is at /spiffs/memory/MEMORY.md — use write_file or edit_file to update it.\n" + "Daily notes are at /spiffs/memory/daily/.md — use get_current_time for today's date, then write_file to create/append.\n" + "When you learn something important about the user or need to remember something, persist it to memory.\n" + "Read /spiffs/memory/MEMORY.md first before writing, so you can append or edit without losing existing content.\n"); /* Bootstrap files */ off = append_file(buf, size, off, MIMI_SOUL_FILE, "Personality"); diff --git a/main/tools/tool_files.c b/main/tools/tool_files.c new file mode 100644 index 0000000..3fada9e --- /dev/null +++ b/main/tools/tool_files.c @@ -0,0 +1,259 @@ +#include "tools/tool_files.h" +#include "mimi_config.h" + +#include +#include +#include +#include +#include +#include "esp_log.h" +#include "cJSON.h" + +static const char *TAG = "tool_files"; + +#define MAX_FILE_SIZE (32 * 1024) + +/** + * Validate that a path starts with /spiffs/ and contains no ".." traversal. + */ +static bool validate_path(const char *path) +{ + if (!path) return false; + if (strncmp(path, "/spiffs/", 8) != 0) return false; + if (strstr(path, "..") != NULL) return false; + return true; +} + +/* ── read_file ─────────────────────────────────────────────── */ + +esp_err_t tool_read_file_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 *path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + if (!validate_path(path)) { + snprintf(output, output_size, "Error: path must start with /spiffs/ and must not contain '..'"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + FILE *f = fopen(path, "r"); + if (!f) { + snprintf(output, output_size, "Error: file not found: %s", path); + cJSON_Delete(root); + return ESP_ERR_NOT_FOUND; + } + + size_t max_read = output_size - 1; + if (max_read > MAX_FILE_SIZE) max_read = MAX_FILE_SIZE; + + size_t n = fread(output, 1, max_read, f); + output[n] = '\0'; + fclose(f); + + ESP_LOGI(TAG, "read_file: %s (%d bytes)", path, (int)n); + cJSON_Delete(root); + return ESP_OK; +} + +/* ── write_file ────────────────────────────────────────────── */ + +esp_err_t tool_write_file_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 *path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + const char *content = cJSON_GetStringValue(cJSON_GetObjectItem(root, "content")); + + if (!validate_path(path)) { + snprintf(output, output_size, "Error: path must start with /spiffs/ and must not contain '..'"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + if (!content) { + snprintf(output, output_size, "Error: missing 'content' field"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + FILE *f = fopen(path, "w"); + if (!f) { + snprintf(output, output_size, "Error: cannot open file for writing: %s", path); + cJSON_Delete(root); + return ESP_FAIL; + } + + size_t len = strlen(content); + size_t written = fwrite(content, 1, len, f); + fclose(f); + + if (written != len) { + snprintf(output, output_size, "Error: wrote %d of %d bytes to %s", (int)written, (int)len, path); + cJSON_Delete(root); + return ESP_FAIL; + } + + snprintf(output, output_size, "OK: wrote %d bytes to %s", (int)written, path); + ESP_LOGI(TAG, "write_file: %s (%d bytes)", path, (int)written); + cJSON_Delete(root); + return ESP_OK; +} + +/* ── edit_file ─────────────────────────────────────────────── */ + +esp_err_t tool_edit_file_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 *path = cJSON_GetStringValue(cJSON_GetObjectItem(root, "path")); + const char *old_str = cJSON_GetStringValue(cJSON_GetObjectItem(root, "old_string")); + const char *new_str = cJSON_GetStringValue(cJSON_GetObjectItem(root, "new_string")); + + if (!validate_path(path)) { + snprintf(output, output_size, "Error: path must start with /spiffs/ and must not contain '..'"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + if (!old_str || !new_str) { + snprintf(output, output_size, "Error: missing 'old_string' or 'new_string' field"); + cJSON_Delete(root); + return ESP_ERR_INVALID_ARG; + } + + /* Read existing file */ + FILE *f = fopen(path, "r"); + if (!f) { + snprintf(output, output_size, "Error: file not found: %s", path); + cJSON_Delete(root); + return ESP_ERR_NOT_FOUND; + } + + fseek(f, 0, SEEK_END); + long file_size = ftell(f); + fseek(f, 0, SEEK_SET); + + if (file_size <= 0 || file_size > MAX_FILE_SIZE) { + snprintf(output, output_size, "Error: file too large or empty (%ld bytes)", file_size); + fclose(f); + cJSON_Delete(root); + return ESP_ERR_INVALID_SIZE; + } + + /* Allocate buffer for the result (old content + possible expansion) */ + size_t old_len = strlen(old_str); + size_t new_len = strlen(new_str); + size_t max_result = file_size + (new_len > old_len ? new_len - old_len : 0) + 1; + char *buf = malloc(file_size + 1); + char *result = malloc(max_result); + if (!buf || !result) { + free(buf); + free(result); + fclose(f); + snprintf(output, output_size, "Error: out of memory"); + cJSON_Delete(root); + return ESP_ERR_NO_MEM; + } + + size_t n = fread(buf, 1, file_size, f); + buf[n] = '\0'; + fclose(f); + + /* Find and replace first occurrence */ + char *pos = strstr(buf, old_str); + if (!pos) { + snprintf(output, output_size, "Error: old_string not found in %s", path); + free(buf); + free(result); + cJSON_Delete(root); + return ESP_ERR_NOT_FOUND; + } + + size_t prefix_len = pos - buf; + memcpy(result, buf, prefix_len); + memcpy(result + prefix_len, new_str, new_len); + size_t suffix_start = prefix_len + old_len; + size_t suffix_len = n - suffix_start; + memcpy(result + prefix_len + new_len, buf + suffix_start, suffix_len); + size_t total = prefix_len + new_len + suffix_len; + result[total] = '\0'; + + free(buf); + + /* Write back */ + f = fopen(path, "w"); + if (!f) { + snprintf(output, output_size, "Error: cannot open file for writing: %s", path); + free(result); + cJSON_Delete(root); + return ESP_FAIL; + } + + fwrite(result, 1, total, f); + fclose(f); + free(result); + + snprintf(output, output_size, "OK: edited %s (replaced %d bytes with %d bytes)", path, (int)old_len, (int)new_len); + ESP_LOGI(TAG, "edit_file: %s", path); + cJSON_Delete(root); + return ESP_OK; +} + +/* ── list_dir ──────────────────────────────────────────────── */ + +esp_err_t tool_list_dir_execute(const char *input_json, char *output, size_t output_size) +{ + cJSON *root = cJSON_Parse(input_json); + const char *prefix = NULL; + if (root) { + cJSON *pfx = cJSON_GetObjectItem(root, "prefix"); + if (pfx && cJSON_IsString(pfx)) { + prefix = pfx->valuestring; + } + } + + DIR *dir = opendir(MIMI_SPIFFS_BASE); + if (!dir) { + snprintf(output, output_size, "Error: cannot open /spiffs directory"); + cJSON_Delete(root); + return ESP_FAIL; + } + + size_t off = 0; + struct dirent *ent; + int count = 0; + + while ((ent = readdir(dir)) != NULL && off < output_size - 1) { + /* Build full path: SPIFFS entries are just filenames with embedded slashes */ + char full_path[512]; + snprintf(full_path, sizeof(full_path), "%s/%s", MIMI_SPIFFS_BASE, ent->d_name); + + if (prefix && strncmp(full_path, prefix, strlen(prefix)) != 0) { + continue; + } + + off += snprintf(output + off, output_size - off, "%s\n", full_path); + count++; + } + + closedir(dir); + + if (count == 0) { + snprintf(output, output_size, "(no files found)"); + } + + ESP_LOGI(TAG, "list_dir: %d files (prefix=%s)", count, prefix ? prefix : "(none)"); + cJSON_Delete(root); + return ESP_OK; +} diff --git a/main/tools/tool_files.h b/main/tools/tool_files.h new file mode 100644 index 0000000..b84dd46 --- /dev/null +++ b/main/tools/tool_files.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esp_err.h" +#include + +/** + * Read a file from SPIFFS. + * Input JSON: {"path": "/spiffs/..."} + */ +esp_err_t tool_read_file_execute(const char *input_json, char *output, size_t output_size); + +/** + * Write/overwrite a file on SPIFFS. + * Input JSON: {"path": "/spiffs/...", "content": "..."} + */ +esp_err_t tool_write_file_execute(const char *input_json, char *output, size_t output_size); + +/** + * Find-and-replace edit a file on SPIFFS. + * Input JSON: {"path": "/spiffs/...", "old_string": "...", "new_string": "..."} + */ +esp_err_t tool_edit_file_execute(const char *input_json, char *output, size_t output_size); + +/** + * List files on SPIFFS, optionally filtered by path prefix. + * Input JSON: {"prefix": "/spiffs/..."} (prefix is optional) + */ +esp_err_t tool_list_dir_execute(const char *input_json, char *output, size_t output_size); diff --git a/main/tools/tool_registry.c b/main/tools/tool_registry.c index dd2483c..3f4d892 100644 --- a/main/tools/tool_registry.c +++ b/main/tools/tool_registry.c @@ -1,6 +1,7 @@ #include "tool_registry.h" #include "tools/tool_web_search.h" #include "tools/tool_get_time.h" +#include "tools/tool_files.h" #include #include "esp_log.h" @@ -78,6 +79,57 @@ esp_err_t tool_registry_init(void) }; register_tool(>); + /* Register read_file */ + mimi_tool_t rf = { + .name = "read_file", + .description = "Read a file from SPIFFS storage. Path must start with /spiffs/.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute path starting with /spiffs/\"}}," + "\"required\":[\"path\"]}", + .execute = tool_read_file_execute, + }; + register_tool(&rf); + + /* Register write_file */ + mimi_tool_t wf = { + .name = "write_file", + .description = "Write or overwrite a file on SPIFFS storage. Path must start with /spiffs/.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute path starting with /spiffs/\"}," + "\"content\":{\"type\":\"string\",\"description\":\"File content to write\"}}," + "\"required\":[\"path\",\"content\"]}", + .execute = tool_write_file_execute, + }; + register_tool(&wf); + + /* Register edit_file */ + mimi_tool_t ef = { + .name = "edit_file", + .description = "Find and replace text in a file on SPIFFS. Replaces first occurrence of old_string with new_string.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute path starting with /spiffs/\"}," + "\"old_string\":{\"type\":\"string\",\"description\":\"Text to find\"}," + "\"new_string\":{\"type\":\"string\",\"description\":\"Replacement text\"}}," + "\"required\":[\"path\",\"old_string\",\"new_string\"]}", + .execute = tool_edit_file_execute, + }; + register_tool(&ef); + + /* Register list_dir */ + mimi_tool_t ld = { + .name = "list_dir", + .description = "List files on SPIFFS storage, optionally filtered by path prefix.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{\"prefix\":{\"type\":\"string\",\"description\":\"Optional path prefix filter, e.g. /spiffs/memory/\"}}," + "\"required\":[]}", + .execute = tool_list_dir_execute, + }; + register_tool(&ld); + build_tools_json(); ESP_LOGI(TAG, "Tool registry initialized");