# Markdown Context Window — Implementation Plan The context window that the agent sees should be a nicely formatted markdown document. Skills are the building blocks, and the runtime assembles them into a coherent document with proper heading hierarchy, role markers for the LLM API, and clean formatting throughout. See also: [example_context_v2.md](example_context_v2.md) for visual examples. --- ## Design Decisions ### 1. One generic JSON-to-markdown converter A single function converts any JSON value into readable markdown. No per-tool formatters. Tools continue to return JSON; the template engine calls the converter when injecting tool output into the markdown document. ```c char* json_to_markdown(const char* json_string); ``` Conversion rules: - **String**: output as-is (inline) - **Number**: output as-is (inline) - **Boolean**: `true` / `false` - **Null**: *(empty)* - **Object**: bullet list with bold keys - `- **key**: value` - Nested objects indent one level - **Array of primitives**: comma-separated inline or bullet list - **Array of objects**: bullet list, each item shows its fields #### Examples **Simple object**: ```json {"name":"Simon","npub":"npub1kfc89...","nip05":"simon@nostr"} ``` ```markdown - **name**: Simon - **npub**: npub1kfc89... - **nip05**: simon@nostr ``` **Nested object**: ```json {"agent":{"name":"Simon","npub":"npub1kfc89..."},"version":"v0.2.12"} ``` ```markdown - **agent**: - **name**: Simon - **npub**: npub1kfc89... - **version**: v0.2.12 ``` **Array of objects**: ```json [{"content":"Deployed v0.2.12","created_at":1774263529},{"content":"Working on skills","created_at":1774263600}] ``` ```markdown - **content**: Deployed v0.2.12 - **created_at**: 1774263529 - **content**: Working on skills - **created_at**: 1774263600 ``` **Plain string** (already markdown): ```json "You are Simon, a sovereign AI agent." ``` ```markdown You are Simon, a sovereign AI agent. ``` #### Edge case: tool content that is already markdown Some tools may return markdown text in their `content` field (e.g., a skill that already formats its own output). The converter detects this: if the input is a plain JSON string (not an object/array), it passes through unchanged. This means skill authors can return pre-formatted markdown from tools if they want precise control. #### Edge case: very large JSON For large arrays (e.g., 50 Nostr events from a query), the converter should truncate with a note: `*(... and 42 more items)*`. A reasonable default limit is 8 items for arrays. ### 2. Runtime owns h1, heading bump for skills - The runtime emits `# Agent Name` as the document title - All headings in skill content get bumped one level: `#` → `##`, `##` → `###` - Template variables are resolved BEFORE the heading bump - Layer 2 skills (embedded via `{{skill_d_tag}}`) get the same bump as their containing layer 1 skill (because they are resolved inline first) ### 3. Role markers parsed Skill content can contain `system:` and `user:` markers at the start of a line. The runtime splits on these markers to produce the API messages array. - `system:` — content goes into the system message - `user:` — content goes into the user message - `assistant:` — content goes into an assistant message (rare, for few-shot) - No marker — defaults to `system:` - Multiple skills can contribute `system:` sections (concatenated in order) - Multiple `user:` sections get concatenated ### 4. Skills separated by horizontal rules Layer 1 skills are separated by `---` in the assembled document. This gives visual structure and helps the LLM distinguish between different instruction blocks. ### 5. Context log shows the markdown document The `append_context_log()` function logs the assembled markdown document so you can inspect exactly what the agent sees, formatted as readable markdown rather than raw JSON message arrays. --- ## Implementation Steps ### Step 1: Create json_to_markdown converter **File**: `src/json_to_markdown.c` / `src/json_to_markdown.h` New module with a single public function: ```c // Convert a JSON string to markdown text. // Returns a malloc'd string. Caller frees. // If input is a plain string (not object/array), passes through unchanged. // Objects become bullet lists with bold keys. // Arrays become bullet lists of items. // Nested structures indent appropriately. // Large arrays truncate at max_items with a note. char* json_to_markdown(const char* json_string, int max_array_items); ``` Implementation: - Parse JSON with cJSON - Recursive walk of the JSON tree - Build output string with the append_text pattern used elsewhere - Handle: string, number, bool, null, object, array - Indent nested structures with 2-space indentation - Truncate arrays beyond max_array_items ### Step 2: Create context_roles splitter **File**: `src/context_roles.c` / `src/context_roles.h` New module that splits a markdown document on role markers: ```c typedef struct { char* system_content; // Everything under system: markers char* user_content; // Everything under user: markers char* assistant_content; // Everything under assistant: markers (rare) } context_roles_t; // Split markdown text on role markers (system:, user:, assistant:) // at the start of a line. Unmarked content defaults to system. // Returns 0 on success, -1 on error. int context_roles_split(const char* markdown, context_roles_t* out); void context_roles_free(context_roles_t* roles); ``` ### Step 3: Create heading_bump utility **File**: `src/context_format.c` / `src/context_format.h` Utility functions for markdown formatting in context assembly: ```c // Bump all markdown headings in text by one level (# -> ##, ## -> ###, etc.) // Returns a malloc'd string. Caller frees. char* context_bump_headings(const char* text); // Build the runtime document title from agent identity. // Returns "# Agent Name\n\n" as a malloc'd string. char* context_build_title(tools_context_t* ctx); ``` ### Step 4: Modify prompt_template_resolve_inline_variables **File**: `src/prompt_template.c` After extracting the `content` field from a tool result (line ~179), pass it through `json_to_markdown()` before inserting into the template: Current code (simplified): ```c cJSON* content = cJSON_GetObjectItemCaseSensitive(root, "content"); if (content && cJSON_IsString(content)) { replacement_owned = strdup(content->valuestring); } ``` New code: ```c cJSON* content = cJSON_GetObjectItemCaseSensitive(root, "content"); if (content && cJSON_IsString(content)) { // Try to convert JSON content to markdown char* md = json_to_markdown(content->valuestring, 8); replacement_owned = md ? md : strdup(content->valuestring); } ``` If the content is already a plain string (not JSON), `json_to_markdown` passes it through unchanged. If it is a JSON object/array, it gets converted to a markdown bullet list. ### Step 5: Modify build_context_from_triggers **File**: `src/agent.c` Update the context assembly function to: 1. Resolve template variables (already done) 2. Bump headings in each skill's content 3. Build the document title 4. Concatenate with `---` separators 5. Return the complete markdown document (not yet split by roles) Current flow: ``` for each matching skill: expand = resolve_skill_references(skill.content) append expand to output with --- separator ``` New flow: ``` title = context_build_title(tools_ctx) output = title for each matching skill: expanded = resolve_skill_references(skill.content) resolved = prompt_template_resolve_inline_variables(expanded) bumped = context_bump_headings(resolved) append bumped to output with --- separator ``` ### Step 6: Modify agent_on_message and agent_on_trigger **File**: `src/agent.c` Update the callers of `build_context_from_triggers` to use role splitting: Current flow: ``` dm_context = build_context_from_triggers(DM, ...) // dm_context goes entirely into system message // user message is the raw DM text llm_chat(dm_context, message) ``` New flow: ``` markdown_doc = build_context_from_triggers(DM, ...) context_roles_split(markdown_doc, &roles) // roles.system_content -> system message // roles.user_content -> user message (or fall back to raw DM text) llm_chat(roles.system_content, roles.user_content ?: message) ``` ### Step 7: Update append_context_log **File**: `src/agent.c` Log the assembled markdown document instead of (or in addition to) the raw JSON messages array. The log entry should show the readable markdown so you can open `context.log.md` and see exactly what the agent sees. Current format: ``` [{"role":"system","content":"...escaped..."},{"role":"user","content":"..."}] ``` New format: ```markdown system: # Simon — Didactyl Agent You are Simon... ## Rules - ... --- ## Chat Respond helpfully... user: Hey Simon, who mentioned me today? ``` ### Step 8: Update DM history formatting **File**: `src/nostr_handler.c` (or wherever DM history is built) The conversation history that gets injected into the context should be formatted as a markdown list instead of inline JSON: Current: ``` [{"role":"assistant","content":"Started up..."},{"role":"user","content":"Hello"}] ``` New: ```markdown ## Conversation History - **You**: Started up and is online at 2026-03-23 09:22:48. - **Admin**: Hello - **You**: Hello! How can I help? ``` This could be handled by the generic `json_to_markdown` converter if the history is passed as JSON, or by a dedicated history formatter if we want the cleaner `**You**` / `**Admin**` labels instead of `**role**`. Note: This is the one place where a small specialized formatter adds real value — the generic converter would produce `**role**: assistant` / `**content**: Started up...` which is less readable than `**You**: Started up...`. A simple function that maps role names to display labels handles this. ### Step 9: Update documentation **Files**: `docs/SKILLS.md`, `docs/CONTEXT.md` Update the documentation to reflect: - Skill content is markdown with role markers - The runtime assembles a coherent markdown document - Heading bump behavior - The generic JSON-to-markdown conversion for template variables - Updated examples showing the new format --- ## Files Changed | File | Change | |------|--------| | `src/json_to_markdown.c` | **NEW** — Generic JSON to markdown converter | | `src/json_to_markdown.h` | **NEW** — Header for converter | | `src/context_roles.c` | **NEW** — Role marker splitter | | `src/context_roles.h` | **NEW** — Header for role splitter | | `src/context_format.c` | **NEW** — Heading bump and title builder | | `src/context_format.h` | **NEW** — Header for context formatting | | `src/prompt_template.c` | **MODIFY** — Use json_to_markdown for template variable output | | `src/agent.c` | **MODIFY** — Use heading bump, role splitting, updated context log | | `src/nostr_handler.c` | **MODIFY** — Format DM history as markdown list | | `Makefile` | **MODIFY** — Add new source files to build | | `docs/SKILLS.md` | **MODIFY** — Update skill content format documentation | | `docs/CONTEXT.md` | **MODIFY** — Update context assembly documentation | --- ## What Does NOT Change - **Tool implementations** — All tools continue to return JSON. No changes to any tool_*.c files. - **LLM API interface** — Still sends OpenAI-compatible messages array. The markdown is the content within the messages, not the transport format. - **Runtime tool results** — Tool call/result messages during multi-turn loops stay as JSON. Only template variable injection gets markdown conversion. - **Skill event format on Nostr** — Skills are still stored as kind 31123/31124 events. The content field is still markdown with role markers. No protocol change. - **Adoption list** — No changes to kind 10123 or skill ordering. --- ## Risk Assessment ### Low risk - `json_to_markdown` is a pure function with no side effects - `context_roles_split` is a simple string splitter - `context_bump_headings` is a line-by-line string operation - All new code is additive — existing behavior preserved for skills without role markers (defaults to system) ### Medium risk - Changing `prompt_template_resolve_inline_variables` to use json_to_markdown could change the content of existing template variables. Need to verify that existing skills still work correctly with markdown output instead of raw JSON. Mitigation: json_to_markdown passes through plain strings unchanged, so skills that already return non-JSON content are unaffected. ### Testing approach - Unit test json_to_markdown with various JSON inputs - Unit test context_roles_split with various role marker patterns - Unit test context_bump_headings with various heading levels - Integration test: run existing skills and compare context.log.md output before and after to verify no regressions