Files
didactyl/plans/markdown_context_window.md
T

402 lines
13 KiB
Markdown

# 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