# Implementation Plan: Trigger-Scoped Skill Architecture

Target spec: [docs/SKILLS.md](../docs/SKILLS.md) · [docs/CONTEXT.md](../docs/CONTEXT.md) · [docs/TOOLS.md](../docs/TOOLS.md)

## Current State

- `g_system_context` is a monolithic global string (from the default skill) prepended to every LLM call
- `agent_on_trigger()` prepends `g_system_context` to every triggered skill execution
- `agent_on_message()` falls through to `g_system_context` chat if no DM trigger fires
- `trigger_manager_fire_dm()` fires all matching DM triggers independently (separate LLM calls each)
- `trigger_manager_load_from_skills()` loads triggers from adopted skills only
- Template variables resolve to tools only — no skill-to-skill `{{d_tag}}` resolution
- `apply_trigger_runtime_to_llm_config()` parses `provider/model` but discards the provider
- Skill `content` is JSON with `description` and `template` fields (spec says content IS the template)
- `["tools", "true/false/csv"]` tag controls tool access (spec says use `requires_tool` instead)
- `["enabled", "true/false"]` tag on triggers (spec says adoption IS enablement)

## Phase 0: Provider Override Fix

**File:** `src/trigger_manager.c`
**Function:** `apply_trigger_runtime_to_llm_config()` (line 592)

Currently at line 602-611, when a slash is found in the llm_spec, only the model (after slash) is extracted. The provider (before slash) is discarded.

**Change:** When slash is found, also copy the provider prefix into `cfg->provider`.

```c
// Current: only extracts model
if (slash) {
    const char* model = slash + 1;
    // ... sets cfg->model only
}

// New: extract both provider and model
if (slash) {
    size_t provider_len = (size_t)(slash - spec);
    if (provider_len > 0 && provider_len < sizeof(cfg->provider)) {
        snprintf(cfg->provider, sizeof(cfg->provider), "%.*s", (int)provider_len, spec);
    }
    const char* model = slash + 1;
    // ... sets cfg->model as before
}
```

## Phase 1: Two-Layer Context Assembly

### 1a. Skill-to-skill template resolution

**File:** `src/prompt_template.c`
**Function:** `map_variable_tool_name()` (line 100)

Currently returns a tool name for known variables, or NULL for unknown ones (which resolve to empty).

**Change:** Add a fallback path. If the variable name doesn't match a known tool, look it up as a skill d-tag in the adoption list cache. If found, return the skill's content.

This requires access to the skill cache from the template resolver. Options:
- Pass a skill lookup callback into the template builder
- Add a `skill_content_lookup` function pointer to `tools_context_t`

**File:** `src/prompt_template.c`
**Function:** `prompt_template_build_messages()` (line 37)

When resolving a `{{variable}}` that doesn't match a tool, call the skill lookup function to check adopted skills by d-tag.

### 1b. Trigger-matched context assembly

**File:** `src/agent.c`
**New function:** `build_context_from_triggers()`

```c
char* build_context_from_triggers(
    trigger_type_t trigger_type,
    const char* trigger_filter,
    cJSON* trigger_event,
    const char* relay_url);
```

Implementation:
1. Load the adoption list (kind `10123`) — already cached at startup
2. For each adopted skill, check if it has a trigger matching `trigger_type` and `trigger_filter`
3. If match: resolve the skill's template (expanding `{{...}}` references via Phase 1a)
4. Concatenate all matched skill templates in adoption-list order
5. Append the triggering event payload
6. For DM triggers: always append raw message content at the end
7. Return the assembled system prompt

### 1c. Replace g_system_context in trigger execution

**File:** `src/agent.c`
**Function:** `agent_on_trigger()` (line 1817)

**Current (line 1852-1858):**
```c
snprintf(system_prompt, system_len, "%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
         g_system_context,
         trigger_prefix, skill_d_tag, relay, skill_content);
```

**Replace with:** Call `build_context_from_triggers()` instead of prepending `g_system_context`.

### 1d. Skill content format change

**File:** `src/nostr_handler.c` and `src/config.c`

Currently skill content is parsed as JSON to extract `template` field. The spec says `content` IS the template (plain string).

**Change:** When loading a skill's content, check if it's a JSON object with a `template` field (backward compat) or a plain string (new format). Use the template/string directly.

