The SessionStart hook asked for a project_id via plugin userConfig, which pins one install to a single project — wrong for an operator working across many repos/projects. Resolve the active project server-side from the working repo's git remote instead (a stable identifier, not a dir-name guess). - repo_bindings table (migration 0064) + RepoBinding model: (user, repo_key) -> project, FKs CASCADE. - services/repo_bindings: normalize_repo_key collapses ssh/https/scp/creds/port/ .git to host/owner/repo; resolve/set/list/delete. - GET /api/plugin/context takes ?repo=<remote>; unbound repo -> a "bind this repo" hint with a ready bind_repo() call. project_id kept as manual override. - MCP tools: bind_repo / list_repo_bindings / unbind_repo. - Hook sends ?repo=$(git remote get-url origin) URL-encoded; all project_id handling removed. plugin.json drops the project_id userConfig (0.1.2 -> 0.1.3). - Tests: normalize equivalence classes + unbound-hint rendering. Refs task 755 (Scribe-as-plugin push channel). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
5.2 KiB
Python
122 lines
5.2 KiB
Python
"""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=<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}
|