"""Session-context rendering for the Scribe plugin's SessionStart hook. The plugin's hook curls `GET /api/plugin/context` at session start and injects the returned text as `additionalContext`, giving Scribe the same push channel that superpowers and file-memory have. This module renders that text. Design note — altitude: we inject rule *titles* grouped by topic (a compact index), NOT every rule's full statement. The 48 always-on statements run well past the 10k-char `additionalContext` cap, and the push channel's job is to make Claude *aware* the rules exist and *reach* for them — not to dump them. Full text stays one `get_rule(id)` / `list_always_on_rules()` call away. Titles are mostly self-describing ("`dev` is home", "No GitHub — Fabled-Git only"), so the index alone already steers behavior. """ from __future__ import annotations from sqlalchemy import select from scribe.models import async_session from scribe.models.rulebook import RulebookTopic from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_svc # Defensive cap below Claude Code's 10k additionalContext limit. _MAX_CHARS = 9000 async def _topic_titles(topic_ids: set[int]) -> dict[int, str]: """Map topic_id -> title for the given ids (live topics only).""" if not topic_ids: return {} async with async_session() as session: rows = await session.execute( select(RulebookTopic.id, RulebookTopic.title).where( RulebookTopic.id.in_(topic_ids), RulebookTopic.deleted_at.is_(None), ) ) return {tid: title for tid, title in rows.all()} async def build_session_context( user_id: int, project_id: int = 0, unbound_repo: str = "" ) -> dict: """Render the SessionStart context for a user, optionally project-scoped. Args: user_id: the operator. project_id: the resolved active project (0 = none). The endpoint resolves this from the working repo's remote, not from config. unbound_repo: when the hook sent a repo remote that maps to no project, its normalized key — triggers a one-line "bind this repo" hint so the binding is self-healing. Returns {"context": str, "rule_count": int, "project": dict | None}. `context` is markdown ready to drop into `additionalContext`; it is capped at _MAX_CHARS with an explicit truncation note so the hook can pass it through verbatim. """ rules = await rulebooks_svc.list_always_on_rules(user_id) topic_map = await _topic_titles({r.topic_id for r in rules if r.topic_id}) lines: list[str] = [ "# Scribe — standing session context (auto-injected by the Scribe plugin)", "", "You are working with Scribe, the operator's self-hosted second brain. " "The always-on rules below are BINDING this session. Titles only — full " "text via `list_always_on_rules()` or `get_rule(id)`.", "", "## Always-on rules (by topic)", ] # rules already arrive ordered by rulebook/topic/order, so grouping by # consecutive topic_id preserves the intended sequence. current_topic: int | None = object() # sentinel distinct from any id/None for r in rules: if r.topic_id != current_topic: current_topic = r.topic_id heading = topic_map.get(r.topic_id, "ungrouped") if r.topic_id else "ungrouped" lines.append(f"### {heading}") lines.append(f"- [{r.id}] {r.title}") project_dict: dict | None = None if project_id: project = await projects_svc.get_project(user_id, project_id) if project is not None: _, open_count = await notes_svc.list_notes( user_id, is_task=True, status="todo", project_id=project_id, limit=1, ) goal = (getattr(project, "goal", "") or "").strip() project_dict = {"id": project.id, "title": project.title} lines += [ "", f"## Active project: {project.title} (id {project.id})", f"Goal: {goal[:200]}" if goal else "", f"Open todo tasks: {open_count}", ] elif unbound_repo: lines += [ "", "## Repository not yet bound", f"This repo (`{unbound_repo}`) isn't mapped to a Scribe project, so " "no project context was loaded. Bind it once with " f'`bind_repo(repo_url="{unbound_repo}", project_id=)` ' "(call `list_projects` to find the id) and future sessions here will " "auto-load that project's context.", ] lines += [ "", "Reflex: search Scribe (search / list_tasks / list_notes, scoped to the " "active project) before answering or starting work; prefer UPDATING an " "existing note/rule over creating a new one.", ] context = "\n".join(line for line in lines if line is not None) if len(context) > _MAX_CHARS: context = context[:_MAX_CHARS].rstrip() + "\n\n…(truncated — call list_always_on_rules())" return {"context": context, "rule_count": len(rules), "project": project_dict}