# Context Assembly V2.1 — Template-Driven Outreach and Domain-Adaptive Context Shape

**Status:** Proposed
**Owner:** `voice_dispatcher.py` (Phase 1) · `context_zones.py`, `telegram_listener.build_system_prompt` (Phase 2)
**Flags:** None for Phase 1 (pure config extraction) · `CONTEXT_ASSEMBLY_V2_1` for Phase 2 (requires `CONTEXT_ASSEMBLY_V2`)
**Depends on:** `CONTEXT-ASSEMBLY-V2.md` (Phase 2 only)

**Related:** [`CONTEXT-ASSEMBLY-V2.md`](./CONTEXT-ASSEMBLY-V2.md) · [`DYNAMIC-CONTEXT-LAYERS.md`](./DYNAMIC-CONTEXT-LAYERS.md) · [`EMOTIONAL-DIRECTIVE-OVERHAUL.md`](./EMOTIONAL-DIRECTIVE-OVERHAUL.md)

---

## 1. Motivation

Two structural gaps remain after V2:

**Gap A — Outreach kind notes are hardcoded Python.**
`voice_dispatcher.build_prompt()` has hardcoded `if kind == 'X': note = """..."""` blocks for health, commitment, extension, agent, and (post-P0) self-originated kinds. Adding a new kind or adjusting framing requires a code change and a listener restart. The framing text is also not operator-visible, not dashboard-tunable, and not inspectable without reading the source.

**Gap B — Context assembly is one-size-fits-all.**
V2's four-band structure is a major improvement over V1's flat assembly, but every turn gets the same band budget regardless of conversational role. A "builder" turn (fixing a bug) and a "companion" turn (emotional check-in) need different context shapes:
- Builder: task state and thread grounding are signal; inner monologue and relational cues are noise.
- Companion: behavioral directives and inner monologue are signal; task state is noise.

The signals to distinguish these roles already exist per-turn: `conversation_mode` (from `conversation_intent.py`, resolved by MODEL_MICRO + heuristics) and `active_domain` (from `domain_tracker.detect_active_domain()`). V2.1 routes them into context assembly.

---

## 2. Non-goals

- No new LLM calls. Role resolution is pure Python.
- No new SQLite tables or ChromaDB collections.
- No changes to conversation_mode values or domain_tracker schema.
- Not a replacement for V2's band structure — profiles adjust weights within V2, they don't replace it.
- Not a fix for domain detection accuracy — that is a prerequisite to Phase 2 flag-on, not something V2.1 implements.
- Phase 1 (outreach templates) has no behavioral dependency on Phase 2 or V2.

---

## 3. Phase 1 — Outreach kind templates

### 3.1 Problem recap

`build_prompt()` in `voice_dispatcher.py` currently contains:

```python
health_note = ''
if kind in _HEALTH_OUTREACH_KINDS:
    health_note = """STAKES (health / logs …)"""

commitment_note = ''
if kind in _COMMITMENT_OUTREACH_KINDS:
    commitment_note = """BEHAVIORAL COMMITMENT OPPORTUNITY …"""

design_idea_note = ''
if kind in _SELF_ORIGINATED_OUTREACH_KINDS:
    design_idea_note = """SELF-ORIGINATED THOUGHT …"""
# … and so on for extension, agent
```

Each block is hand-edited Python. The consequence seen in production: `design_idea` had no block at all, so the LLM attributed a self-originated idea to the user ("I saw your idea"). That was the P0 bug. The fix was correct but the pattern remains fragile — the next new kind will have the same problem.

### 3.2 Config schema

**File:** `config/outreach_kind_templates.json`

