# Prompt Template System — Coding Plan

Design doc: `plans/prompt_templates.md`

## Overview

Replace the hardcoded context assembly in `src/agent.c` with a template-driven system. The template lives inside the soul event content (kind 31120), delimited by `---template---`. If no template marker is found, fall back to the current hardcoded assembly for backward compatibility.

---

## Phase 1: New Files — Template Parser & Builder

### Step 1.1: Create `src/prompt_template.h`

Header with data structures and public API.

```c
#ifndef DIDACTYL_PROMPT_TEMPLATE_H
#define DIDACTYL_PROMPT_TEMPLATE_H

#include "cjson/cJSON.h"
#include "config.h"

#define PROMPT_TEMPLATE_MAX_SECTIONS 32
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
#define PROMPT_TEMPLATE_MAX_ROLE_LEN 16
#define PROMPT_TEMPLATE_MARKER "---template---"

typedef struct {
    char name[PROMPT_TEMPLATE_MAX_NAME_LEN];
    char role[PROMPT_TEMPLATE_MAX_ROLE_LEN];   // system, user, assistant, expand
    char* content_template;                     // content with {{var}} placeholders, or NULL
    int limit;                                  // for expand sections, 0 = default
} prompt_template_section_t;

typedef struct {
    char* personality;                          // everything above ---template---
    prompt_template_section_t sections[PROMPT_TEMPLATE_MAX_SECTIONS];
    int section_count;
} prompt_template_t;

// Variable resolver callback: given a variable name, return a malloc'd string or NULL.
// The caller frees the returned string.
typedef char* (*prompt_var_resolver_fn)(const char* var_name, void* user_data);

// Parse soul content into a template. Returns 0 on success, -1 if no template found.
// On success, caller must call prompt_template_free() when done.
// On -1 (no template), out_template is zeroed — caller should use hardcoded fallback.
int prompt_template_parse(const char* soul_content, prompt_template_t* out_template);

// Build a cJSON messages array from a parsed template.
// resolver_fn is called for each {{variable}} encountered.
// dm_history_messages is a cJSON array of user/assistant messages for "expand" sections.
// Returns a new cJSON array (caller owns it), or NULL on error.
cJSON* prompt_template_build_messages(
    const prompt_template_t* tmpl,
    prompt_var_resolver_fn resolver_fn,
    void* resolver_user_data,
    cJSON* dm_history_messages
);

// Free internals of a parsed template (does not free the struct itself).
void prompt_template_free(prompt_template_t* tmpl);

// Get the section name for a message index (for logging/API).
// Returns the section name string or NULL if idx is out of range.
const char* prompt_template_section_name_at(const prompt_template_t* tmpl, int section_idx);

#endif
```

### Step 1.2: Create `src/prompt_template.c`

Implementation file with three main components:

#### 1.2a: Template Parser — `prompt_template_parse()`

