From 3ab16fcbdbccb956c911b7a2129beca08d735876 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 9 Jun 2026 22:43:08 -0400 Subject: [PATCH] feat(plugin): add /api/plugin/context push-channel endpoint + dogfood hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of plan #755 (Scribe-as-plugin). Gives Scribe its own session-start push channel so always-on rules + active-project context surface without being asked — the gap behind 'I have to prompt for everything'. - services/plugin_context.build_session_context: renders always-on rule titles grouped by topic (under the 10k additionalContext cap; full text stays one list_always_on_rules/get_rule call away) + optional project goal/open-task count + a recall/update-over-create reflex line. Capped at 9000 chars. - routes/plugin GET /api/plugin/context (login_required already accepts Bearer fmcp_ keys; read scope suffices). - tests: titles-not-statements, project scoping, length cap (pure mocks). - scripts/scribe_session_context.sh: dogfood SessionStart hook, fail-open, reads url+token from .mcp.json. Superseded in Phase 2 by the plugin-bundled hook using userConfig. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/scribe_session_context.sh | 38 ++++++++++ src/scribe/app.py | 2 + src/scribe/routes/plugin.py | 32 ++++++++ src/scribe/services/plugin_context.py | 101 ++++++++++++++++++++++++++ tests/test_services_plugin_context.py | 69 ++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100755 scripts/scribe_session_context.sh create mode 100644 src/scribe/routes/plugin.py create mode 100644 src/scribe/services/plugin_context.py create mode 100644 tests/test_services_plugin_context.py diff --git a/scripts/scribe_session_context.sh b/scripts/scribe_session_context.sh new file mode 100755 index 0000000..ecde6c6 --- /dev/null +++ b/scripts/scribe_session_context.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Scribe SessionStart hook (dogfood / pre-plugin). +# +# Curls the running Scribe dev instance for the operator's always-on rules + +# active-project context and emits it as SessionStart `additionalContext` — the +# push channel that lets Scribe surface reflexively (plan #755, Phase 1). +# +# FAIL-OPEN by design: any missing tool, missing config, network error, or +# unreachable instance results in injecting nothing and exiting 0. A session +# must NEVER be blocked because Scribe is down. Reads URL+token from the +# project's .mcp.json so no secret lives in this tracked script. +# +# Superseded in Phase 2 by the same hook bundled in the Scribe plugin, where +# URL+token come from plugin userConfig instead of .mcp.json. +set -uo pipefail + +MCP_JSON="${CLAUDE_PROJECT_DIR:-.}/.mcp.json" +command -v jq >/dev/null 2>&1 || exit 0 +command -v curl >/dev/null 2>&1 || exit 0 +[ -f "$MCP_JSON" ] || exit 0 + +# scribe-dev is the locally-wired instance (prod id 2 / dev id 3 per binding). +url=$(jq -r '.mcpServers["scribe-dev"].url // empty' "$MCP_JSON" 2>/dev/null) || exit 0 +auth=$(jq -r '.mcpServers["scribe-dev"].headers.Authorization // empty' "$MCP_JSON" 2>/dev/null) || exit 0 +token=${auth#Bearer } +[ -n "$url" ] && [ -n "$token" ] || exit 0 + +base=${url%/mcp} +body=$(curl -fsS --max-time 8 \ + -H "Authorization: Bearer ${token}" \ + "${base}/api/plugin/context?project_id=3" 2>/dev/null) || exit 0 + +text=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0 +[ -n "$text" ] || exit 0 + +jq -n --arg c "$text" \ + '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}' +exit 0 diff --git a/src/scribe/app.py b/src/scribe/app.py index e0b5222..caa1d85 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -26,6 +26,7 @@ from scribe.routes.search import search_bp from scribe.routes.profile import profile_bp from scribe.routes.knowledge import knowledge_bp from scribe.routes.rulebooks import rulebooks_bp +from scribe.routes.plugin import plugin_bp from scribe.routes.trash import trash_bp from scribe.routes.dashboard import dashboard_bp from scribe.mcp import mount_mcp @@ -87,6 +88,7 @@ def create_app() -> Quart: app.register_blueprint(profile_bp) app.register_blueprint(knowledge_bp) app.register_blueprint(rulebooks_bp) + app.register_blueprint(plugin_bp) app.register_blueprint(trash_bp) app.register_blueprint(dashboard_bp) diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py new file mode 100644 index 0000000..f6c1af0 --- /dev/null +++ b/src/scribe/routes/plugin.py @@ -0,0 +1,32 @@ +"""Scribe-plugin support endpoints. + +Consumed by the Scribe Claude Code plugin (not the web UI). `GET /api/plugin/ +context` is curled by the plugin's SessionStart hook to push the operator's +always-on rules + active-project context into the session — Scribe's own push +channel. Auth is the standard `@login_required` path, which accepts a `Bearer +fmcp_` API key (a read-scoped key suffices for this GET). +""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from scribe.auth import login_required +from scribe.services import plugin_context as plugin_ctx_svc + +plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin") + + +@plugin_bp.get("/context") +@login_required +async def session_context(): + """Return SessionStart context (always-on rules + optional project). + + Query: project_id (optional int) — when set, includes that project's goal + and open-task count. The hook passes the bound project's id here. + """ + try: + project_id = int(request.args.get("project_id", 0) or 0) + except (TypeError, ValueError): + project_id = 0 + result = await plugin_ctx_svc.build_session_context(g.user.id, project_id) + return jsonify(result) diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py new file mode 100644 index 0000000..09c7560 --- /dev/null +++ b/src/scribe/services/plugin_context.py @@ -0,0 +1,101 @@ +"""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) -> dict: + """Render the SessionStart context for a user, optionally project-scoped. + + 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}", + ] + + 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} diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py new file mode 100644 index 0000000..b8a809a --- /dev/null +++ b/tests/test_services_plugin_context.py @@ -0,0 +1,69 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def _rule(rid, title, topic_id): + r = MagicMock() + r.id, r.title, r.topic_id = rid, title, topic_id + r.statement = "FULL STATEMENT SHOULD NOT BE INJECTED" + return r + + +@pytest.mark.asyncio +async def test_build_session_context_renders_titles_grouped_by_topic(): + rules = [ + _rule(1, "`dev` is home", 1), + _rule(2, "Release — never without explicit request", 1), + _rule(3, "No GitHub — Fabled-Git only", 2), + ] + with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules", + AsyncMock(return_value=rules)), \ + patch("scribe.services.plugin_context._topic_titles", + AsyncMock(return_value={1: "git-workflow", 2: "fabled-git"})): + from scribe.services.plugin_context import build_session_context + out = await build_session_context(user_id=7, project_id=0) + + ctx = out["context"] + assert out["rule_count"] == 3 + assert out["project"] is None + # Titles present, grouped under topic headings + assert "### git-workflow" in ctx + assert "### fabled-git" in ctx + assert "- [1] `dev` is home" in ctx + assert "- [3] No GitHub — Fabled-Git only" in ctx + # Full statements must NOT be dumped (push channel injects titles only) + assert "FULL STATEMENT SHOULD NOT BE INJECTED" not in ctx + + +@pytest.mark.asyncio +async def test_build_session_context_includes_project_when_scoped(): + project = MagicMock(id=2, title="FabledScribe", goal="ship it") + with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules", + AsyncMock(return_value=[_rule(1, "rule", 1)])), \ + patch("scribe.services.plugin_context._topic_titles", + AsyncMock(return_value={1: "git-workflow"})), \ + patch("scribe.services.plugin_context.projects_svc.get_project", + AsyncMock(return_value=project)), \ + patch("scribe.services.plugin_context.notes_svc.list_notes", + AsyncMock(return_value=([], 4))): + from scribe.services.plugin_context import build_session_context + out = await build_session_context(user_id=7, project_id=2) + + assert out["project"] == {"id": 2, "title": "FabledScribe"} + assert "## Active project: FabledScribe (id 2)" in out["context"] + assert "Open todo tasks: 4" in out["context"] + + +@pytest.mark.asyncio +async def test_build_session_context_caps_length(): + many = [_rule(i, "x" * 200, 1) for i in range(200)] + with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules", + AsyncMock(return_value=many)), \ + patch("scribe.services.plugin_context._topic_titles", + AsyncMock(return_value={1: "git-workflow"})): + from scribe.services.plugin_context import build_session_context + out = await build_session_context(user_id=7) + + assert len(out["context"]) <= 9000 + 60 # cap + truncation note + assert "truncated" in out["context"]