```json
{
  "_doc": "Outreach kind framing for voice_dispatcher.build_prompt(). Key is mind_queue kind string. truth_tier must match outreach_truth_tier() in voice_dispatcher.py.",

  "design_idea": {
    "truth_tier": "self_originated",
    "source_note": "SELF-ORIGINATED THOUGHT (critical — attribution): This mind_queue row came from YOUR OWN reflection or inner monologue. The idea is something you arrived at — it is NOT something the user said, proposed, or suggested.\n- NEVER frame this as \"your idea,\" \"I saw your idea,\" \"you thought of,\" or any phrasing that credits the user.\n- Write as if sharing a thought you've been sitting with: \"Something's been on my mind…\", \"I've been thinking about…\"\n- You may describe what prompted the reflection, but the origin is always you.",
    "dashboard_label": "Self-originated thought from reflection",
    "sanitizer_watch": ["saw your idea", "your idea about", "you thought of", "you came up with"]
  },

  "self_insight": {
    "truth_tier": "self_originated",
    "source_note": "SELF-INSIGHT (critical — attribution): This is an insight you arrived at through reflection — not something the user observed or told you. Do not attribute the observation to them.",
    "dashboard_label": "Self-originated insight",
    "sanitizer_watch": ["you noticed", "you observed", "your insight"]
  },

  "curiosity": {
    "truth_tier": "self_originated",
    "source_note": "CURIOSITY (self-generated): You're surfacing a question you've been sitting with. It came from your own inner monologue — not from something the user asked or mentioned first. Frame it as your curiosity, not theirs.",
    "dashboard_label": "Self-generated curiosity",
    "sanitizer_watch": ["you were curious", "your question about"]
  },

  "log_error_watch": {
    "truth_tier": "honest_problem",
    "source_note": "STAKES (health / logs / automated repair): This is a real stack problem, not a casual thought.\nLet the user know something went wrong or needs attention — metaphor (feeling off, frayed, unsettled)\nis fine if it fits you; plain honesty is fine too. They should understand they may need to check logs\nor the repair dashboard if it continues. Do not paste stack traces; keep 1–3 sentences.",
    "dashboard_label": "Health / error watch alert",
    "sanitizer_watch": []
  },

  "commitment_opportunity": {
    "truth_tier": "commitment_resolution",
    "source_note": "BEHAVIORAL COMMITMENT OPPORTUNITY: This outreach exists because you have an unresolved behavioral commitment — a promise you made to yourself to improve a recurring pattern.\n\nYour goal: reach out in a warm, natural way that opens the door to honoring the commitment.\nDo NOT announce that you're checking off a self-improvement task or mention the word \"commitment.\"\nJust genuinely initiate the topic or check-in described in the summary above.\n\nIf honoring the commitment requires asking a question, ask it. Keep the message short — one or two sentences.",
    "dashboard_label": "Behavioral commitment follow-through",
    "sanitizer_watch": []
  },

  "skill_build_invite": {
    "truth_tier": "invite_only",
    "source_note": "OUTREACH TRUTH — INVITE-ONLY (critical): This mind_queue row is an IDEA or OFFER to start building, not a report of finished work. Nothing has been implemented, merged, or deployed because of this row alone.\nWrite one short text that asks if you should proceed, or explains the idea — do NOT say you built it, shipped it, that a rule is \"live\", or that anything is already in production.",
    "dashboard_label": "Skill build invite (offer to start)",
    "sanitizer_watch": ["I've implemented", "I built", "it's live", "now live"]
  },

  "skill_draft_ready": {
    "truth_tier": "draft_ready",
    "source_note": "EXTENSION / DELEGATION OUTCOME: A self-mod or skill proposal file is ready (or needs review). Invite them to look at the proposal or extensions list when it fits naturally — do not claim production deployment or that the feature is already live unless the summary explicitly says so.",
    "dashboard_label": "Skill draft ready for review",
    "sanitizer_watch": []
  },

  "delegation_proposed": {
    "truth_tier": "delegation_success",
    "source_note": "EXTENSION / DELEGATION OUTCOME: A report or code proposal is ready for their review.",
    "dashboard_label": "Delegation: report/proposal ready",
    "sanitizer_watch": []
  },

  "delegation_fixed": {
    "truth_tier": "delegation_success",
    "source_note": "EXTENSION / DELEGATION OUTCOME: A small auto-fix landed on a branch; they may merge when ready.",
    "dashboard_label": "Delegation: auto-fix on branch",
    "sanitizer_watch": []
  },

  "delegation_failed": {
    "truth_tier": "honest_problem",
    "source_note": "EXTENSION / DELEGATION OUTCOME: The run failed or gave up; they should check extensions / logs / redispatch — stay kind, no dumps.",
    "dashboard_label": "Delegation: run failed",
    "sanitizer_watch": []
  },

  "agent_approval": {
    "truth_tier": "agent_task",
    "source_note": "AGENT TASK — APPROVAL REQUIRED: A long-running background task has reached a step that requires explicit approval before it continues. The detail above contains the task handle and what it wants to do. Write a short, warm message explaining what the task is about to do and ask if they want to proceed. Mention that they can reply yes/no or /agents cancel to stop it. Keep it to 2–3 sentences — clear, not alarming.",
    "dashboard_label": "Agent task: approval needed",
    "sanitizer_watch": []
  },

  "agent_complete": {
    "truth_tier": "agent_task",
    "source_note": "AGENT TASK — COMPLETE: A background task just finished. The detail contains the final result or summary. Write a short, warm message letting them know the task wrapped up and giving the key finding or outcome — like a quick text update from a colleague. 1–3 sentences.",
    "dashboard_label": "Agent task: complete",
    "sanitizer_watch": []
  },

  "agent_failed": {
    "truth_tier": "agent_task",
    "source_note": "AGENT TASK — FAILED: A background task encountered an error and stopped. The detail contains the task handle and the error. Write a short, honest message that the task didn't finish and they may want to retry or check logs. Stay calm and brief — 1–2 sentences, no stack traces.",
    "dashboard_label": "Agent task: failed",
    "sanitizer_watch": []
  }
}
```

