summaryrefslogtreecommitdiffstats
path: root/include/dotagents.h
diff options
context:
space:
mode:
Diffstat (limited to 'include/dotagents.h')
-rw-r--r--include/dotagents.h1282
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 */