diff options
| author | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:55:33 +0000 |
|---|---|---|
| committer | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:56:50 +0000 |
| commit | f8423ebea413605b42de23f5dd06c5479d3fa1dd (patch) | |
| tree | 2612a5eb026c59e7aae01f10a1cf62f2ee43042c /include/dotagents.h | |
Diffstat (limited to 'include/dotagents.h')
| -rw-r--r-- | include/dotagents.h | 1282 |
1 files changed, 1282 insertions, 0 deletions
diff --git a/include/dotagents.h b/include/dotagents.h new file mode 100644 index 0000000..659e156 --- /dev/null +++ b/include/dotagents.h @@ -0,0 +1,1282 @@ +/* dotagents.h — portable, header-only implementation of the .agents protocol. + * + * The .agents protocol (dotagentsprotocol.com) is an open directory + * convention for AI agent configuration: one `.agents/` directory holds + * MCP server config, agent guidelines (AGENTS.md-compatible), skills, + * sub-agent profiles, repeat tasks, persistent memories, and model presets — + * in plain, version-controllable files. + * + * This library is the read-only side: it parses and resolves two layers — + * global `~/.agents/` and workspace `./.agents/` — with workspace winning on + * conflicts. It does not write. + * + * Directory schema (per layer): + * agents.md instructions (AGENTS.md-compatible) + * system-prompt.md system prompt + * mcp.json MCP server configuration + * models.json model presets & provider keys + * speakmcp-settings.json general settings + * layouts/ui.json UI/layout prefs + * skills/<id>/skill.md skill definition + * agents/<id>/agent.md sub-agent profile (+ optional config.json) + * tasks/<id>/task.md repeat task definition + * memories/ persistent memory entries (markdown files) + * .backups/ ignored (write-side artifact) + * + * Merge rules (final = global + workspace, workspace wins): + * - agents.md / system-prompt.md: whole-file replacement (workspace wins) + * - models.json: `default` replaced; `presets` merged by preset name + * (workspace wins per name) + * - mcp.json: `mcpServers` merged by server name (workspace wins per name) + * - speakmcp-settings.json / layouts/ui.json: shallow-merged by top-level + * key (workspace wins per key) + * - skills / agents / tasks / memories: merged by id (workspace wins per id) + * - unknown files and malformed files: warned about, skipped (never fatal) + * + * Conformance (read-only): + * [x] agents.md, system-prompt.md, mcp.json, models.json + * [x] speakmcp-settings.json, layouts/ui.json + * [x] skills/, agents/ (+ config.json), tasks/, memories/ + * [x] frontmatter: quoted values, CSV + JSON-array lists, bool, int + * [x] task `profileId`, agent `connection-type` + * [x] case-insensitive file discovery (AGENTS.MD, SKILL.md, ...) + * [x] deterministic id-sorted output for stable diffs + * [ ] write side (atomic writes, .backups/ rotation) — out of scope + * [ ] .dotagents bundles / Hub — out of scope + * + * Usage: #include "dotagents.h" in your code. Requires frontmatter.h in the + * same directory (or on the include path). Zero dependencies beyond + * frontmatter.h and libc. All functions are static inline; safe to include + * in any number of translation units. + * + * API: + * dotagents_load(out) load ~/.agents + ./.agents (cwd) + * dotagents_load_from(out, ws, global) load two explicit layer dirs + * dotagents_free(out) free everything + * dotagents_project_sections(d, cap) guidelines + skill index + memory note + * dotagents_compose_preamble(d, base, cap) base + sections + * dotagents_find_skill(d, id) skill or NULL + * dotagents_enabled_sub_agent(d, id) enabled sub-agent or NULL + * dotagents_mcp_config(d, name) raw per-server JSON or NULL + * models_file_get(models, name) preset or NULL + * models_file_free(models) free a models_file + * memory_importance_rank(m) 0 high, 1 medium, 2 else + * parse_frontmatter / doc_get / doc_free / fm_list_items ... + * from frontmatter.h (re-exported) + * + * Memory: all strings are owned by the `dotagents` struct until + * dotagents_free. Load never fails: missing layers and malformed files are + * recorded in `out->warnings` (or silently skipped when absent). + */ +#ifndef DOTAGENTS_H +#define DOTAGENTS_H + +#include "frontmatter.h" + +#include <dirent.h> +#include <strings.h> +#include <sys/stat.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +typedef enum { + LAYER_GLOBAL, + LAYER_WORKSPACE, +} layer; + +typedef struct { + char *id; + char *name; + char *description; + bool enabled; + char *body; + layer layer; +} skill; + +typedef struct { + char *id; + char *name; + char *description; + char *role; + char *connection_type; /* `connection-type` frontmatter, or NULL */ + bool enabled; + char *body; + char *config; /* raw JSON text, or NULL */ + layer layer; +} sub_agent; + +typedef struct { + char *id; + char *name; + char *profile_id; /* `profileId` frontmatter, or NULL */ + unsigned long interval_minutes; + bool has_interval; + bool enabled; + bool run_on_startup; + char *body; + layer layer; +} task_entry; + +typedef struct { + char *id; + char *title; + char *importance; + char **tags; + size_t tag_count; + char *summary; + char *body; + layer layer; +} memory; + +typedef struct { + char *name; + char *provider; + char *model; + char *base_url; + char *api_key; + unsigned long max_turns; + bool has_max_turns; + unsigned long context_limit; + bool has_context_limit; +} model_preset; + +typedef struct { + char *default_name; + model_preset *presets; + size_t preset_count; +} models_file; + +typedef struct { + layer *found_layers; + char **found_paths; + size_t found_count; + fm_strlist warnings; + char *system_prompt; + char *agents_md; + char *settings_json; /* merged speakmcp-settings.json, or NULL */ + char *layouts_json; /* merged layouts/ui.json, or NULL */ + fm_json *settings; /* accumulated merge tree (transient) */ + fm_json *layouts; /* accumulated merge tree (transient) */ + models_file models; + bool has_models; + char **mcp_names; + char **mcp_configs; /* raw JSON per server */ + size_t mcp_count; + skill *skills; + size_t skill_count; + sub_agent *sub_agents; + size_t sub_agent_count; + task_entry *tasks; + size_t task_count; + memory *memories; + size_t memory_count; +} dotagents; + +/* qsort comparators (replace the old O(n^2) inline sorts) */ +typedef struct { char *name; char *path; } da_dirent; +static int da_cmp_dirent(const void *a, const void *b) { + return strcmp(((const da_dirent *)a)->name, ((const da_dirent *)b)->name); +} +static int da_cmp_skill_id(const void *a, const void *b) { + return strcmp(((const skill *)a)->id, ((const skill *)b)->id); +} +static int da_cmp_subagent_id(const void *a, const void *b) { + return strcmp(((const sub_agent *)a)->id, ((const sub_agent *)b)->id); +} +static int da_cmp_task_id(const void *a, const void *b) { + return strcmp(((const task_entry *)a)->id, ((const task_entry *)b)->id); +} +static int da_cmp_memory_id(const void *a, const void *b) { + return strcmp(((const memory *)a)->id, ((const memory *)b)->id); +} +static int da_cmp_cstr(const void *a, const void *b) { + return strcmp(*(const char *const *)a, *(const char *const *)b); +} + +/* ------------------------------------------------------------------ */ +/* Layer helpers */ +/* ------------------------------------------------------------------ */ + +static inline const char *layer_short(layer l) { + return l == LAYER_GLOBAL ? "global" : "workspace"; +} + +static inline const char *layer_display(layer l) { + return l == LAYER_GLOBAL ? "~/.agents" : ".agents"; +} + +/* ------------------------------------------------------------------ */ +/* File I/O */ +/* ------------------------------------------------------------------ */ + +static inline bool da_is_file(const char *p) { + struct stat st; + return stat(p, &st) == 0 && S_ISREG(st.st_mode); +} + +static inline bool da_is_dir(const char *p) { + struct stat st; + return stat(p, &st) == 0 && S_ISDIR(st.st_mode); +} + +/* Read a whole file. 0 on success, -1 on any failure. */ +static inline int da_read_file(const char *path, char **out, size_t *out_len) { + FILE *f = fopen(path, "rb"); + if (!f) + return -1; + fm_buf b; + fm_buf_init(&b); + char chunk[8192]; + size_t r; + while ((r = fread(chunk, 1, sizeof chunk, f)) > 0) + fm_buf_putn(&b, chunk, r); + int fe = ferror(f); + fclose(f); + if (fe) { + fm_buf_free(&b); + return -1; + } + size_t total = b.len; + if (out) + *out = fm_buf_steal(&b); + if (out_len) + *out_len = total; + return 0; +} + +static inline bool da_dir_has_entry(const char *dir) { + DIR *d = opendir(dir); + if (!d) + return false; + bool has = readdir(d) != NULL; + closedir(d); + return has; +} + +/* Find `{stem}.md` case-insensitively directly inside dir. */ +static inline char *da_find_md(const char *dir, const char *stem) { + char *want = fm_strfmt("%s.md", stem); + for (char *p = want; *p; p++) + *p = (char)tolower((unsigned char)*p); + DIR *d = opendir(dir); + if (!d) { + free(want); + return NULL; + } + char *result = NULL; + struct dirent *e; + while ((e = readdir(d))) { + if (strcasecmp(e->d_name, want) == 0) { + result = fm_strfmt("%s/%s", dir, e->d_name); + break; + } + } + closedir(d); + free(want); + return result; +} + +/* Sorted subdirectories of `dir`: returns name+path arrays. */ +static inline void da_sub_dirs(const char *dir, char ***out_names, + char ***out_paths, size_t *out_count) { + *out_count = 0; + DIR *d = opendir(dir); + if (!d) + return; + size_t cap = 8, n = 0; + char **names = (char **)fm_xmalloc(cap * sizeof(char *)); + char **paths = (char **)fm_xmalloc(cap * sizeof(char *)); + struct dirent *e; + while ((e = readdir(d))) { + if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) + continue; + char *path = fm_strfmt("%s/%s", dir, e->d_name); + if (!da_is_dir(path)) { + free(path); + continue; + } + if (n + 1 > cap) { + cap *= 2; + names = (char **)fm_xrealloc(names, cap * sizeof(char *)); + paths = (char **)fm_xrealloc(paths, cap * sizeof(char *)); + } + names[n] = fm_strdup(e->d_name); + paths[n] = path; + n++; + } + closedir(d); + /* sort by name (keep the parallel paths array in lockstep) */ + if (n > 1) { + da_dirent *ents = (da_dirent *)fm_xmalloc(n * sizeof *ents); + for (size_t i = 0; i < n; i++) { + ents[i].name = names[i]; + ents[i].path = paths[i]; + } + qsort(ents, n, sizeof *ents, da_cmp_dirent); + for (size_t i = 0; i < n; i++) { + names[i] = ents[i].name; + paths[i] = ents[i].path; + } + free(ents); + } + *out_names = names; + *out_paths = paths; + *out_count = n; +} + +/* Does this root hold any recognizable protocol content? */ +static inline bool da_has_protocol_content(const char *root) { + const char *stems[2] = {"agents", "system-prompt"}; + for (size_t i = 0; i < 2; i++) { + char *md = da_find_md(root, stems[i]); + if (md) { + free(md); + return true; + } + } + const char *files[4] = {"models.json", "mcp.json", + "speakmcp-settings.json", "layouts/ui.json"}; + for (size_t i = 0; i < 4; i++) { + char *p = fm_strfmt("%s/%s", root, files[i]); + bool has = da_is_file(p); + free(p); + if (has) + return true; + } + const char *subs[4] = {"skills", "agents", "tasks", "memories"}; + for (size_t i = 0; i < 4; i++) { + char *p = fm_strfmt("%s/%s", root, subs[i]); + bool has = da_is_dir(p) && da_dir_has_entry(p); + free(p); + if (has) + return true; + } + return false; +} + +/* ------------------------------------------------------------------ */ +/* Accumulator helpers */ +/* ------------------------------------------------------------------ */ + +static inline void da_warn(dotagents *out, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *msg = fm_vsnprintf(fmt, ap); + va_end(ap); + fm_sl_push(&out->warnings, msg); +} + +static inline char *da_read_optional(const char *path, layer layer, + dotagents *out) { + char *raw = NULL; + size_t len = 0; + if (da_read_file(path, &raw, &len) != 0) { + struct stat st; + if (stat(path, &st) == 0) { + /* exists but unreadable */ + const char *base = strrchr(path, '/'); + da_warn(out, "%s: %s: read error", layer_display(layer), + base ? base + 1 : path); + } + free(raw); + return NULL; + } + /* whitespace-only: treat as absent */ + size_t i = 0; + while (i < len && (raw[i] == ' ' || raw[i] == '\t' || raw[i] == '\n' || + raw[i] == '\r')) + i++; + if (i == len) { + free(raw); + return NULL; + } + return raw; +} + +/* ------------------------------------------------------------------ */ +/* Ingest */ +/* ------------------------------------------------------------------ */ + +static inline void da_ingest_models_json(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/models.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o || o->type != FMJ_OBJECT) { + da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer)); + fm_json_free(o); + return; + } + /* unknown field check (deny_unknown_fields) */ + bool ok = true; + for (size_t i = 0; i < o->count; i++) { + const char *k = o->items[i] ? o->items[i]->key : NULL; + if (!k || (strcmp(k, "default") != 0 && strcmp(k, "presets") != 0)) { + ok = false; + break; + } + } + if (ok) { + const fm_json *def = fm_json_get(o, "default"); + if (def && def->type == FMJ_STRING) { + free(out->models.default_name); + out->models.default_name = fm_strdup(def->str); + } + const fm_json *pres = fm_json_get(o, "presets"); + if (pres && pres->type == FMJ_OBJECT) { + for (size_t pi = 0; pi < pres->count; pi++) { + const fm_json *pv = pres->items[pi]; + if (!pv || pv->type != FMJ_OBJECT) { + da_warn(out, "%s: models.json: preset `%s` is not an " + "object; skipped", + layer_short(layer), pv && pv->key ? pv->key : "?"); + continue; + } + const fm_json *bad = NULL; + for (size_t fi = 0; fi < pv->count; fi++) { + const char *fk = pv->items[fi] ? pv->items[fi]->key : NULL; + if (!fk || + (strcmp(fk, "provider") != 0 && + strcmp(fk, "model") != 0 && + strcmp(fk, "base_url") != 0 && + strcmp(fk, "api_key") != 0 && + strcmp(fk, "max_turns") != 0 && + strcmp(fk, "context_limit") != 0)) { + bad = pv->items[fi]; + break; + } + } + if (bad) { + da_warn(out, "%s: models.json: preset `%s` has unknown " + "field `%s`; skipped", + layer_short(layer), pv->key ? pv->key : "?", + bad->key ? bad->key : "?"); + continue; + } + model_preset mp; + memset(&mp, 0, sizeof mp); + mp.name = fm_strdup(pv->key ? pv->key : ""); + const fm_json *s; + if ((s = fm_json_get(pv, "provider")) && s->type == FMJ_STRING) + mp.provider = fm_strdup(s->str); + if ((s = fm_json_get(pv, "model")) && s->type == FMJ_STRING) + mp.model = fm_strdup(s->str); + if ((s = fm_json_get(pv, "base_url")) && s->type == FMJ_STRING) + mp.base_url = fm_strdup(s->str); + if ((s = fm_json_get(pv, "api_key")) && s->type == FMJ_STRING) + mp.api_key = fm_strdup(s->str); + if ((s = fm_json_get(pv, "max_turns")) && + s->type == FMJ_NUMBER) { + mp.max_turns = (unsigned long)s->num; + mp.has_max_turns = true; + } + if ((s = fm_json_get(pv, "context_limit")) && + s->type == FMJ_NUMBER) { + mp.context_limit = (unsigned long)s->num; + mp.has_context_limit = true; + } + /* replace by name or append */ + bool replaced = false; + for (size_t i = 0; i < out->models.preset_count; i++) { + if (strcmp(out->models.presets[i].name, pv->key) == 0) { + free(out->models.presets[i].name); + free(out->models.presets[i].provider); + free(out->models.presets[i].model); + free(out->models.presets[i].base_url); + free(out->models.presets[i].api_key); + out->models.presets[i] = mp; + replaced = true; + break; + } + } + if (!replaced) { + out->models.presets = (model_preset *)fm_xrealloc( + out->models.presets, + (out->models.preset_count + 1) * + sizeof(model_preset)); + out->models.presets[out->models.preset_count++] = mp; + } + out->has_models = true; + } + } + } else { + da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer)); + } + fm_json_free(o); +} + +static inline void da_ingest_mcp(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/mcp.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o) { + da_warn(out, "%s: mcp.json: invalid JSON; skipped", layer_short(layer)); + return; + } + const fm_json *servers = fm_json_get(o, "mcpServers"); + if (!servers || servers->type != FMJ_OBJECT) { + da_warn(out, "%s: mcp.json: missing `mcpServers` object; skipped", + layer_short(layer)); + fm_json_free(o); + return; + } + for (size_t i = 0; i < servers->count; i++) { + const fm_json *sv = servers->items[i]; + if (!sv) + continue; + char *cfg = fm_json_serialize(sv); + bool replaced = false; + for (size_t j = 0; j < out->mcp_count; j++) { + if (strcmp(out->mcp_names[j], sv->key ? sv->key : "") == 0) { + free(out->mcp_configs[j]); + out->mcp_configs[j] = cfg; + replaced = true; + break; + } + } + if (!replaced) { + out->mcp_names = + (char **)fm_xrealloc(out->mcp_names, + (out->mcp_count + 1) * sizeof(char *)); + out->mcp_configs = + (char **)fm_xrealloc(out->mcp_configs, + (out->mcp_count + 1) * sizeof(char *)); + out->mcp_names[out->mcp_count] = + fm_strdup(sv->key ? sv->key : ""); + out->mcp_configs[out->mcp_count] = cfg; + out->mcp_count++; + } + } + fm_json_free(o); +} + +static inline void da_ingest_settings(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/speakmcp-settings.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o || o->type != FMJ_OBJECT) { + da_warn(out, "%s: speakmcp-settings.json: invalid JSON; skipped", + layer_short(layer)); + fm_json_free(o); + return; + } + out->settings = fm_json_merge_owned(out->settings, o); +} + +static inline void da_ingest_layouts(const char *root, layer layer, + dotagents *out) { + char *path = fm_strfmt("%s/layouts/ui.json", root); + char *raw = da_read_optional(path, layer, out); + free(path); + if (!raw) + return; + fm_json *o = fm_json_parse(raw); + free(raw); + if (!o || o->type != FMJ_OBJECT) { + da_warn(out, "%s: layouts/ui.json: invalid JSON; skipped", + layer_short(layer)); + fm_json_free(o); + return; + } + out->layouts = fm_json_merge_owned(out->layouts, o); +} + +static inline void da_ingest_skills(const char *root, layer layer, + dotagents *out) { + char **names = NULL, **paths = NULL; + size_t n = 0; + char *base = fm_strfmt("%s/skills", root); + da_sub_dirs(base, &names, &paths, &n); + free(base); + for (size_t i = 0; i < n; i++) { + char *md = da_find_md(paths[i], "skill"); + if (!md) { + da_warn(out, "%s: skills/%s: missing skill.md; skipped", + layer_short(layer), names[i]); + continue; + } + char *text = NULL; + if (da_read_file(md, &text, NULL) != 0) { + da_warn(out, "%s: skills/%s: skill.md unreadable; skipped", + layer_short(layer), names[i]); + free(md); + continue; + } + doc d; + parse_frontmatter(text, &d); + free(text); + + char *id = names[i]; + const fm_value *v; + if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *name = names[i]; + if ((v = doc_get(&d, "name")) && v->kind == FM_STR) + name = v->s; + char *desc = ""; + if ((v = doc_get(&d, "description")) && v->kind == FM_STR) + desc = v->s; + bool enabled = true; + if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL) + enabled = v->b; + + skill s; + s.id = fm_strdup(id); + s.name = fm_strdup(name); + s.description = fm_strdup(desc); + s.enabled = enabled; + s.body = fm_strdup(d.body); + s.layer = layer; + + bool replaced = false; + for (size_t j = 0; j < out->skill_count; j++) { + if (strcmp(out->skills[j].id, s.id) == 0) { + free(out->skills[j].id); + free(out->skills[j].name); + free(out->skills[j].description); + free(out->skills[j].body); + out->skills[j] = s; + replaced = true; + break; + } + } + if (!replaced) { + out->skills = (skill *)fm_xrealloc( + out->skills, (out->skill_count + 1) * sizeof(skill)); + out->skills[out->skill_count++] = s; + } + doc_free(&d); + free(md); + } + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(paths[i]); + } + free(names); + free(paths); + /* keep sorted by id */ + if (out->skill_count > 1) + qsort(out->skills, out->skill_count, sizeof *out->skills, + da_cmp_skill_id); +} + +static inline void da_ingest_sub_agents(const char *root, layer layer, + dotagents *out) { + char **names = NULL, **paths = NULL; + size_t n = 0; + char *base = fm_strfmt("%s/agents", root); + da_sub_dirs(base, &names, &paths, &n); + free(base); + for (size_t i = 0; i < n; i++) { + char *md = da_find_md(paths[i], "agent"); + if (!md) { + da_warn(out, "%s: agents/%s: missing agent.md; skipped", + layer_short(layer), names[i]); + continue; + } + char *text = NULL; + if (da_read_file(md, &text, NULL) != 0) { + da_warn(out, "%s: agents/%s: agent.md unreadable; skipped", + layer_short(layer), names[i]); + free(md); + continue; + } + doc d; + parse_frontmatter(text, &d); + free(text); + + /* optional config.json next to agent.md */ + char *config = NULL; + char *cp = fm_strfmt("%s/config.json", paths[i]); + char *craw = NULL; + if (da_read_file(cp, &craw, NULL) == 0) { + size_t cl = strlen(craw); + size_t ci = 0; + while (ci < cl && (craw[ci] == ' ' || craw[ci] == '\t' || + craw[ci] == '\n' || craw[ci] == '\r')) + ci++; + if (ci < cl) { + fm_json *cv = fm_json_parse(craw); + if (cv) { + config = fm_json_serialize(cv); + fm_json_free(cv); + } else { + da_warn(out, "%s: agents/%s/config.json: invalid JSON; " + "skipped", + layer_short(layer), names[i]); + } + } + free(craw); + } + free(cp); + + char *id = names[i]; + const fm_value *v; + if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *name = names[i]; + if ((v = doc_get(&d, "name")) && v->kind == FM_STR) + name = v->s; + char *desc = ""; + if ((v = doc_get(&d, "description")) && v->kind == FM_STR) + desc = v->s; + char *role = "delegation-target"; + if ((v = doc_get(&d, "role")) && v->kind == FM_STR) + role = v->s; + char *ctype = NULL; + if ((v = doc_get(&d, "connection-type")) && v->kind == FM_STR) + ctype = v->s; + bool enabled = true; + if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL) + enabled = v->b; + + sub_agent a; + a.id = fm_strdup(id); + a.name = fm_strdup(name); + a.description = fm_strdup(desc); + a.role = fm_strdup(role); + a.connection_type = ctype ? fm_strdup(ctype) : NULL; + a.enabled = enabled; + a.body = fm_strdup(d.body); + a.config = config; + a.layer = layer; + + bool replaced = false; + for (size_t j = 0; j < out->sub_agent_count; j++) { + if (strcmp(out->sub_agents[j].id, a.id) == 0) { + free(out->sub_agents[j].id); + free(out->sub_agents[j].name); + free(out->sub_agents[j].description); + free(out->sub_agents[j].role); + free(out->sub_agents[j].connection_type); + free(out->sub_agents[j].body); + free(out->sub_agents[j].config); + out->sub_agents[j] = a; + replaced = true; + break; + } + } + if (!replaced) { + out->sub_agents = + (sub_agent *)fm_xrealloc(out->sub_agents, + (out->sub_agent_count + 1) * + sizeof(sub_agent)); + out->sub_agents[out->sub_agent_count++] = a; + } + doc_free(&d); + free(md); + } + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(paths[i]); + } + free(names); + free(paths); + if (out->sub_agent_count > 1) + qsort(out->sub_agents, out->sub_agent_count, sizeof *out->sub_agents, + da_cmp_subagent_id); +} + +static inline void da_ingest_tasks(const char *root, layer layer, + dotagents *out) { + char **names = NULL, **paths = NULL; + size_t n = 0; + char *base = fm_strfmt("%s/tasks", root); + da_sub_dirs(base, &names, &paths, &n); + free(base); + for (size_t i = 0; i < n; i++) { + char *md = da_find_md(paths[i], "task"); + if (!md) { + da_warn(out, "%s: tasks/%s: missing task.md; skipped", + layer_short(layer), names[i]); + continue; + } + char *text = NULL; + if (da_read_file(md, &text, NULL) != 0) { + da_warn(out, "%s: tasks/%s: task.md unreadable; skipped", + layer_short(layer), names[i]); + free(md); + continue; + } + doc d; + parse_frontmatter(text, &d); + free(text); + + char *id = names[i]; + const fm_value *v; + if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *name = names[i]; + if ((v = doc_get(&d, "name")) && v->kind == FM_STR) + name = v->s; + char *pid = NULL; + if ((v = doc_get(&d, "profileId")) && v->kind == FM_STR && v->s && + *v->s) + pid = v->s; + + task_entry t; + memset(&t, 0, sizeof t); + t.id = fm_strdup(id); + t.name = fm_strdup(name); + t.profile_id = pid ? fm_strdup(pid) : NULL; + if ((v = doc_get(&d, "intervalMinutes")) && v->kind == FM_INT && + v->i >= 0) { + t.interval_minutes = (unsigned long)v->i; + t.has_interval = true; + } + t.enabled = true; + if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL) + t.enabled = v->b; + t.run_on_startup = false; + if ((v = doc_get(&d, "runOnStartup")) && v->kind == FM_BOOL) + t.run_on_startup = v->b; + t.body = fm_strdup(d.body); + t.layer = layer; + + bool replaced = false; + for (size_t j = 0; j < out->task_count; j++) { + if (strcmp(out->tasks[j].id, t.id) == 0) { + free(out->tasks[j].id); + free(out->tasks[j].name); + free(out->tasks[j].profile_id); + free(out->tasks[j].body); + out->tasks[j] = t; + replaced = true; + break; + } + } + if (!replaced) { + out->tasks = + (task_entry *)fm_xrealloc(out->tasks, + (out->task_count + 1) * + sizeof(task_entry)); + out->tasks[out->task_count++] = t; + } + doc_free(&d); + free(md); + } + for (size_t i = 0; i < n; i++) { + free(names[i]); + free(paths[i]); + } + free(names); + free(paths); + if (out->task_count > 1) + qsort(out->tasks, out->task_count, sizeof *out->tasks, + da_cmp_task_id); +} + +static inline void da_ingest_memories(const char *root, layer layer, + dotagents *out) { + char *dir = fm_strfmt("%s/memories", root); + DIR *d = opendir(dir); + if (!d) { + free(dir); + return; + } + /* collect .md files */ + size_t cap = 8, n = 0; + char **files = (char **)fm_xmalloc(cap * sizeof(char *)); + struct dirent *e; + while ((e = readdir(d))) { + char *p = fm_strfmt("%s/%s", dir, e->d_name); + if (!da_is_file(p)) { + free(p); + continue; + } + const char *dot = strrchr(e->d_name, '.'); + if (!dot || strcasecmp(dot, ".md") != 0) { + free(p); + continue; + } + if (n + 1 > cap) { + cap *= 2; + files = (char **)fm_xrealloc(files, cap * sizeof(char *)); + } + files[n++] = p; + } + closedir(d); + if (n > 1) + qsort(files, n, sizeof *files, da_cmp_cstr); + for (size_t i = 0; i < n; i++) { + const char *base = strrchr(files[i], '/'); + base = base ? base + 1 : files[i]; + const char *dot = strrchr(base, '.'); + char *stem = fm_strndup( + base, dot ? (size_t)(dot - base) : strlen(base)); + char *text = NULL; + if (da_read_file(files[i], &text, NULL) != 0) { + da_warn(out, "%s: memories/%s.md: unreadable; skipped", + layer_short(layer), stem); + free(stem); + continue; + } + doc d2; + parse_frontmatter(text, &d2); + free(text); + + char *id = stem; + const fm_value *v; + if ((v = doc_get(&d2, "id")) && v->kind == FM_STR && v->s && *v->s) + id = v->s; + char *title = stem; + if ((v = doc_get(&d2, "title")) && v->kind == FM_STR) + title = v->s; + char *importance = "medium"; + if ((v = doc_get(&d2, "importance")) && v->kind == FM_STR) + importance = v->s; + char *summary = ""; + if ((v = doc_get(&d2, "content")) && v->kind == FM_STR) + summary = v->s; + + memory m; + memset(&m, 0, sizeof m); + m.id = fm_strdup(id); + m.title = fm_strdup(title); + m.importance = fm_strdup(importance); + m.summary = fm_strdup(summary); + m.body = fm_strdup(d2.body); + m.layer = layer; + if ((v = doc_get(&d2, "tags"))) { + fm_strlist items; + fm_list_items(v, &items); + m.tags = (char **)fm_xmalloc( + (items.len ? items.len : 1) * sizeof(char *)); + for (size_t k = 0; k < items.len; k++) + m.tags[k] = fm_strdup(items.items[k]); + m.tag_count = items.len; + fm_sl_free(&items); + } + + bool replaced = false; + for (size_t j = 0; j < out->memory_count; j++) { + if (strcmp(out->memories[j].id, m.id) == 0) { + free(out->memories[j].id); + free(out->memories[j].title); + free(out->memories[j].importance); + free(out->memories[j].summary); + free(out->memories[j].body); + for (size_t k = 0; k < out->memories[j].tag_count; k++) + free(out->memories[j].tags[k]); + free(out->memories[j].tags); + out->memories[j] = m; + replaced = true; + break; + } + } + if (!replaced) { + out->memories = + (memory *)fm_xrealloc(out->memories, + (out->memory_count + 1) * + sizeof(memory)); + out->memories[out->memory_count++] = m; + } + doc_free(&d2); + free(stem); + } + for (size_t i = 0; i < n; i++) + free(files[i]); + free(files); + free(dir); + if (out->memory_count > 1) + qsort(out->memories, out->memory_count, sizeof *out->memories, + da_cmp_memory_id); +} + +static inline void da_ingest(const char *root, layer layer, dotagents *out) { + char *agents_md = da_find_md(root, "agents"); + if (agents_md) { + char *text = da_read_optional(agents_md, layer, out); + if (text) { + free(out->agents_md); + out->agents_md = text; + } + free(agents_md); + } + char *sp = da_find_md(root, "system-prompt"); + if (sp) { + char *text = da_read_optional(sp, layer, out); + if (text) { + free(out->system_prompt); + out->system_prompt = text; + } + free(sp); + } + da_ingest_models_json(root, layer, out); + da_ingest_mcp(root, layer, out); + da_ingest_settings(root, layer, out); + da_ingest_layouts(root, layer, out); + da_ingest_skills(root, layer, out); + da_ingest_sub_agents(root, layer, out); + da_ingest_tasks(root, layer, out); + da_ingest_memories(root, layer, out); +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +static inline void dotagents_free(dotagents *d) { + free(d->found_layers); + for (size_t i = 0; i < d->found_count; i++) + free(d->found_paths[i]); + free(d->found_paths); + fm_sl_free(&d->warnings); + free(d->system_prompt); + free(d->agents_md); + free(d->settings_json); + free(d->layouts_json); + fm_json_free(d->settings); + fm_json_free(d->layouts); + free(d->models.default_name); + for (size_t i = 0; i < d->models.preset_count; i++) { + free(d->models.presets[i].name); + free(d->models.presets[i].provider); + free(d->models.presets[i].model); + free(d->models.presets[i].base_url); + free(d->models.presets[i].api_key); + } + free(d->models.presets); + for (size_t i = 0; i < d->mcp_count; i++) { + free(d->mcp_names[i]); + free(d->mcp_configs[i]); + } + free(d->mcp_names); + free(d->mcp_configs); + for (size_t i = 0; i < d->skill_count; i++) { + free(d->skills[i].id); + free(d->skills[i].name); + free(d->skills[i].description); + free(d->skills[i].body); + } + free(d->skills); + for (size_t i = 0; i < d->sub_agent_count; i++) { + free(d->sub_agents[i].id); + free(d->sub_agents[i].name); + free(d->sub_agents[i].description); + free(d->sub_agents[i].role); + free(d->sub_agents[i].connection_type); + free(d->sub_agents[i].body); + free(d->sub_agents[i].config); + } + free(d->sub_agents); + for (size_t i = 0; i < d->task_count; i++) { + free(d->tasks[i].id); + free(d->tasks[i].name); + free(d->tasks[i].profile_id); + free(d->tasks[i].body); + } + free(d->tasks); + for (size_t i = 0; i < d->memory_count; i++) { + free(d->memories[i].id); + free(d->memories[i].title); + free(d->memories[i].importance); + for (size_t k = 0; k < d->memories[i].tag_count; k++) + free(d->memories[i].tags[k]); + free(d->memories[i].tags); + free(d->memories[i].summary); + free(d->memories[i].body); + } + free(d->memories); +} + +static inline void dotagents_load_from(dotagents *out, const char *workspace, + const char *global) { + memset(out, 0, sizeof *out); + fm_sl_init(&out->warnings); + if (global && da_is_dir(global) && da_has_protocol_content(global)) { + da_ingest(global, LAYER_GLOBAL, out); + out->found_layers = (layer *)fm_xrealloc( + out->found_layers, (out->found_count + 1) * sizeof(layer)); + out->found_paths = (char **)fm_xrealloc( + out->found_paths, (out->found_count + 1) * sizeof(char *)); + out->found_layers[out->found_count] = LAYER_GLOBAL; + out->found_paths[out->found_count] = fm_strdup(global); + out->found_count++; + } + if (da_is_dir(workspace) && da_has_protocol_content(workspace)) { + da_ingest(workspace, LAYER_WORKSPACE, out); + out->found_layers = (layer *)fm_xrealloc( + out->found_layers, (out->found_count + 1) * sizeof(layer)); + out->found_paths = (char **)fm_xrealloc( + out->found_paths, (out->found_count + 1) * sizeof(char *)); + out->found_layers[out->found_count] = LAYER_WORKSPACE; + out->found_paths[out->found_count] = fm_strdup(workspace); + out->found_count++; + } + /* materialize the transient merge trees as stable JSON text */ + if (out->settings) { + out->settings_json = fm_json_serialize(out->settings); + fm_json_free(out->settings); + out->settings = NULL; + } + if (out->layouts) { + out->layouts_json = fm_json_serialize(out->layouts); + fm_json_free(out->layouts); + out->layouts = NULL; + } +} + +static inline void dotagents_load(dotagents *out) { + memset(out, 0, sizeof *out); + fm_sl_init(&out->warnings); + const char *home = getenv("HOME"); + char *global = NULL; + if (home) + global = fm_strfmt("%s/.agents", home); + dotagents_load_from(out, ".agents", global); + free(global); +} + +/* Sections appended to the preamble: guidelines + skill index + memories. */ +static inline char *dotagents_project_sections(const dotagents *d, + unsigned long cap) { + if (cap < 1) + cap = 1; + fm_buf out; + fm_buf_init(&out); + if (d->agents_md && d->agents_md[0] && + d->agents_md[strcspn(d->agents_md, " \t\n\r")] != 0) { + fm_buf_push(&out, '\n'); + /* trim end */ + size_t l = strlen(d->agents_md); + while (l && (d->agents_md[l - 1] == '\n' || d->agents_md[l - 1] == '\r' || + d->agents_md[l - 1] == ' ')) + l--; + fm_buf_putn(&out, d->agents_md, l); + fm_buf_push(&out, '\n'); + } + /* enabled skills */ + size_t enabled_count = 0; + for (size_t i = 0; i < d->skill_count; i++) + if (d->skills[i].enabled) + enabled_count++; + if (enabled_count > 0) { + fm_buf_put(&out, "\n# Skills\n\n"); + fm_buf_put( + &out, + "Skills available for this project. Load a skill's full " + "instructions with the `use_skill` tool (pass its id):\n"); + size_t listed = 0; + for (size_t i = 0; i < d->skill_count && listed < cap; i++) { + if (!d->skills[i].enabled) + continue; + if (d->skills[i].description[0]) + fm_buf_fmt(&out, "- %s: %s\n", d->skills[i].id, + d->skills[i].description); + else + fm_buf_fmt(&out, "- %s\n", d->skills[i].id); + listed++; + } + if (enabled_count > cap) + fm_buf_fmt(&out, "…and %lu more\n", enabled_count - cap); + } + if (d->memory_count > 0) { + fm_buf_fmt( + &out, + "\n# Memories\n\n%lu persistent memory entries are available " + "across sessions. Retrieve them with the `search_memories` tool " + "(keyword or tag query).\n", + d->memory_count); + } + return fm_buf_steal(&out); +} + +static inline char *dotagents_compose_preamble(const dotagents *d, + const char *base, + unsigned long cap) { + char *sections = dotagents_project_sections(d, cap); + fm_buf b; + fm_buf_init(&b); + fm_buf_put(&b, base); + fm_buf_put(&b, sections); + free(sections); + return fm_buf_steal(&b); +} + +static inline const sub_agent *dotagents_enabled_sub_agent( + const dotagents *d, const char *id) { + for (size_t i = 0; i < d->sub_agent_count; i++) + if (strcmp(d->sub_agents[i].id, id) == 0 && d->sub_agents[i].enabled) + return &d->sub_agents[i]; + return NULL; +} + +static inline const skill *dotagents_find_skill(const dotagents *d, + const char *id) { + for (size_t i = 0; i < d->skill_count; i++) + if (strcmp(d->skills[i].id, id) == 0) + return &d->skills[i]; + return NULL; +} + +static inline const char *dotagents_mcp_config(const dotagents *d, + const char *name) { + for (size_t i = 0; i < d->mcp_count; i++) + if (strcmp(d->mcp_names[i], name) == 0) + return d->mcp_configs[i]; + return NULL; +} + +static inline const model_preset *models_file_get(const models_file *m, + const char *name) { + for (size_t i = 0; i < m->preset_count; i++) + if (strcmp(m->presets[i].name, name) == 0) + return &m->presets[i]; + return NULL; +} + +static inline void models_file_free(models_file *m) { + free(m->default_name); + for (size_t i = 0; i < m->preset_count; i++) { + free(m->presets[i].name); + free(m->presets[i].provider); + free(m->presets[i].model); + free(m->presets[i].base_url); + free(m->presets[i].api_key); + } + free(m->presets); + m->presets = NULL; + m->preset_count = 0; +} + +static inline int memory_importance_rank(const memory *m) { + fm_buf l; + fm_buf_init(&l); + for (const char *p = m->importance; *p; p++) + fm_buf_push(&l, (char)tolower((unsigned char)*p)); + l.data[l.len] = '\0'; + int r = 2; + if (strcmp(l.data, "high") == 0) + r = 0; + else if (strcmp(l.data, "medium") == 0 || strcmp(l.data, "normal") == 0) + r = 1; + fm_buf_free(&l); + return r; +} + +#ifdef __cplusplus +} +#endif + +#endif /* DOTAGENTS_H */ |