Files
didactyl/plans/markdown_context_window.md

13 KiB

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 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.

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:

{"name":"Simon","npub":"npub1kfc89...","nip05":"simon@nostr"}
- **name**: Simon
- **npub**: npub1kfc89...
- **nip05**: simon@nostr

Nested object:

{"agent":{"name":"Simon","npub":"npub1kfc89..."},"version":"v0.2.12"}
- **agent**:
  - **name**: Simon
  - **npub**: npub1kfc89...
- **version**: v0.2.12

Array of objects:

[{"content":"Deployed v0.2.12","created_at":1774263529},{"content":"Working on skills","created_at":1774263600}]
- **content**: Deployed v0.2.12
  - **created_at**: 1774263529
- **content**: Working on skills
  - **created_at**: 1774263600

Plain string (already markdown):

"You are Simon, a sovereign AI agent."
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:

// 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:

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:

// 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):

cJSON* content = cJSON_GetObjectItemCaseSensitive(root, "content");
if (content && cJSON_IsString(content)) {
    replacement_owned = strdup(content->valuestring);
}

New code:

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:

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:

## 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