Files
FabledGrimoire/src/scribe/services/plugin_context.py
T
bvandeusenandClaude Opus 4.8 8fe571e175
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m0s
feat(plugin): resolve session project from git remote, not a pinned project_id
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>
2026-06-10 01:33:58 -04:00

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}