---
phase: 20-session-recall
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/services/recall.py
  - src/ztlctl/controllers/recall.py
  - tests/services/test_recall.py
  - tests/controllers/test_recall.py
autonomous: true
requirements:
  - RECL-01
  - RECL-02
  - RECL-05

must_haves:
  truths:
    - "RecallService.recall_temporal returns sessions filtered by date range with per-session summaries"
    - "RecallService.recall_topic returns sessions matching a text query via BM25 on session_logs.summary"
    - "RecallController delegates to RecallService through _run_action pattern"
  artifacts:
    - path: "src/ztlctl/services/recall.py"
      provides: "RecallService with recall_temporal and recall_topic methods"
      exports: ["RecallService"]
    - path: "src/ztlctl/controllers/recall.py"
      provides: "RecallController wrapping RecallService"
      exports: ["RecallController"]
    - path: "tests/services/test_recall.py"
      provides: "Service-level tests for temporal and topic recall"
    - path: "tests/controllers/test_recall.py"
      provides: "Controller-level tests for recall actions"
  key_links:
    - from: "src/ztlctl/services/recall.py"
      to: "session_logs table + nodes table"
      via: "SQLAlchemy select queries"
      pattern: "select.*session_logs|select.*nodes.*type.*log"
    - from: "src/ztlctl/controllers/recall.py"
      to: "src/ztlctl/services/recall.py"
      via: "lazy import RecallService inside each method"
      pattern: "from ztlctl.services.recall import RecallService"
---

<objective>
Create RecallService and RecallController with temporal and topic recall methods.

Purpose: Establish the core recall infrastructure (RECL-01, RECL-02, partial RECL-05) so users and agents can query session history by date range and by topic.
Output: RecallService with `recall_temporal` and `recall_topic`, RecallController wrapping them via `_run_action`.
</objective>

<execution_context>
@~/.claude/get-shit-done/workflows/execute-plan.md
@~/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/20-session-recall/20-CONTEXT.md

<interfaces>
<!-- Key types and contracts the executor needs. -->

From src/ztlctl/services/base.py:
```python
class BaseService:
    def __init__(self, vault: Vault) -> None:
        self._vault = vault
    def _dispatch_event(self, hook_name: str, payload: dict[str, Any], warnings: list[str], *, session_id: str | None = None) -> int | None: ...
```

From src/ztlctl/controllers/base.py:
```python
class BaseController:
    def __init__(self, vault: Vault) -> None:
        self._vault = vault
    def _run_action(self, action_name: str, kwargs: dict[str, Any], invoke: Callable) -> ServiceResult: ...
```

From src/ztlctl/services/result.py:
```python
class ServiceResult(BaseModel):
    ok: bool
    op: str
    data: dict[str, Any] = {}
    error: ServiceError | None = None
    warnings: list[str] = []
```

From src/ztlctl/infrastructure/database/schema.py:
```python
nodes = Table("nodes", metadata,
    Column("id", Text, primary_key=True),
    Column("title", Text, nullable=False),
    Column("type", Text, nullable=False),  # "log" for sessions
    Column("status", Text, nullable=False),  # "open" or "closed"
    Column("topic", Text),
    Column("session", Text),  # LOG-NNNN
    Column("created", Text, nullable=False),
    Column("created_at", Text),  # high-resolution timestamp
    ...
)

session_logs = Table("session_logs", metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("session_id", Text, nullable=False),
    Column("timestamp", Text, nullable=False),
    Column("type", Text, nullable=False),
    Column("subtype", Text),
    Column("summary", Text, nullable=False),
    Column("detail", Text),
    Column("cost", Integer),
    Column("pinned", Integer),
    Column("references", Text),  # JSON array
    Column("metadata", Text),  # JSON object
)
```

From src/ztlctl/services/session.py (pattern reference):
```python
class SessionService(BaseService):
    @staticmethod
    def _find_active_session(conn: Any) -> Any: ...
    @traced
    def start(self, topic: str) -> ServiceResult: ...
```

