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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user