feat(plugin): add /api/plugin/context push-channel endpoint + dogfood hook
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m6s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:43:08 -04:00
parent 8c1b19f49c
commit 3ab16fcbdb
5 changed files with 242 additions and 0 deletions
+38
View File
@@ -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
+2
View File
@@ -26,6 +26,7 @@ from scribe.routes.search import search_bp
from scribe.routes.profile import profile_bp from scribe.routes.profile import profile_bp
from scribe.routes.knowledge import knowledge_bp from scribe.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_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.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp from scribe.routes.dashboard import dashboard_bp
from scribe.mcp import mount_mcp from scribe.mcp import mount_mcp
@@ -87,6 +88,7 @@ def create_app() -> Quart:
app.register_blueprint(profile_bp) app.register_blueprint(profile_bp)
app.register_blueprint(knowledge_bp) app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp) app.register_blueprint(rulebooks_bp)
app.register_blueprint(plugin_bp)
app.register_blueprint(trash_bp) app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp) app.register_blueprint(dashboard_bp)
+32
View File
@@ -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_<key>` 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)
+101
View File
@@ -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}
+69
View File
@@ -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"]