feat(plugin): add /api/plugin/context push-channel endpoint + dogfood hook
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:
Executable
+38
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user