From src/ztlctl/controllers/session.py (pattern reference):
```python
class SessionController(BaseController):
    def start(self, topic: str) -> ServiceResult:
        from ztlctl.services.session import SessionService
        kwargs: dict[str, Any] = {"topic": topic}
        def _invoke(kw: dict[str, Any]) -> ServiceResult:
            return SessionService(self._vault).start(kw["topic"])
        return self._run_action("start", kwargs, _invoke)
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: RecallService with temporal and topic recall</name>
  <files>src/ztlctl/services/recall.py, tests/services/test_recall.py</files>
  <read_first>
    src/ztlctl/services/session.py
    src/ztlctl/services/base.py
    src/ztlctl/services/result.py
    src/ztlctl/infrastructure/database/schema.py
    tests/services/test_session.py
  </read_first>
  <behavior>
    - recall_temporal with no date filters returns all sessions (type='log' in nodes) with per-session summaries (topic, log_entry_count, note_ids created in that session, start/end timestamps)
    - recall_temporal with from_date filters sessions created on or after that ISO date
    - recall_temporal with to_date filters sessions created on or before that ISO date
    - recall_temporal with both from_date and to_date returns sessions in range
    - recall_temporal returns empty list when no sessions exist (ok=True, data.sessions=[])
    - recall_topic with a query string searches session_logs.summary using SQL LIKE and returns matching sessions with the matched log entries
    - recall_topic returns empty results for non-matching query (ok=True, data.sessions=[])
    - recall_topic with empty query returns error (ok=False, code=EMPTY_QUERY)
  </behavior>
  <action>
    Create src/ztlctl/services/recall.py with RecallService(BaseService):

    1. `recall_temporal(self, *, from_date: str | None = None, to_date: str | None = None) -> ServiceResult`:
       - Query nodes table WHERE type='log', optionally filter by created >= from_date and/or created <= to_date
       - For each session: count log entries from session_logs, collect note IDs from nodes WHERE session=session_id AND type IN ('note','reference','task'), extract topic from nodes.topic
       - Return ServiceResult(ok=True, op="recall_temporal", data={"sessions": [...], "count": N})
       - Each session dict: {session_id, topic, status, started (created_at or created), ended (modified_at or modified for closed), log_entry_count, note_ids}

    2. `recall_topic(self, query: str) -> ServiceResult`:
       - Validate query not empty (return EMPTY_QUERY error)
       - Search session_logs.summary using SQL LIKE '%{query}%' (case-insensitive via SQLite COLLATE NOCASE or func.lower)
       - Group matching log entries by session_id
       - For each matching session: fetch session node (nodes WHERE id=session_id), include matched log summaries
       - Return ServiceResult(ok=True, op="recall_topic", data={"sessions": [...], "count": N, "query": query})
       - Each session dict: {session_id, topic, status, matched_entries: [{summary, timestamp, type}]}

    Use @traced decorator on both methods. Use self._vault.engine.connect() for read-only queries (not transaction).

    Create tests/services/test_recall.py following test_session.py patterns:
    - Use vault fixture (from conftest)
    - Create test sessions via SessionService.start() + log_entry() to populate data
    - Close sessions via SessionService.close() where needed
    - Test all behaviors listed above
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/services/test_recall.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "class RecallService" src/ztlctl/services/recall.py
    - grep -q "def recall_temporal" src/ztlctl/services/recall.py
    - grep -q "def recall_topic" src/ztlctl/services/recall.py
    - grep -q "@traced" src/ztlctl/services/recall.py
    - grep -q "test_recall_temporal" tests/services/test_recall.py
    - grep -q "test_recall_topic" tests/services/test_recall.py
  </acceptance_criteria>
  <done>RecallService has recall_temporal and recall_topic methods. All tests pass. Temporal filtering by date range works. Topic search via LIKE on session_logs.summary works.</done>
</task>

<task type="auto">
  <name>Task 2: RecallController wrapping RecallService</name>
  <files>src/ztlctl/controllers/recall.py, tests/controllers/test_recall.py</files>
  <read_first>
    src/ztlctl/controllers/session.py
    src/ztlctl/controllers/base.py
    src/ztlctl/services/recall.py
    tests/controllers/test_recall.py
  </read_first>
  <action>
    Create src/ztlctl/controllers/recall.py with RecallController(BaseController):

    1. `recall_temporal(self, *, from_date: str | None = None, to_date: str | None = None) -> ServiceResult`:
       - Lazy import RecallService
       - Build kwargs dict, define _invoke that calls RecallService(self._vault).recall_temporal(**kw)
       - Return self._run_action("recall_temporal", kwargs, _invoke)

    2. `recall_topic(self, query: str) -> ServiceResult`:
       - Lazy import RecallService
       - Build kwargs dict with query, define _invoke
       - Return self._run_action("recall_topic", kwargs, _invoke)

    3. `recall_topology(self, *, limit: int = 10) -> ServiceResult`:
       - Placeholder for Plan 02 — lazy import RecallService
       - Build kwargs dict with limit, define _invoke
       - Return self._run_action("recall_topology", kwargs, _invoke)

    Follow exact SessionController pattern: lazy imports inside each method, kwargs dict, _invoke closure, _run_action delegation.

    Create tests/controllers/test_recall.py with basic smoke tests:
    - Create a vault, start a session, add log entries, close session
    - Call RecallController(vault).recall_temporal() and assert ok=True
    - Call RecallController(vault).recall_topic(query="...") with a matching query and assert ok=True with results
  </action>
  <verify>
    <automated>cd /Users/shparki/Documents/Workspace/thatdev/ztlctl && uv run pytest tests/controllers/test_recall.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "class RecallController" src/ztlctl/controllers/recall.py
    - grep -q "def recall_temporal" src/ztlctl/controllers/recall.py
    - grep -q "def recall_topic" src/ztlctl/controllers/recall.py
    - grep -q "def recall_topology" src/ztlctl/controllers/recall.py
    - grep -q "_run_action" src/ztlctl/controllers/recall.py
    - grep -q "test_recall" tests/controllers/test_recall.py
  </acceptance_criteria>
  <done>RecallController delegates all three recall methods through _run_action. Controller tests pass. recall_topology is stubbed for Plan 02.</done>
</task>

</tasks>

<verification>
```bash
cd /Users/shparki/Documents/Workspace/thatdev/ztlctl
uv run pytest tests/services/test_recall.py tests/controllers/test_recall.py -x -v
uv run ruff check src/ztlctl/services/recall.py src/ztlctl/controllers/recall.py
uv run mypy src/ztlctl/services/recall.py src/ztlctl/controllers/recall.py
```
</verification>

<success_criteria>
- RecallService exists with recall_temporal and recall_topic methods
- RecallController exists wrapping RecallService via _run_action
- Temporal recall returns sessions filtered by date range with summaries
- Topic recall returns sessions matching text query on session_logs.summary
- All tests pass, ruff clean, mypy clean
</success_criteria>

<output>
After completion, create `.planning/phases/20-session-recall/20-01-SUMMARY.md`
</output>
