diff --git a/talent/_routine_context.py b/talent/_routine_context.py new file mode 100644 index 000000000..4926ea3be --- /dev/null +++ b/talent/_routine_context.py @@ -0,0 +1,228 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Shared rendering helpers for routine state template vars.""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import Any + +_TEMPLATE_TRIGGERS = { + "morning-briefing": { + "patterns": [ + "calendar", + "schedule", + "agenda", + "what do i have today", + "what's on my calendar", + "whats on my calendar", + "what's happening today", + "whats happening today", + ], + "threshold": 3, + "description": "asked about your calendar or schedule", + }, + "weekly-review": { + "patterns": [ + "this week", + "last week", + "past few days", + "how did my week", + "what happened this week", + "how was my week", + ], + "threshold": 3, + "description": "asked for week-scale synthesis", + }, + "domain-watch": { + "patterns": [ + "track", + "watch", + "keep an eye on", + "follow", + "across days", + "over time", + "lately", + "trend", + "trends", + ], + "threshold": 3, + "description": "revisited the same topic across multiple days", + }, + "relationship-pulse": { + "patterns": [ + "who haven't i", + "who havent i", + "relationship", + "when did i last talk to", + "catch up with", + ], + "threshold": 2, + "description": "asked about relationships", + }, + "commitment-audit": { + "patterns": [ + "follow up", + "follow-up", + "commitment", + "dropped", + "overdue", + "what did i promise", + "pending", + ], + "threshold": 2, + "description": "asked about commitments or follow-ups", + }, + "meeting-prep": { + "patterns": [ + "brief me", + "who am i meeting", + "meeting with", + "prepare me for", + "prep for my meeting", + "prep me for", + "meeting prep", + ], + "threshold": 3, + "description": "asked for meeting briefings", + }, +} + + +def render_active_routines() -> str: + """Render the active routines template var.""" + from think.routines import get_routine_state + + routines = get_routine_state() + if not routines: + return "" + + lines = ["## Active Routines\n"] + for routine in routines: + status = "on" if routine["enabled"] else "paused" + if routine.get("paused_until"): + status = f"paused until {routine['paused_until']}" + line = f"- **{routine['name']}** ({routine['cadence']}) — {status}" + if routine.get("output_summary"): + line += f" | recent: {routine['output_summary']}" + lines.append(line) + return "\n".join(lines) + + +def get_eligible_suggestion( + routines_config: dict[str, Any], journal_config: dict[str, Any] +) -> dict[str, Any] | None: + """Evaluate the routine suggestion gates and return the best candidate.""" + meta = routines_config.get("_meta", {}) + + if not meta.get("suggestions_enabled", True): + return None + + name_status = journal_config.get("agent", {}).get("name_status", "default") + if name_status == "default": + return None + + last_date_str = meta.get("last_suggestion_date") + if last_date_str: + try: + last_date = date.fromisoformat(last_date_str) + if (date.today() - last_date) < timedelta(days=7): + return None + except ValueError: + pass + + suggestions = meta.get("suggestions", {}) + active_templates = { + value.get("template") + for value in routines_config.values() + if isinstance(value, dict) and value.get("id") + } + + candidates = [] + + for template_name, entry in suggestions.items(): + if template_name in active_templates: + continue + if entry.get("response") == "declined": + continue + + info = _TEMPLATE_TRIGGERS.get(template_name) + if info and entry.get("trigger_count", 0) >= info["threshold"]: + candidates.append( + { + "template_name": template_name, + "trigger_count": entry["trigger_count"], + "first_trigger": entry.get("first_trigger"), + "pattern_description": info["description"], + } + ) + + if "monthly-patterns" not in active_templates: + mp_entry = suggestions.get("monthly-patterns", {}) + if mp_entry.get("response") != "declined": + from think.utils import day_dirs + + days = day_dirs() + if days: + earliest = min(days.keys()) + earliest_date = date( + int(earliest[:4]), + int(earliest[4:6]), + int(earliest[6:8]), + ) + if (date.today() - earliest_date) >= timedelta(days=30): + candidates.append( + { + "template_name": "monthly-patterns", + "trigger_count": 0, + "first_trigger": ( + f"{earliest[:4]}-{earliest[4:6]}-{earliest[6:8]}" + ), + "pattern_description": ( + "your journal has 30+ days of history" + ), + } + ) + + if not candidates: + return None + + candidates.sort(key=lambda candidate: candidate["trigger_count"], reverse=True) + return candidates[0] + + +def render_routine_suggestion() -> str: + """Render the routine suggestion template var.""" + from think.routines import get_config as get_routines_config + from think.utils import get_config as get_journal_config + + suggestion = get_eligible_suggestion(get_routines_config(), get_journal_config()) + if not suggestion: + return "" + + if suggestion["trigger_count"] == 0: + pattern_line = ( + f"Pattern: {suggestion['pattern_description']} " + f"since {suggestion['first_trigger']}." + ) + else: + pattern_line = ( + f"Pattern: You've {suggestion['pattern_description']} " + f"{suggestion['trigger_count']} times since " + f"{suggestion['first_trigger']}." + ) + + return ( + "## Routine Suggestion Eligible\n\n" + f"Template: {suggestion['template_name']}\n" + f"{pattern_line}\n" + f"Trigger count: {suggestion['trigger_count']}\n" + f"First seen: {suggestion['first_trigger']}\n\n" + "### Etiquette\n" + "- Mention this ONCE, naturally, at the end of your response\n" + '- Frame as observation: "I\'ve noticed you often... — would a routine help?"\n' + "- If $name declines or ignores, do not bring it up again this conversation\n" + "- After suggesting, run: `sol call routines suggest-respond " + f"{suggestion['template_name']} --accepted` or `--declined`" + ) diff --git a/talent/chat_context.py b/talent/chat_context.py index a373ca5e0..5eca3c7b4 100644 --- a/talent/chat_context.py +++ b/talent/chat_context.py @@ -6,98 +6,20 @@ from __future__ import annotations import logging -from datetime import date, datetime, timedelta +from datetime import date, datetime from pathlib import Path from typing import Any from convey.chat_stream import read_chat_tail, reduce_chat_state -from think.utils import get_config, get_journal +from talent._routine_context import ( + _TEMPLATE_TRIGGERS as TEMPLATE_TRIGGERS, +) +from talent._routine_context import render_active_routines, render_routine_suggestion +from think.utils import get_journal logger = logging.getLogger(__name__) -TEMPLATE_TRIGGERS = { - "morning-briefing": { - "patterns": [ - "calendar", - "schedule", - "agenda", - "what do i have today", - "what's on my calendar", - "whats on my calendar", - "what's happening today", - "whats happening today", - ], - "threshold": 3, - "description": "asked about your calendar or schedule", - }, - "weekly-review": { - "patterns": [ - "this week", - "last week", - "past few days", - "how did my week", - "what happened this week", - "how was my week", - ], - "threshold": 3, - "description": "asked for week-scale synthesis", - }, - "domain-watch": { - "patterns": [ - "track", - "watch", - "keep an eye on", - "follow", - "across days", - "over time", - "lately", - "trend", - "trends", - ], - "threshold": 3, - "description": "revisited the same topic across multiple days", - }, - "relationship-pulse": { - "patterns": [ - "who haven't i", - "who havent i", - "relationship", - "when did i last talk to", - "catch up with", - ], - "threshold": 2, - "description": "asked about relationships", - }, - "commitment-audit": { - "patterns": [ - "follow up", - "follow-up", - "commitment", - "dropped", - "overdue", - "what did i promise", - "pending", - ], - "threshold": 2, - "description": "asked about commitments or follow-ups", - }, - "meeting-prep": { - "patterns": [ - "brief me", - "who am i meeting", - "meeting with", - "prepare me for", - "prep for my meeting", - "prep me for", - "meeting prep", - ], - "threshold": 3, - "description": "asked for meeting briefings", - }, -} - - def _count_triggers(msg: str, facet: str | None, config: dict) -> bool: """Count trigger signals in the user's message. Returns True if config was mutated.""" lower = msg.lower() @@ -152,95 +74,9 @@ def _count_triggers(msg: str, facet: str | None, config: dict) -> bool: return changed -def _get_eligible_suggestion( - routines_config: dict, journal_config: dict -) -> dict | None: - """Evaluate 5-gate chain and return the best eligible suggestion, or None.""" - meta = routines_config.get("_meta", {}) - - if not meta.get("suggestions_enabled", True): - return None - - name_status = journal_config.get("agent", {}).get("name_status", "default") - if name_status == "default": - return None - - last_date_str = meta.get("last_suggestion_date") - if last_date_str: - try: - last_date = date.fromisoformat(last_date_str) - if (date.today() - last_date) < timedelta(days=7): - return None - except ValueError: - pass - - suggestions = meta.get("suggestions", {}) - active_templates = { - value.get("template") - for value in routines_config.values() - if isinstance(value, dict) and value.get("id") - } - - candidates = [] - - for template_name, entry in suggestions.items(): - if template_name in active_templates: - continue - if entry.get("response") == "declined": - continue - - info = TEMPLATE_TRIGGERS.get(template_name) - if info and entry.get("trigger_count", 0) >= info["threshold"]: - candidates.append( - { - "template_name": template_name, - "trigger_count": entry["trigger_count"], - "first_trigger": entry.get("first_trigger"), - "pattern_description": info["description"], - } - ) - - if "monthly-patterns" not in active_templates: - mp_entry = suggestions.get("monthly-patterns", {}) - if mp_entry.get("response") != "declined": - try: - from think.utils import day_dirs - - days = day_dirs() - if days: - earliest = min(days.keys()) - earliest_date = date( - int(earliest[:4]), - int(earliest[4:6]), - int(earliest[6:8]), - ) - if (date.today() - earliest_date) >= timedelta(days=30): - candidates.append( - { - "template_name": "monthly-patterns", - "trigger_count": 0, - "first_trigger": ( - f"{earliest[:4]}-{earliest[4:6]}-{earliest[6:8]}" - ), - "pattern_description": ( - "your journal has 30+ days of history" - ), - } - ) - except Exception: - pass - - if not candidates: - return None - - candidates.sort(key=lambda candidate: candidate["trigger_count"], reverse=True) - return candidates[0] - - def pre_process(context: dict) -> dict: """Build chat-context template vars for the chat talent prompt.""" from think.routines import get_config as get_routines_config - from think.routines import get_routine_state from think.routines import save_config as save_routines_config facet = context.get("facet") @@ -310,18 +146,7 @@ def pre_process(context: dict) -> dict: template_vars["location"] = _render_location(trigger_payload, context) try: - routines = get_routine_state() - if routines: - lines = ["## Active Routines\n"] - for routine in routines: - status = "on" if routine["enabled"] else "paused" - if routine.get("paused_until"): - status = f"paused until {routine['paused_until']}" - line = f"- **{routine['name']}** ({routine['cadence']}) — {status}" - if routine.get("output_summary"): - line += f" | recent: {routine['output_summary']}" - lines.append(line) - template_vars["active_routines"] = "\n".join(lines) + template_vars["active_routines"] = render_active_routines() except Exception: logger.debug("Routine state enrichment failed", exc_info=True) @@ -335,33 +160,7 @@ def pre_process(context: dict) -> dict: logger.debug("Routine trigger counting failed", exc_info=True) try: - routines_config = get_routines_config() - suggestion = _get_eligible_suggestion(routines_config, get_config()) - if suggestion: - if suggestion["trigger_count"] == 0: - pattern_line = ( - f"Pattern: {suggestion['pattern_description']} " - f"since {suggestion['first_trigger']}." - ) - else: - pattern_line = ( - f"Pattern: You've {suggestion['pattern_description']} " - f"{suggestion['trigger_count']} times since " - f"{suggestion['first_trigger']}." - ) - template_vars["routine_suggestion"] = ( - "## Routine Suggestion Eligible\n\n" - f"Template: {suggestion['template_name']}\n" - f"{pattern_line}\n" - f"Trigger count: {suggestion['trigger_count']}\n" - f"First seen: {suggestion['first_trigger']}\n\n" - "### Etiquette\n" - "- Mention this ONCE, naturally, at the end of your response\n" - '- Frame as observation: "I\'ve noticed you often... — would a routine help?"\n' - "- If $name declines or ignores, do not bring it up again this conversation\n" - "- After suggesting, run: `sol call routines suggest-respond " - f"{suggestion['template_name']} --accepted` or `--declined`" - ) + template_vars["routine_suggestion"] = render_routine_suggestion() except Exception: logger.debug("Routine suggestion eligibility check failed", exc_info=True) diff --git a/talent/exec.md b/talent/exec.md index 730f3a91c..ef54ecc84 100644 --- a/talent/exec.md +++ b/talent/exec.md @@ -2,11 +2,18 @@ "type": "cogitate", "tier": 3, "title": "Exec", - "description": "Sol — the journal itself, as a conversational partner" + "description": "Sol — the journal itself, as a conversational partner", + "hook": {"pre": "exec_context"} } $facets +## Current Routine State + +$active_routines + +$routine_suggestion + ## Adaptive Depth Match your response depth to the question. The owner doesn't pick a mode — you decide. diff --git a/talent/exec_context.py b/talent/exec_context.py new file mode 100644 index 000000000..f2ca4c3a5 --- /dev/null +++ b/talent/exec_context.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Pre-hook: provide routine state template vars for exec.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def pre_process(context: dict) -> dict: + """Build routine-state template vars for the exec talent prompt.""" + del context + + active_routines = "" + routine_suggestion = "" + + try: + from talent._routine_context import render_active_routines + + active_routines = render_active_routines() + except Exception: + logger.debug("exec_context: failed to render active routines", exc_info=True) + + try: + from talent._routine_context import render_routine_suggestion + + routine_suggestion = render_routine_suggestion() + except Exception: + logger.debug("exec_context: failed to render routine suggestion", exc_info=True) + + return { + "template_vars": { + "active_routines": active_routines, + "routine_suggestion": routine_suggestion, + } + } diff --git a/tests/test_exec_context.py b/tests/test_exec_context.py new file mode 100644 index 000000000..739e03f02 --- /dev/null +++ b/tests/test_exec_context.py @@ -0,0 +1,255 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import importlib.util +import json +from pathlib import Path + +TEMPLATE_VAR_KEYS = frozenset({"active_routines", "routine_suggestion"}) + + +def _load_exec_context_module(): + path = Path(__file__).resolve().parents[1] / "talent" / "exec_context.py" + spec = importlib.util.spec_from_file_location("test_exec_context_local", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _load_chat_context_module(): + path = Path(__file__).resolve().parents[1] / "talent" / "chat_context.py" + spec = importlib.util.spec_from_file_location("test_chat_context_local", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _assert_template_vars_result(result): + assert set(result.keys()) == {"template_vars"} + assert set(result["template_vars"].keys()) == TEMPLATE_VAR_KEYS + return result["template_vars"] + + +def _write_journal_config(journal: Path, data: dict) -> None: + config_dir = journal / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "journal.json").write_text( + json.dumps(data, indent=2), + encoding="utf-8", + ) + + +def test_exec_pre_process_populated_state(monkeypatch, tmp_path): + journal = tmp_path / "journal" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) + _write_journal_config( + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} + ) + + monkeypatch.setattr( + "think.routines.get_routine_state", + lambda: [ + { + "name": "Morning Briefing", + "cadence": "0 9 * * *", + "last_run": None, + "enabled": True, + "paused_until": None, + "output_summary": "Shared the top priorities.", + } + ], + ) + monkeypatch.setattr( + "think.routines.get_config", + lambda: { + "_meta": { + "suggestions_enabled": True, + "suggestions": { + "meeting-prep": { + "trigger_count": 3, + "first_trigger": "2026-04-01", + "last_trigger": "2026-04-19", + "trigger_data": {}, + "response": None, + "suggested": False, + } + }, + } + }, + ) + monkeypatch.setattr("think.routines.save_config", lambda config: None) + + result = _load_exec_context_module().pre_process({"day": "20260420"}) + + template_vars = _assert_template_vars_result(result) + assert "## Active Routines" in template_vars["active_routines"] + assert "Morning Briefing" in template_vars["active_routines"] + assert "## Routine Suggestion Eligible" in template_vars["routine_suggestion"] + assert "meeting-prep" in template_vars["routine_suggestion"] + assert ( + "sol call routines suggest-respond meeting-prep --accepted" + in template_vars["routine_suggestion"] + ) + + +def test_exec_pre_process_empty_state(monkeypatch, tmp_path): + journal = tmp_path / "journal" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) + _write_journal_config( + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} + ) + + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) + monkeypatch.setattr( + "think.routines.get_config", + lambda: {"_meta": {"suggestions_enabled": True, "suggestions": {}}}, + ) + monkeypatch.setattr("think.routines.save_config", lambda config: None) + + result = _load_exec_context_module().pre_process({"day": "20260420"}) + + template_vars = _assert_template_vars_result(result) + assert template_vars["active_routines"] == "" + assert template_vars["routine_suggestion"] == "" + + +def test_exec_pre_process_errors_swallowed(monkeypatch, tmp_path): + journal = tmp_path / "journal" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) + _write_journal_config( + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} + ) + + module = _load_exec_context_module() + + def _boom(*_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr("think.routines.get_routine_state", _boom) + monkeypatch.setattr( + "think.routines.get_config", + lambda: {"_meta": {"suggestions_enabled": True, "suggestions": {}}}, + ) + monkeypatch.setattr("think.routines.save_config", _boom) + + result = module.pre_process({"day": "20260420"}) + template_vars = _assert_template_vars_result(result) + assert template_vars["active_routines"] == "" + + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) + monkeypatch.setattr("think.routines.get_config", _boom) + + result = module.pre_process({"day": "20260420"}) + template_vars = _assert_template_vars_result(result) + assert template_vars["routine_suggestion"] == "" + + +def test_exec_pre_process_returned_dict_shape(monkeypatch, tmp_path): + journal = tmp_path / "journal" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) + + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) + monkeypatch.setattr( + "think.routines.get_config", + lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, + ) + monkeypatch.setattr("think.routines.save_config", lambda config: None) + + result = _load_exec_context_module().pre_process({"day": "20260420"}) + + assert set(result.keys()) == {"template_vars"} + assert set(result["template_vars"].keys()) == TEMPLATE_VAR_KEYS + + +def test_exec_pre_process_never_calls_save_config(monkeypatch, tmp_path): + journal = tmp_path / "journal" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) + _write_journal_config( + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} + ) + + def _fail_save(*_args, **_kwargs): + raise AssertionError("save_config should not be called") + + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) + monkeypatch.setattr( + "think.routines.get_config", + lambda: {"_meta": {"suggestions_enabled": True, "suggestions": {}}}, + ) + monkeypatch.setattr("think.routines.save_config", _fail_save) + + module = _load_exec_context_module() + owner_result = module.pre_process( + { + "prompt": "What is on my calendar today?", + "trigger_kind": "owner_message", + "trigger_payload": {"text": "What is on my calendar today?"}, + } + ) + talent_result = module.pre_process( + { + "trigger_kind": "talent_finished", + "trigger_payload": {"name": "exec", "summary": "Done."}, + } + ) + + _assert_template_vars_result(owner_result) + _assert_template_vars_result(talent_result) + + +def test_exec_and_chat_render_identical_routine_vars(monkeypatch, tmp_path): + journal = tmp_path / "journal" + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) + _write_journal_config( + journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} + ) + + routine_state = [ + { + "name": "Morning Briefing", + "cadence": "0 9 * * *", + "last_run": None, + "enabled": True, + "paused_until": None, + "output_summary": "Shared the top priorities.", + } + ] + routines_config = { + "_meta": { + "suggestions_enabled": True, + "suggestions": { + "meeting-prep": { + "trigger_count": 3, + "first_trigger": "2026-04-01", + "last_trigger": "2026-04-19", + "trigger_data": {}, + "response": None, + "suggested": False, + } + }, + } + } + + def _fail_save(*_args, **_kwargs): + raise AssertionError("save_config should not be called") + + monkeypatch.setattr("think.routines.get_routine_state", lambda: routine_state) + monkeypatch.setattr("think.routines.get_config", lambda: routines_config) + monkeypatch.setattr("think.routines.save_config", _fail_save) + + context = {"day": "20260420"} + chat_result = _load_chat_context_module().pre_process(context) + exec_result = _load_exec_context_module().pre_process(context) + + assert ( + chat_result["template_vars"]["active_routines"] + == exec_result["template_vars"]["active_routines"] + ) + assert ( + chat_result["template_vars"]["routine_suggestion"] + == exec_result["template_vars"]["routine_suggestion"] + ) diff --git a/tests/test_routines.py b/tests/test_routines.py index 991df620f..5c77350e3 100644 --- a/tests/test_routines.py +++ b/tests/test_routines.py @@ -3,6 +3,7 @@ """Tests for think.routines — user-defined routines engine.""" +import importlib import importlib.util from contextlib import contextmanager from datetime import date, datetime, timedelta, timezone @@ -31,6 +32,12 @@ def _load_chat_context_module(): return module +def _load_routine_context_module(): + """Load talent._routine_context from this worktree explicitly for tests.""" + module = importlib.import_module("talent._routine_context") + return importlib.reload(module) + + def _load_routines_cli_module(): """Load think.tools.routines from this worktree explicitly for tests.""" path = Path(__file__).resolve().parents[1] / "think" / "tools" / "routines.py" @@ -1041,7 +1048,7 @@ class TestEligibilityGates: def test_suggestions_disabled_blocks(self, journal_path): """Gate 1: suggestions_enabled=False blocks all suggestions.""" - module = _load_chat_context_module() + module = _load_routine_context_module() routines_config = { "_meta": { "suggestions_enabled": False, @@ -1058,11 +1065,11 @@ class TestEligibilityGates: } } journal_config = {"agent": {"name_status": "chosen"}} - assert module._get_eligible_suggestion(routines_config, journal_config) is None + assert module.get_eligible_suggestion(routines_config, journal_config) is None def test_naming_default_blocks(self, journal_path): """Gate 2: name_status='default' blocks all suggestions.""" - module = _load_chat_context_module() + module = _load_routine_context_module() routines_config = { "_meta": { "suggestions": { @@ -1078,11 +1085,11 @@ class TestEligibilityGates: } } journal_config = {"agent": {"name_status": "default"}} - assert module._get_eligible_suggestion(routines_config, journal_config) is None + assert module.get_eligible_suggestion(routines_config, journal_config) is None def test_active_routine_blocks(self, journal_path): """Gate 3: existing routine with same template blocks suggestion.""" - module = _load_chat_context_module() + module = _load_routine_context_module() routines_config = { "routine-1": { "id": "routine-1", @@ -1103,11 +1110,11 @@ class TestEligibilityGates: }, } journal_config = {"agent": {"name_status": "chosen"}} - assert module._get_eligible_suggestion(routines_config, journal_config) is None + assert module.get_eligible_suggestion(routines_config, journal_config) is None def test_declined_blocks(self, journal_path): """Gate 4: declined response blocks suggestion for that template.""" - module = _load_chat_context_module() + module = _load_routine_context_module() routines_config = { "_meta": { "suggestions": { @@ -1123,11 +1130,11 @@ class TestEligibilityGates: } } journal_config = {"agent": {"name_status": "chosen"}} - assert module._get_eligible_suggestion(routines_config, journal_config) is None + assert module.get_eligible_suggestion(routines_config, journal_config) is None def test_cooldown_blocks(self, journal_path): """Gate 5: suggestion within last 7 days blocks all.""" - module = _load_chat_context_module() + module = _load_routine_context_module() yesterday = (date.today() - timedelta(days=1)).isoformat() routines_config = { "_meta": { @@ -1145,11 +1152,11 @@ class TestEligibilityGates: } } journal_config = {"agent": {"name_status": "chosen"}} - assert module._get_eligible_suggestion(routines_config, journal_config) is None + assert module.get_eligible_suggestion(routines_config, journal_config) is None def test_all_gates_pass(self, journal_path): """When all gates pass and threshold met, returns suggestion.""" - module = _load_chat_context_module() + module = _load_routine_context_module() routines_config = { "_meta": { "suggestions": { @@ -1165,14 +1172,14 @@ class TestEligibilityGates: } } journal_config = {"agent": {"name_status": "chosen"}} - result = module._get_eligible_suggestion(routines_config, journal_config) + result = module.get_eligible_suggestion(routines_config, journal_config) assert result is not None assert result["template_name"] == "morning-briefing" assert result["trigger_count"] == 3 def test_below_threshold_no_suggestion(self, journal_path): """Trigger count below threshold does not produce a suggestion.""" - module = _load_chat_context_module() + module = _load_routine_context_module() routines_config = { "_meta": { "suggestions": { @@ -1188,11 +1195,11 @@ class TestEligibilityGates: } } journal_config = {"agent": {"name_status": "chosen"}} - assert module._get_eligible_suggestion(routines_config, journal_config) is None + assert module.get_eligible_suggestion(routines_config, journal_config) is None def test_highest_trigger_count_wins(self, journal_path): """When multiple templates eligible, highest trigger_count wins.""" - module = _load_chat_context_module() + module = _load_routine_context_module() routines_config = { "_meta": { "suggestions": { @@ -1216,13 +1223,13 @@ class TestEligibilityGates: } } journal_config = {"agent": {"name_status": "chosen"}} - result = module._get_eligible_suggestion(routines_config, journal_config) + result = module.get_eligible_suggestion(routines_config, journal_config) assert result["template_name"] == "meeting-prep" assert result["trigger_count"] == 5 def test_cooldown_expired_allows(self, journal_path): """Cooldown older than 7 days allows suggestions.""" - module = _load_chat_context_module() + module = _load_routine_context_module() old_date = (date.today() - timedelta(days=8)).isoformat() routines_config = { "_meta": { @@ -1240,7 +1247,7 @@ class TestEligibilityGates: } } journal_config = {"agent": {"name_status": "chosen"}} - result = module._get_eligible_suggestion(routines_config, journal_config) + result = module.get_eligible_suggestion(routines_config, journal_config) assert result is not None