/* 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//skill.md skill definition * agents//agent.md sub-agent profile (+ optional config.json) * tasks//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 #include #include #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 */