## Phase 2: DM Composition with Default Handler

**File:** `src/agent.c`
**Function:** `agent_on_message()` (line 2085)

Currently at line 2120-2128, calls `trigger_manager_fire_dm()` which fires each matching DM trigger independently. If none fire, falls through to `g_system_context` chat.

**Change:**
1. Use `build_context_from_triggers()` with `TRIGGER_TYPE_DM` and the sender tier
2. This automatically finds all DM-triggered skills in adoption-list order and composes them
3. Always append the raw admin message at the end (default DM handler)
4. If no DM-triggered skill exists, use a minimal built-in default: "You are an AI agent. Respond to the message."
5. Make one LLM call with the composed context

**File:** `src/trigger_manager.c`
**Function:** `trigger_manager_fire_dm()` (line 1545)

Currently fires all matching DM triggers independently (each gets its own LLM call).

**Change:** Instead of firing independently, return the list of matching DM skill d-tags (in adoption-list order). Let the caller (`agent_on_message`) compose them into a single context via `build_context_from_triggers()`.

New function signature:
```c
int trigger_manager_get_dm_skills(trigger_manager_t* mgr,
                                   const char* sender_pubkey_hex,
                                   didactyl_sender_tier_t tier,
                                   char** out_d_tags,
                                   int max_d_tags);
```

## Phase 3: Trigger Discovery Independent of Adoption

**File:** `src/trigger_manager.c`
**Function:** `trigger_manager_load_from_skills()` (line ~varies)

Currently loads triggers only from adopted skills in the `10123` list.

**Change:** Scan all skill events published by the agent (query own pubkey for kinds 31123/31124), not just adopted ones. Arm any skill with trigger tags. The adoption list controls `{{...}}` resolution and ordering, not trigger arming.

Non-adopted skills with triggers fire in isolation (no layer 2 skill references available).

## Phase 4: Context Compaction

**File:** `src/agent.c`
**Functions:** Tool loops in `agent_on_trigger()` (line 1905) and `agent_on_message()` (line 2212)

**New function:**
```c
static int estimate_context_tokens(cJSON* messages);
```

Approximate: sum character lengths of all message content fields, divide by 4.

**Change in tool loops:** Before each LLM call, check if `estimate_context_tokens(messages)` exceeds 70% of the model's context window. If so:

1. Build a summarization request: "Summarize your progress so far, including key findings and remaining work."
2. Send to LLM with `tool_choice: "none"` (text only)
3. Replace all tool call/result messages with a single system message containing the summary
4. Continue the tool loop with the compacted context

## Phase 5: LLM Fallback Chain

**File:** `src/trigger_manager.c`
**Function:** `apply_trigger_runtime_to_llm_config()` (line 592)

Currently parses the comma in `llm_spec` but only uses the first entry.

**Change:** Walk the comma-separated entries. For each:
1. Parse `provider/model` or bare `model` or capability keyword
2. Check if the model is available (query provider, or check a local model list)
3. If available, use it and stop
4. If not, try the next entry
5. Capability keywords (`cheap`, `fast`, `best`, `default`) resolve to runtime-configured models

## Phase 6: Remove Obsolete Code

**File:** `src/agent.c`
- Remove `g_system_context` global variable
- Remove `g_system_context` from `agent_init()` parameter
- Remove `g_system_context` prepend from all code paths

**File:** `src/nostr_handler.c`
- Remove `g_system_context` global variable (line 28)
- Remove `g_system_context` initialization in `nostr_handler_reconcile_startup_events()` (line 3124-3157)
- Remove `nostr_handler_get_system_context()` function

**File:** `src/agent.h`
- Update `agent_init()` signature to remove `system_context` parameter

## Migration Path

1. **Phase 0** — safe, independent fix. No behavioral change.
2. **Phase 1** — core change. Backward compatible if existing default skill has a DM trigger tag. The `g_system_context` is still used as fallback until Phase 6.
3. **Phase 2** — changes DM behavior. Existing agents work if default skill has `["trigger", "dm"]`.
4. **Phase 3** — additive. Skills without triggers are unaffected.
5. **Phase 4** — additive. New capability, no existing behavior changes.
6. **Phase 5** — additive. Currently only first entry is used; this adds fallback.
7. **Phase 6** — cleanup. Only after Phases 1-2 are stable.