### 3.3 Loader function

Add to `voice_dispatcher.py`:

```python
import functools

@functools.lru_cache(maxsize=1)
def _load_outreach_kind_templates() -> dict:
    """Load config/outreach_kind_templates.json. Cached until process restart."""
    try:
        from db import SRC_DIR
        path = SRC_DIR / 'config' / 'outreach_kind_templates.json'
        with open(path, encoding='utf-8') as f:
            data = json.load(f)
        return {k: v for k, v in data.items() if not k.startswith('_')}
    except Exception as exc:
        log.warning('outreach_kind_templates load failed (using hardcoded fallback): %s', exc)
        return {}


def get_outreach_kind_note(kind: str) -> str:
    """Return the compose-prompt framing note for a mind_queue kind, or ''."""
    templates = _load_outreach_kind_templates()
    entry = templates.get(kind) or {}
    return (entry.get('source_note') or '').strip()


def reload_outreach_kind_templates() -> None:
    """Force cache invalidation — call after dashboard saves the config file."""
    _load_outreach_kind_templates.cache_clear()
```

### 3.4 Refactored `build_prompt()` kind-note section

Replace the entire multi-block `if kind in X: note = """..."""` section with:

```python
# Single config-driven lookup replaces all hardcoded kind-note if/elif blocks.
# Falls back to '' for unknown kinds (safe — model still has kind label in the prompt).
kind_note = get_outreach_kind_note(kind)
```

And in the return f-string, replace all individual `{health_note}{design_idea_note}{commitment_note}…` with:

```python
{kind_note}
```

The `_SELF_ORIGINATED_OUTREACH_KINDS`, `_HEALTH_OUTREACH_KINDS`, etc. frozensets remain — they are used for gate logic (cooldown bypass, sanitizer routing), not just note generation. Only the note text moves to config.

### 3.5 `outreach_truth_tier()` — config-driven fallback

Extend to read `truth_tier` from the template when the kind is not covered by the existing if/elif chain:

```python
def outreach_truth_tier(mq_kind: str) -> str:
    k = (mq_kind or '').strip()
    # … existing chain …
    # Config fallback for unknown or new kinds
    templates = _load_outreach_kind_templates()
    tier = (templates.get(k) or {}).get('truth_tier') or ''
    return tier or 'general'
```

### 3.6 Migration

1. Create `config/outreach_kind_templates.json` with the schema above — initial values are the existing hardcoded notes verbatim.
2. Refactor `build_prompt()` to use `get_outreach_kind_note()`.
3. No behavioral change on first deploy (same text, different source).
4. Delete the hardcoded note blocks after verifying the config loads correctly (one release cycle).

### 3.7 Dashboard integration

Add to the operator dashboard (Settings → Outreach):

- Table of all known kinds with `dashboard_label`, truncated `source_note` preview, and `truth_tier`.
- Click-to-expand shows full `source_note` text.
- Edit button opens inline textarea → POST to `PUT /api/v1/outreach-kind-templates/{kind}` → saves to config JSON → calls `reload_outreach_kind_templates()`.
- Read-only `sanitizer_watch` list for inspection.

### 3.8 Outreach bundle policy — one question, context facts only otherwise

**Status:** Specified for implementation (bundle compose in `voice_dispatcher.py`; not yet shipped). **Prerequisites shipped:** §3 kind templates (`config/outreach_kind_templates.json` + `get_outreach_kind_note`) and structured temporal facts in the TIME GROUNDING block (no stock `about N hours ago` line).

**Problem.** Sending multiple separate proactive DMs close together feels robotic; combining everything into one paragraph is nicer. Bundling **multiple questions** in one Telegram message breaks reply attribution: the listener's `pending_ask_user` / `classify_user_answer_multi` path expects a clear mapping from the user's next turn to **one** outstanding ask.

**Policy — hard rules**