Logic:
1. Search `soul_content` for the string `"\n---template---\n"` (with newlines on both sides, or at start/end of string).
2. If not found, return -1 (no template).
3. Split: everything before the marker → `tmpl->personality` (strdup'd).
4. Everything after the marker → parse as template sections.
5. Template section format is line-oriented YAML-like:

```
- section: admin_identity
  role: system
  limit: 0
  content: |
    This is your administrator! Admin pubkey: {{admin_pubkey}}
```

Parsing rules:
- Lines starting with `- section:` begin a new section.
- `role:` sets the role (default: `system`).
- `limit:` sets the limit integer (default: 0).
- `content: |` starts a multi-line content block. All subsequent lines indented by 4+ spaces (or until the next `- section:` line) are the content template.
- `{{variable_name}}` placeholders in content are left as-is during parsing; they are resolved at build time.

Edge cases:
- Trim leading/trailing whitespace from section names and roles.
- If `content:` is a single line (not `|`), treat the rest of the line as the content.
- Cap at `PROMPT_TEMPLATE_MAX_SECTIONS`.

#### 1.2b: Variable Resolver — `prompt_template_build_messages()`

Logic:
1. Create a new cJSON array.
2. First, append the personality as a system message (role=system, content=personality).
3. For each section in order:
   - If `role` is `"expand"`: insert the `dm_history_messages` array items here, limited by `section.limit` (take last N if limit > 0).
   - Otherwise: resolve `{{var}}` placeholders in `content_template` by calling `resolver_fn(var_name, user_data)`. Build the resolved string. Append as a message with the configured role.
4. Return the array.

Variable resolution:
- Scan content_template for `{{` ... `}}` pairs.
- Extract the variable name (trimmed).
- Call `resolver_fn(name, user_data)`.
- If resolver returns NULL, substitute empty string.
- If resolver returns a string, substitute it and free the returned string.
- Build the final resolved content by concatenating literal segments and resolved values.

#### 1.2c: Cleanup — `prompt_template_free()`

- Free `tmpl->personality`.
- For each section, free `content_template`.
- Zero the struct.

---

## Phase 2: Variable Resolver in `src/agent.c`

### Step 2.1: Create a resolver function

Add a static function in `src/agent.c`:

```c
static char* agent_resolve_template_var(const char* var_name, void* user_data);
```

This function maps variable names to data sources:

| Variable Name | Source | Implementation |
|---|---|---|
| `admin_pubkey` | `g_cfg->admin.pubkey` | `strdup(g_cfg->admin.pubkey)` |
| `admin_kind0_json` | `nostr_handler_get_admin_kind0_context()` | Already returns malloc'd string |
| `admin_kind10002_json` | `nostr_handler_get_admin_kind10002_context()` | Already returns malloc'd string |
| `startup_events_json` | Serialize startup events | Reuse logic from current `append_startup_events_context()` |
| `adopted_skills_content` | Build skills string | Reuse logic from current `append_adopted_skills_context()` |
| `admin_notes_content` | `nostr_handler_get_admin_kind1_notes_context()` | Already returns malloc'd string |
| `agent_pubkey` | `g_cfg->keys.public_key_hex` | `strdup(g_cfg->keys.public_key_hex)` |

The `user_data` parameter is unused (NULL) since the resolver accesses globals.

### Step 2.2: Extract helper functions from existing code

Refactor the following existing static functions to return malloc'd strings instead of appending directly to a cJSON array:

- Extract startup events serialization from `append_startup_events_context()` (lines 383-434) into a new `static char* build_startup_events_string(void)`.
- Extract adopted skills content from `append_adopted_skills_context()` (lines ~744-940) into a new `static char* build_adopted_skills_string(void)`.

These helpers are called by the resolver function.

---

## Phase 3: Wire Template into Agent

### Step 3.1: Parse template at init time

In `agent_init()` (or when `g_system_context` is set), after the soul content is available:

```c
static prompt_template_t g_prompt_template;
static int g_has_template = 0;
```

After `g_system_context` is assigned, call:

```c
g_has_template = (prompt_template_parse(g_system_context, &g_prompt_template) == 0);
```

If `g_has_template` is true, `g_prompt_template.personality` replaces `g_system_context` for the system prompt message.

### Step 3.2: Modify `agent_build_admin_messages_json()`

Current location: `src/agent.c:1092`

Current signature (unchanged):
```c
int agent_build_admin_messages_json(const char* current_user_message, char** out_messages_json);
```

New logic:

```c
if (g_has_template) {
    // Build DM history as a cJSON array
    cJSON* dm_history = build_dm_history_array(current_user_message);
    
    // Build messages from template
    cJSON* messages = prompt_template_build_messages(
        &g_prompt_template,
        agent_resolve_template_var,
        NULL,
        dm_history
    );
    
    cJSON_Delete(dm_history);
    
    if (!messages) {
        return -1;
    }
    
    char* json = cJSON_PrintUnformatted(messages);
    cJSON_Delete(messages);
    *out_messages_json = json;
    return json ? 0 : -1;
} else {
    // Existing hardcoded assembly (current code, unchanged)
    ...
}
```

### Step 3.3: Extract DM history builder

Extract the DM history logic from `append_recent_admin_dm_history()` into a function that returns a cJSON array of user/assistant messages:

```c
static cJSON* build_dm_history_array(const char* current_user_message);
```

This is used by the template builder for `role: expand` sections.

---

## Phase 4: Context Log Formatting

### Step 4.1: Update `format_context_payload_for_log()`

Current location: `src/agent.c:245`

When `g_has_template` is true, use section names from the template instead of `detect_context_section()`:

- Message 0 is always `system_prompt` (the personality).
- Messages 1..N map to template sections by index.
- For `expand` sections, multiple messages share the same section name.

Change the log header from:
```
Message 01 | role=system | section=system_prompt
```
To:
```
Section: system_prompt | role=system
```

### Step 4.2: Update `classify_part_name()` in `src/http_api.c`

Current location: `src/http_api.c:113`

Add a new exported function from `src/agent.h`:
```c
const char* agent_get_section_name_for_message(int message_index);
```

This returns the template section name if a template is active, or falls back to the existing content-prefix detection.

In `classify_part_name()`, call this function first. If it returns non-NULL, use it. Otherwise fall back to the existing `strncmp` chain.

---

## Phase 5: Build System Updates

### Step 5.1: Update `Makefile`

Add `$(SRC_DIR)/prompt_template.c` to the `SRCS` list (after `trigger_manager.c`, before `http_api.c`).

### Step 5.2: Update `Dockerfile.alpine-musl`

Add `src/prompt_template.c` to the gcc command line (after `src/trigger_manager.c`, before `src/http_api.c`).

---

## Phase 6: Default Template in Soul

### Step 6.1: Update `config.json.example`

The startup events should include a soul event (kind 31120) with a `---template---` section that matches the current hardcoded behavior:

```markdown
# Didactyl Agent

You are Didactyl, a sovereign AI agent living on Nostr.
...existing soul content...

---template---

- section: admin_identity
  role: system
  content: |
    This is your administrator! Admin pubkey (hex): {{admin_pubkey}}

- section: admin_profile
  role: system
  content: |
    Administrator profile (JSON): {{admin_kind0_json}}

- section: admin_relay_list
  role: system
  content: |
    Administrator relay list (JSON): {{admin_kind10002_json}}

- section: startup_events
  role: system
  content: |
    Startup events memory (kinds/content/tags): {{startup_events_json}}

- section: adopted_skills
  role: system
  content: |
    {{adopted_skills_content}}

- section: dm_history
  role: expand
  limit: 12

- section: admin_notes
  role: system
  limit: 10
  content: |
    Administrator recent public notes: {{admin_notes_content}}
```

---

## Phase 7: Testing

### Step 7.1: Backward compatibility test

1. Build with `make`.
2. Run with existing config (no `---template---` in soul).
3. Send a DM and verify `context.log` output matches pre-change format.
4. Verify `/api/context/current` returns expected structure.

### Step 7.2: Template test

1. Edit the soul event content to include `---template---` section.
2. Restart agent.
3. Send a DM and verify `context.log` shows section-named headers.
4. Verify `/api/context/current` returns section names from template.
5. Verify the LLM receives the correct messages in the correct order.

### Step 7.3: Section reordering test

1. Move `admin_notes` section above `adopted_skills` in the template.
2. Restart and verify the order changes in `context.log`.

### Step 7.4: Limit test

1. Set `dm_history` limit to 4 (instead of 12).
2. Verify only 4 DM history turns appear in context.

---

## File Change Summary

| File | Action | Description |
|---|---|---|
| `src/prompt_template.h` | **NEW** | Data structures and API |
| `src/prompt_template.c` | **NEW** | Parser, variable resolver, context builder |
| `src/agent.c` | **MODIFY** | Add template globals, resolver function, wire into `agent_build_admin_messages_json()`, update log formatter |
| `src/agent.h` | **MODIFY** | Add `agent_get_section_name_for_message()` export |
| `src/http_api.c` | **MODIFY** | Update `classify_part_name()` to use template section names |
| `Makefile` | **MODIFY** | Add `prompt_template.c` to SRCS |
| `Dockerfile.alpine-musl` | **MODIFY** | Add `prompt_template.c` to gcc command |
| `config.json.example` | **MODIFY** | Update soul event to include template section |

---

## Implementation Order

1. `src/prompt_template.h` — data structures and API declarations
2. `src/prompt_template.c` — parser, builder, free
3. `Makefile` + `Dockerfile.alpine-musl` — add new source file
4. Build and verify compilation
5. `src/agent.c` — extract helper functions (`build_startup_events_string`, `build_adopted_skills_string`, `build_dm_history_array`)
6. `src/agent.c` — add resolver function and template globals
7. `src/agent.c` — wire template into `agent_build_admin_messages_json()`
8. `src/agent.c` — update `format_context_payload_for_log()`
9. `src/agent.h` + `src/http_api.c` — section name API for classify_part_name
10. Build and test backward compatibility (no template in soul)
11. `config.json.example` — add template to soul event
12. Test with template soul
13. Test section reordering and limit changes