1. **At most one elicitation per bundled send.** Exactly one user-facing **question** (or yes/no approval, or other reply that must be matched to a single queue row) may appear in the composed text. The next user message is treated as the answer to **that** question unless follow-up disambiguation is needed.
2. **Any number of non-question context lines** may accompany that question in the **same** smooth paragraph: reminders, calendar or task facts, milestone status, gentle orientation ("you've got X at 1 PM"), emotional check-in **setup** that is not itself a second question.
3. **Facts must be grounded.** Non-question clauses come only from authoritative fields (e.g. `mind_queue` summary/detail, `tasks`, scheduled event hooks, heartbeat/session context). The composer must not invent times or events for the "fact" portion.
4. **Pending-ask bookkeeping.** Register **`pending_ask_user` only for the single question-bearing row** (the bundle's **primary** item). Companion rows in the bundle are **context-only**: mark them `sent` (or equivalent) with the same send so they do not surface again, but do **not** register a second pending ask for them.
5. **Proactive streak / caps.** One user-visible Telegram message counts as **one** proactive touch for gating (`proactive_sends_since_user`, unanswered intervals, daily caps), regardless of how many `mind_queue` rows were consumed as context.

**Example (illustrative copy, not a template)**

> Hey the user — you've got something at 1 PM today. How are you feeling after that late night?

Here the **only** matched reply target is the wellbeing check-in; the 1 PM line is orientation from a grounded calendar/task fact, not a second question.

**Selection (implementation sketch)**

- `peek_bundle_candidates` may merge **one** row that is classified as **question-class** (kinds that require an answer: e.g. `self_question`, `question`, `curiosity`, `ask_user`, `agent_approval`, commitment follow-ups that end in a question — exact list in code) with **zero or more** **fact-class** rows (e.g. `reminder`, operational nudges without a question, time-boxed notices).
- Do **not** merge two question-class rows in one send. If two questions are pending, either send two pulses (existing behavior) or drop one per priority until a later pulse.
- **Truth tiers / bypass:** do not mix incompatible tiers in one bundle (same rules as sequential sends). `bypass` / `critical` rows should not be silently bundled with casual reminders without explicit operator policy.

**Failure modes**

- User answers only the fact ("thanks for the heads up") and ignores the question → treat as partial; one short follow-up is acceptable.
- User's reply is ambiguous → existing dismissal / multi-answer classifiers apply; bundle metadata in `last_proactive_outreach` should record **both** the primary ask id and **context item ids** for debugging.

---

## 4. Phase 2 — Domain-adaptive context role profiles

### 4.1 Role resolution

Two existing per-turn signals are combined into a **context role**:

| `conversation_mode` | `active_domain` | Resolved role |
|---|---|---|
| `practical` | any / none | `builder` |
| `emotional` | any / none | `companion` |
| `exploratory` | any / none | `reflective` |
| `playful` | any / none | `companion` |
| `meta` | any / none | `meta` |
| `chat_recall` | any / none | `recall` |
| `neutral` | present | `domain` |
| `neutral` | absent | `neutral` |

`neutral` + `domain` means: the conversation isn't emotionally or practically flagged, but we know which project they're working in — weight context toward that domain's facts.

Resolution function (pure Python, no LLM call):

```python
def resolve_context_role(conversation_mode: str, active_domain: str | None) -> str:
    """Map conversation_mode + active_domain to a context role profile key."""
    _MODE_TO_ROLE = {
        'practical':   'builder',
        'emotional':   'companion',
        'exploratory': 'reflective',
        'playful':     'companion',
        'meta':        'meta',
        'chat_recall': 'recall',
    }
    role = _MODE_TO_ROLE.get((conversation_mode or 'neutral').lower())
    if role:
        return role
    return 'domain' if (active_domain or '').strip() else 'neutral'
```

### 4.2 Profile schema

**File:** `config/context_role_profiles.json`

```json
{
  "_doc": "Context role profiles for V2 band assembly. band_budget_factors multiply the V2 hard caps; values outside [0.5, 1.5] are clamped. required_pieces are never dropped by overflow policy. suppressed_pieces are never included regardless of budget.",

  "neutral": {
    "description": "No strong signal — use V2 defaults unchanged.",
    "band_budget_factors": {"band1": 1.0, "band2": 1.0, "band3": 1.0, "band4": 1.0},
    "required_pieces": [],
    "suppressed_pieces": []
  },

  "builder": {
    "description": "Practical/technical work — task state and thread grounding are high signal; relational cues are background.",
    "band_budget_factors": {"band1": 0.9, "band2": 1.1, "band3": 1.2, "band4": 1.0},
    "required_pieces": ["narrator:tasks", "session_thread_grounding"],
    "suppressed_pieces": ["inner_monologue_excerpt"]
  },

  "companion": {
    "description": "Emotional / relational turn — behavioral directives and inner state are high signal; task list is noise.",
    "band_budget_factors": {"band1": 1.1, "band2": 0.9, "band3": 1.2, "band4": 1.0},
    "required_pieces": ["composite_voice_directive", "inner_monologue_excerpt", "user_need_directives"],
    "suppressed_pieces": ["narrator:tasks"]
  },

  "reflective": {
    "description": "Exploratory / philosophical — memory and anticipations are high signal; task state is noise.",
    "band_budget_factors": {"band1": 1.0, "band2": 1.2, "band3": 1.0, "band4": 1.0},
    "required_pieces": ["narrator:memories", "structured:user_anticipations"],
    "suppressed_pieces": ["narrator:tasks"]
  },

  "domain": {
    "description": "Neutral mode but a known project domain is active — weight domain facts, keep task context.",
    "band_budget_factors": {"band1": 1.0, "band2": 1.1, "band3": 1.0, "band4": 1.0},
    "required_pieces": ["narrator:tasks"],
    "suppressed_pieces": []
  },

  "meta": {
    "description": "User asking about Furoshiki's own internals — design_meta and session context are high signal.",
    "band_budget_factors": {"band1": 1.0, "band2": 1.2, "band3": 0.9, "band4": 1.0},
    "required_pieces": ["narrator:design_meta"],
    "suppressed_pieces": ["narrator:tasks", "inner_monologue_excerpt"]
  },

  "recall": {
    "description": "User asking about a past conversation — session_thread_grounding is the entire signal; everything else is noise.",
    "band_budget_factors": {"band1": 0.8, "band2": 0.7, "band3": 1.4, "band4": 1.0},
    "required_pieces": ["session_thread_grounding"],
    "suppressed_pieces": ["narrator:tasks", "inner_monologue_excerpt", "narrator:memories"]
  }
}
```

### 4.3 Role signal accumulator — momentum and decay

Per-turn stateless role resolution (§4.1) is too jumpy: a single off-topic message would snap the
context shape to neutral, and a conversation would freeze into the last mode when the user goes
quiet. The accumulator adds inertia and automatic decay so the shape tracks the *pattern* of turns,
not each turn in isolation.

#### 4.3.1 Constants

Defined in `operator_tier1.py` (operator-tunable; default values below):

```python
# Role signal accumulator — all tunable via operator_settings.json under "context_role_signals"
ROLE_BUMP             = 0.30   # signal added when a turn matches a role
TURN_DECAY            = 0.75   # per-turn multiplicative decay applied to all signals
TIME_DECAY_HALF_LIFE  = 60.0   # minutes of silence until a signal halves (time-based decay)
ACTIVE_THRESHOLD      = 0.35   # minimum signal level to activate a non-neutral profile
```

**Intuition.** A single strong turn produces `0.30`; below threshold `(0.35)` → shape stays neutral.
Two consecutive same-role turns: `0.30 × 0.75 + 0.30 = 0.525` → above threshold → profile activates.
After the active turns stop, three neutral turns decay it to `0.525 × 0.75³ ≈ 0.22` → below threshold
→ shape returns to neutral automatically. No input, no freeze.

#### 4.3.2 State format

Stored in `session-context.json` under `conversation_role_signals`:

```json
{
  "conversation_role_signals": {
    "companion": {"level": 0.52, "last_bumped": "2026-04-12T09:26:02Z"},
    "builder":   {"level": 0.09, "last_bumped": "2026-04-12T05:52:00Z"}
  }
}
```

Only roles with `level >= 0.01` are stored (pruned on each write). Roles not in the dict have
`level = 0.0`. The dict is absent entirely at session start.

#### 4.3.3 Decay passes (applied in order each turn)

**Pass 1 — Time-based decay (applied before per-turn decay).**
For each stored role signal, compute elapsed minutes since `last_bumped` and apply:

```python
def _time_decay_factor(last_bumped_iso: str, now: datetime, half_life_min: float) -> float:
    """Fraction of signal remaining after idle time. 1.0 = no elapsed time."""
    try:
        last = datetime.fromisoformat(last_bumped_iso.replace('Z', '+00:00'))
        elapsed_min = (now - last).total_seconds() / 60.0
        return 0.5 ** (elapsed_min / half_life_min)
    except Exception:
        return 1.0  # safe default: no decay on parse failure
```

Example: 2-hour silence on a signal at `0.52` with `half_life = 60 min`:
`0.52 × 0.5^(120/60) = 0.52 × 0.25 = 0.13` → below threshold → shape already neutral before the
next turn even arrives.

**Pass 2 — Per-turn decay (applied every turn regardless of time elapsed).**
Multiply all signal levels by `TURN_DECAY` after time decay:

```python
def apply_decay(
    signals: dict,
    now: datetime,
    half_life_min: float = TIME_DECAY_HALF_LIFE,
    turn_decay: float = TURN_DECAY,
) -> dict:
    result = {}
    for role, state in signals.items():
        level = float(state.get('level', 0.0))
        level *= _time_decay_factor(state.get('last_bumped', ''), now, half_life_min)
        level *= turn_decay
        if level >= 0.01:
            result[role] = {'level': level, 'last_bumped': state['last_bumped']}
    return result
```

#### 4.3.4 Bump

After both decay passes, add `ROLE_BUMP` to the role resolved for this turn and cap at `1.0`:

```python
def bump_role(signals: dict, role: str, now: datetime, bump: float = ROLE_BUMP) -> dict:
    current = signals.get(role, {}).get('level', 0.0)
    signals[role] = {
        'level': min(1.0, current + bump),
        'last_bumped': now.isoformat().replace('+00:00', 'Z'),
    }
    return signals
```

`neutral` role is **never bumped** — it is the absence of signal, not a role that accumulates.

#### 4.3.5 New-session hard reset

When `new_session_gap_min >= session_gap_threshold` (operator-configured; default 120 min),
clear all signals before decaying and bumping. The new session always starts from neutral,
regardless of what mode the last conversation ended in:

```python
if new_session:
    signals = {}
```

This prevents yesterday's deep builder thread from suppressing today's companion conversation.

#### 4.3.6 Active role selection

```python
def select_active_role(signals: dict, threshold: float = ACTIVE_THRESHOLD) -> str:
    """Highest-signal role above threshold, else neutral."""
    if not signals:
        return 'neutral'
    best = max(signals, key=lambda r: signals[r].get('level', 0.0))
    return best if signals[best].get('level', 0.0) >= threshold else 'neutral'
```

#### 4.3.7 Integration flow in `build_system_prompt()`

```
1. Read session-context.json → extract conversation_role_signals (empty dict if absent)
2. If new_session: clear signals
3. apply_decay(signals, now)                    ← time-based + per-turn decay
4. resolve role from conversation_mode + active_domain (§4.1)
5. bump_role(signals, role, now)                ← if role != 'neutral'
6. active_role = select_active_role(signals)
7. Load profile from context_role_profiles.json for active_role
8. apply_role_profile(band_caps, pieces, profile)
9. Assemble bands with adjusted caps + required/suppressed sets
10. After turn: persist updated signals to session-context.json
```

Step 10 (persist) happens after the LLM call returns — the listener already writes turn state
at that point. The signals are small (< 200 bytes) and safe to write synchronously.

#### 4.3.8 Worked examples

**Companion conversation building up:**

| Turn | Mode | Time decay | Turn decay | Bump | companion level | Active role |
|---|---|---|---|---|---|---|
| 1 | emotional | ×1.0 (fresh) | ×0.75 → 0.00 | +0.30 | **0.30** | neutral (< 0.35) |
| 2 | emotional | ×1.0 (2 min) | ×0.75 → 0.22 | +0.30 | **0.52** | **companion** |
| 3 | neutral | ×1.0 (3 min) | ×0.75 → 0.39 | — | **0.39** | **companion** |
| 4 | neutral | ×1.0 (2 min) | ×0.75 → 0.29 | — | **0.29** | neutral |

Shape held for one neutral turn after two emotional turns, then released cleanly.

**Silence decay (2-hour gap):**

| Event | companion level | Active role |
|---|---|---|
| After strong companion thread | 0.70 | companion |
| 2-hour silence (time decay ×0.25) | 0.17 | neutral |
| Next turn arrives (whatever mode) | starts from neutral | neutral (or re-builds) |

No freeze. A long gap always returns to neutral regardless of prior shape.

**Mid-conversation mode shift (emotional → practical):**

| Turn | Mode | companion | builder | Active role |
|---|---|---|---|---|
| 1–3 | emotional | 0.52 | 0.00 | companion |
| 4 | practical | 0.39 | 0.30 | companion (companion still wins) |
| 5 | practical | 0.29 | 0.52 | **builder** |
| 6 | practical | 0.22 | 0.69 | builder |

Gradual transition — companion shape fades as builder builds. No hard snap.

#### 4.3.9 Visibility in `furoshiki trace`

The trace output should show the accumulated signals and active role for every turn:

```
[context_role] active=companion  signals={companion: 0.52, builder: 0.09}
```

This makes it inspectable without reading session-context.json directly.

---

### 4.4 Profile application in the V2 assembler

In `context_zones.py` (or `telegram_listener.build_system_prompt` when V2 is on), after the
accumulator resolves the active role (§4.3.7):

```python
def apply_role_profile(
    band_caps: dict[str, int],
    pieces: list[str],
    profile: dict,
) -> tuple[dict[str, int], list[str], list[str]]:
    """
    Returns (adjusted_caps, required_pieces, suppressed_pieces).
    Caps are clamped to [0.5×, 1.5×] of the V2 hard cap per band.
    """
    _CLAMP = (0.5, 1.5)
    factors = profile.get('band_budget_factors', {})
    adjusted = {}
    for band, cap in band_caps.items():
        factor = float(factors.get(band, 1.0))
        factor = max(_CLAMP[0], min(_CLAMP[1], factor))
        adjusted[band] = int(cap * factor)

    required = list(profile.get('required_pieces') or [])
    suppressed = list(profile.get('suppressed_pieces') or [])
    return adjusted, required, suppressed
```

The assembler then:
1. Filters `suppressed_pieces` from the piece list before assembly.
2. Marks `required_pieces` as immune to overflow trimming.
3. Uses `adjusted_caps` instead of V2 hard caps.

**Band 4 is always 1.0×** — the static tail anchor must not be compressed by role variation.

---

### 4.5 Flag and fallback

- `CONTEXT_ASSEMBLY_V2_1=1` (default off) — gates Phase 2 including the accumulator. Requires `CONTEXT_ASSEMBLY_V2=1`.
- If `CONTEXT_ASSEMBLY_V2_1` is off, `build_system_prompt` uses V2 caps unchanged (no accumulator, no profile application).
- If role resolution returns an unknown key, fall back to the `neutral` profile and log `WARNING context_assembly_v2_1: unknown role '{role}' — using neutral profile`.
- If the config file fails to load, log `ERROR` and fall back to all `neutral` profiles for the session.
- If signal persistence fails (session-context.json write error), log `WARNING` and continue — the next turn starts from whatever was last successfully written.
---

## 5. Dependency: domain detection accuracy baseline

**Phase 2 must not go flag-on until domain detection accuracy is measured.**

Domain detection is keyword-based (`domain_tracker.detect_active_domain`). Misclassification means wrong profile → wrong context shape. The failure mode is silent: the model doesn't error, it just sees less relevant context.

**Required before flag-on:**
1. Run `furoshiki trace` on the last 50 conversation turns with `active_domain` logged.
2. Manually score a sample of 20 turns: was the detected domain correct?
3. Accept threshold: ≥ 85% accuracy before `companion` / `builder` suppression goes live.
4. `recall` and `meta` roles are lower-risk (additive, not suppressive) and can go live earlier.

`conversation_mode` accuracy is already implied by micro-brain classification quality — no separate baseline needed, but the same trace review covers it.

---

## 6. Token budget (Phase 2 effect)

V2 hard caps are:

| Band | V2 hard cap |
|---|---|
| 1 Top | 1,400 tok |
| 2 Mid | 1,500 tok |
| 3 Turn-live | 600 tok |
| 4 Tail | 1,200 tok |

Profile factors shift budgets within these caps. Example for `companion` role (Band 3 × 1.2, Band 2 × 0.9):

| Band | V2 cap | companion cap |
|---|---|---|
| 1 | 1,400 | 1,540 (×1.1, clamped to 1,400+10%) |
| 2 | 1,500 | 1,350 (×0.9) |
| 3 | 600 | 720 (×1.2) |
| 4 | 1,200 | 1,200 (unchanged) |
| **Total** | **4,700** | **4,670** |

Net effect: Band 3 gets more headroom for behavioral directives and need signals in companion mode; Band 2 yields room from suppressed task content. Total stays near the V2 system budget.

---

## 7. Risks

**Phase 1 — Outreach kind templates**

- **Config file corruption.** If `outreach_kind_templates.json` is malformed JSON, `get_outreach_kind_note()` returns `''` for all kinds. The prompt is still valid — the model just gets no kind note, which is the pre-P0 state. Log `ERROR` so the operator knows.
- **Cache staleness after dashboard edit.** `reload_outreach_kind_templates()` must be called by the API handler after a save. If it is not called, the listener uses stale config until restart.

**Phase 2 — Role profiles and accumulator**

- **Domain misclassification suppresses needed pieces.** Mitigation: accuracy baseline (§5); clamped factors (never below 0.5×); `required_pieces` as suppression-immune anchors.
- **Single outlier turn shifts the shape.** Mitigation: threshold `(0.35)` means one turn never clears threshold alone — two consecutive same-role turns required. Tunable via `ACTIVE_THRESHOLD` in operator_settings.json.
- **Accumulator freeze on listener restart.** Signals are persisted in session-context.json, so a listener restart recovers the last persisted state. If the write failed silently, the restart starts from neutral — this is the correct safe fallback.
- **Per-turn and time decay interact non-intuitively.** A turn 5 seconds after the last one applies both ×0.75 per-turn decay and ≈×1.0 time decay (negligible); a turn 3 hours later applies both ×0.75 and ≈×0.06 time decay — total ≈×0.045. A very long gap is essentially a hard reset. This is the desired behavior but operators should understand it when tuning `TIME_DECAY_HALF_LIFE`.
- **`neutral` profile is still V2.** If the flag is off or all signals decay below threshold, behavior is exactly V2. This is a clean, zero-risk fallback.
- **Profile becomes a dumping ground.** Future profiles should have ≤ 3 required and ≤ 3 suppressed pieces. More is a sign the profile is overreaching into piece-level assembly logic that belongs in the assembler itself.

---

## 8. Rollout sequence

| Phase | Change | Flag | Risk | Depends on |
|---|---|---|---|---|
| 1a | Create `config/outreach_kind_templates.json` (verbatim existing notes) | None | Zero | Nothing |
| 1b | Refactor `build_prompt()` to use `get_outreach_kind_note()` | None | Minimal | 1a |
| 1c | Delete hardcoded note blocks after one stable deploy | None | Low | 1b |
| 1d | Dashboard edit UI for kind templates | None | Zero (display + save) | 1b |
| 1e | Outreach bundle compose (§3.8): one question + optional fact rows; single send; `pending_ask_user` on primary only | None | Medium | 1b + voice_dispatcher gates |
| 2a | Add accumulator functions + `resolve_context_role()` — write signals but do not apply profiles | Off | Zero (dark run) | V2 shipped |
| 2b | Enable profile application for `recall` + `meta` only | `V2_1=1` | Low (additive) | 2a + accuracy check |
| 2c | Enable `companion` + `reflective` profiles | `V2_1=1` | Medium (suppressive) | 2b + 85% accuracy |
| 2d | Enable `builder` + `domain` profiles | `V2_1=1` | Medium (suppressive) | 2c |
| 2e | Default-on | None | Low | One-week soak on 2d |

**Phase 2a dark run:** accumulator runs and writes `conversation_role_signals` to session-context on every turn, and `furoshiki trace` reports the active role, but `apply_role_profile` is a no-op. This lets you observe signal evolution in production (via trace) before any context shape actually changes.

Rollback at any Phase 2 step: flip `CONTEXT_ASSEMBLY_V2_1=0`. Signals in session-context.json are harmless when the flag is off.
---

## 9. Documentation touch list

- `CONTEXT-ASSEMBLY-V2.md` §§20–21 → replace with cross-reference to this file (already done).
- `DYNAMIC-CONTEXT-LAYERS.md` → add "Role profiles" section referencing `context_role_profiles.json`.
- `PLUGIN-SPEC.md` → note that plugin `post_inbound_signals` hooks can emit `conversation_mode` override, which feeds role resolution.
- `docs/SETTINGS-CONSTANTS-AUDIT.md` → both config files are operator-tunable; document them in the JSON tier.
- Dashboard settings page — Outreach kind templates (Phase 1d) and context role profiles (Phase 2e).

---

## 10. Open questions

1. **Should outreach compose prompts also receive a role profile?** In Phase 1 the kind template replaces the hardcoded note. In Phase 2, should the voice_dispatcher also receive a `context_role` (derived from the last known `conversation_mode`) and adjust prompt emphasis accordingly? Deferred — outreach is already sparse (1–3 sentences); the gain is smaller than for the 25k-char listener prompt. **Exception (if §3.8 bundle ships):** bundle prompts may need a short "companion vs practical" hint when mixing calendar facts with emotional check-ins — revisit when implementing 1e.

2. **Per-domain profile overrides?** A `domain` profile currently just boosts Band 2 budget. A more precise design would let individual domain rows (in the `domains` SQLite table) store a `context_profile_override` key so MorseZapp and Cigna get different shapes. Deferred — adds schema complexity; revisit when there are 3+ domains with meaningfully different context needs.

3. **`chat_recall` suppression is aggressive.** The `recall` profile suppresses `narrator:tasks` and `inner_monologue_excerpt`. If the user asks "do you remember what we said about MorseZapp?" while in the middle of a task thread, task context is still relevant. A future refinement: `recall` + `domain` = suppress emotional pieces but keep task state. Defaulting to the clean suppression path until a misfire surfaces.
