CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 25s
The only just-in-time rule surface was registered on `Write|Edit` and queried with `code or path`, so a rule could be retrieved at the moment of a code write and nowhere else. Every rule about which tool to reach for — don't curl the forge, don't stand up a stack, don't run the suite locally, don't branch — was unreachable exactly when it mattered, and residency in the always-on preload was the only surface it had. That is the pressure that grew the resident set to 31 against #3089's ceiling of ~23; it was never a judgment anybody made. A reflex generates no query, so an instruction to check the rules cannot catch one. A mechanical trigger can: the tool call IS the query, and a reflex has to become a tool call before it can do anything. `build_tool_rule_hint` is deliberately tool-agnostic — a name and a string — so widening the matcher later is a hooks.json edit with no server change. The hook starts on Bash, which is where the action reflexes live. The two pre-tool arms share ONE session ledger of already-named rules (`<state>/<sid>.rules.ids`). Two ledgers would mean a rule named by one arm gets re-offered by the other, and the hint that fires most often is exactly the one that must not repeat itself. A test asserts both scripts build the same path, and another checks the shell hook and the Python route agree on every query-arg name (rule 33) — a rename there fails silently, looking like a surface that never finds anything rather than a broken one. Deliberately silent on outage, unlike the prior-art hook: a write is occasional, a Bash call is not, and an outage line before every command is what gets a channel muted. `tier="conditional"` matches the write arm and is the transition point — an always-on rule is already resident, so re-tier one and it starts arriving here instead of in every session's preamble. `pre_tool_rule` joins RANKED_SOURCES: this arm chose what it showed, so a pull can settle whether the choice landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
279 lines
13 KiB
Python
279 lines
13 KiB
Python
"""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 admin_required, get_current_user_id, login_required
|
|
from scribe.config import Config
|
|
from scribe.services import plugin_context as plugin_ctx_svc
|
|
from scribe.services import repo_bindings as repo_bindings_svc
|
|
from scribe.services.settings import get_admin_setting, set_setting
|
|
|
|
plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
|
|
|
|
# Setting key for the git URL of the marketplace that serves this plugin (the
|
|
# Scribe app's own repo). Instance-global (stored on an admin account) so every
|
|
# user's Settings page shows the real, copyable install command.
|
|
_MARKETPLACE_KEY = "plugin_marketplace_url"
|
|
|
|
|
|
def _int_list(raw: str | None) -> list[int]:
|
|
"""A comma-separated id list from the query string; non-ints dropped."""
|
|
return [int(p) for p in (raw or "").split(",") if p.strip().isdigit()]
|
|
|
|
|
|
async def _project_scope() -> tuple[int, str, str]:
|
|
"""(project_id, repo, unbound_repo) from the request's `project_id` /
|
|
`repo` query args — the one resolution every plugin endpoint shares.
|
|
|
|
An explicit project_id wins; otherwise the repo remote is resolved
|
|
through the caller's bindings, and a remote nobody bound comes back as
|
|
`unbound_repo` (normalised) so /context can say "bind this repo".
|
|
"""
|
|
try:
|
|
project_id = int(request.args.get("project_id", 0) or 0)
|
|
except (TypeError, ValueError):
|
|
project_id = 0
|
|
repo = (request.args.get("repo") or "").strip()
|
|
unbound_repo = ""
|
|
if repo and not project_id:
|
|
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
|
|
if resolved:
|
|
project_id = resolved
|
|
else:
|
|
unbound_repo = repo_bindings_svc.normalize_repo_key(repo)
|
|
return project_id, repo, unbound_repo
|
|
|
|
|
|
|
|
@plugin_bp.get("/context")
|
|
@login_required
|
|
async def session_context():
|
|
"""Return SessionStart context (always-on rules + active project).
|
|
|
|
Query:
|
|
repo (optional str) — the working repo's git remote. The server
|
|
resolves it to the bound project (see services/repo_bindings); an
|
|
unbound repo yields a "bind this repo" hint instead. This is how the
|
|
active project is determined — the plugin never pins a project id.
|
|
project_id (optional int) — explicit override, mainly for manual/ad-hoc
|
|
curl testing; takes precedence over `repo` when set.
|
|
"""
|
|
project_id, _repo, unbound_repo = await _project_scope()
|
|
result = await plugin_ctx_svc.build_session_context(
|
|
g.user.id, project_id, unbound_repo=unbound_repo
|
|
)
|
|
return jsonify(result)
|
|
|
|
|
|
@plugin_bp.get("/retrieve")
|
|
@login_required
|
|
async def autoinject_retrieve():
|
|
"""Title-first knowledge auto-inject for the plugin's UserPromptSubmit hook.
|
|
|
|
Given the user's prompt (`q`), returns a compact awareness hint — note
|
|
titles + scores only, never bodies — for the top hits that clear the
|
|
per-user gates (see services.plugin_context.build_autoinject_hint). Returns
|
|
empty context (most of the time) when disabled or nothing is relevant.
|
|
|
|
Query:
|
|
q (str) — the user's prompt to retrieve against.
|
|
repo (optional) — working repo remote; resolved to the bound project
|
|
to scope the search (mirrors /context). Unbound or
|
|
absent → searches all the user's notes.
|
|
project_id (opt) — explicit project scope override (ad-hoc/testing).
|
|
exclude_ids (opt) — comma-separated note ids already injected this
|
|
session; skipped so each note injects at most once.
|
|
"""
|
|
q = (request.args.get("q") or "").strip()
|
|
project_id, _repo, _unbound = await _project_scope()
|
|
exclude_ids = _int_list(request.args.get("exclude_ids"))
|
|
result = await plugin_ctx_svc.build_autoinject_hint(
|
|
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
|
|
)
|
|
return jsonify(result)
|
|
|
|
|
|
@plugin_bp.get("/tool-rules")
|
|
@login_required
|
|
async def pre_tool_rules():
|
|
"""Standing rules for the plugin's PreToolUse hook on ACTIONS (#3476).
|
|
|
|
Answers "does a recorded rule speak to the command about to be run?" — the
|
|
sibling of /prior-art, which can only answer that question about a code
|
|
write. Rules about which tool to reach for (don't curl the forge, don't
|
|
stand up a stack, don't run the suite locally) had no retrieval surface at
|
|
all before this, which is why they all had to live in the resident preload.
|
|
|
|
Titles + trigger only, never the statement: the hint says a rule may apply
|
|
and hands over `get_rule(id)`. One rule at most (RULEHINT_LIMIT), and empty
|
|
most of the time.
|
|
|
|
Query:
|
|
tool (str) — the tool about to run, e.g. `Bash`. Used in
|
|
the hint's wording, not in the search: a
|
|
rule is about the action, not the harness.
|
|
command (str) — the command about to run; the semantic query.
|
|
Absent or blank → empty, no search.
|
|
repo (optional) — working repo remote, resolved to the bound
|
|
project exactly as /retrieve and /prior-art.
|
|
exclude_rule_ids (opt) — comma-separated rule ids already surfaced
|
|
this session. SHARED with /prior-art's
|
|
ledger on purpose: one session keeps one
|
|
list, so a rule named by either arm is not
|
|
re-offered by the other.
|
|
"""
|
|
tool = (request.args.get("tool") or "tool").strip()
|
|
command = request.args.get("command") or ""
|
|
project_id, _repo, _unbound = await _project_scope()
|
|
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
|
result = await plugin_ctx_svc.build_tool_rule_hint(
|
|
g.user.id, tool, command,
|
|
project_id=project_id, exclude_rule_ids=exclude_rule_ids,
|
|
)
|
|
return jsonify(result)
|
|
|
|
|
|
@plugin_bp.get("/prior-art")
|
|
@login_required
|
|
async def write_path_prior_art():
|
|
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
|
|
|
Answers "what is already recorded for the file about to be written?" — by
|
|
place (a snippet at this path or in its directory) and by meaning (snippets
|
|
resembling the code about to be written). Titles only, gated, and empty most
|
|
of the time. See services.plugin_context.build_write_path_hint.
|
|
|
|
Query:
|
|
path (str) — the target file, REPO-RELATIVE, matching the
|
|
convention snippet locations are recorded in. An
|
|
absolute path simply won't match anything.
|
|
code (optional) — the code about to be written; the semantic query.
|
|
Omit it and only the location arm runs.
|
|
repo (optional) — working repo remote; resolved to the bound project
|
|
to scope the search, exactly as /retrieve does. It
|
|
is NOT used as the location `repo` filter — see the
|
|
service docstring for why those two differ.
|
|
project_id (opt) — explicit project scope override (ad-hoc/testing).
|
|
exclude_ids (opt) — comma-separated REUSE-class ids already surfaced
|
|
this session.
|
|
exclude_sync_ids (opt) — comma-separated SYNC-class ids (snippets
|
|
recorded AT the edited file, #2708) already
|
|
surfaced. A separate channel on purpose: a reuse
|
|
hint shown early must not suppress the record-sync
|
|
nudge when the recorded file is edited later.
|
|
exclude_rule_ids (opt) — comma-separated RULE ids already surfaced
|
|
this session. Its own channel like the three
|
|
above, and for the same reason: a rule named
|
|
twenty turns ago should not be re-offered on
|
|
every subsequent write.
|
|
exclude_derive (opt) — comma-separated derive keys (a derive group id
|
|
or `canon:<snippet_id>`) already named this
|
|
session by the ledger arm (#2900); its own
|
|
channel, like the two above.
|
|
rules_etag (opt) — the marker the session was given when it loaded
|
|
its always-on rules (milestone 323). Sent back
|
|
so the server can say whether those rules have
|
|
MOVED since. Absent means the hook has nothing
|
|
stored, which is silence, not a mismatch.
|
|
shapes (opt) — comma-separated `kind:name` definitions the hook
|
|
found in (or enclosing) the payload, kind being
|
|
css|sym. The shape ledger's write-path feed
|
|
(#2791): when the session recently PULLED a
|
|
snippet this payload references or resembles,
|
|
these land as instance rows (classified_by=hook).
|
|
Honoured only for a caller allowed to write — a
|
|
read-scoped key still gets the hint, and never
|
|
changes accounting on a GET.
|
|
"""
|
|
path = (request.args.get("path") or "").strip()
|
|
code = request.args.get("code") or ""
|
|
project_id, repo, _unbound = await _project_scope()
|
|
exclude_ids = _int_list(request.args.get("exclude_ids"))
|
|
exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids"))
|
|
exclude_derive = [
|
|
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
|
|
]
|
|
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
|
rules_etag = (request.args.get("rules_etag") or "").strip()
|
|
shapes = _parse_shapes(request.args.get("shapes") or "")
|
|
api_key = getattr(g, "api_key", None)
|
|
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
|
|
|
result = await plugin_ctx_svc.build_write_path_hint(
|
|
g.user.id, path, code=code, project_id=project_id,
|
|
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
|
|
stamp_shapes=shapes if may_stamp else None,
|
|
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
|
exclude_derive=exclude_derive,
|
|
exclude_rule_ids=exclude_rule_ids,
|
|
rules_etag=rules_etag,
|
|
)
|
|
return jsonify(result)
|
|
|
|
|
|
# The hook names at most a dozen definitions per write; anything past that is
|
|
# a generated file, not a shape being instantiated.
|
|
_SHAPES_CAP = 12
|
|
|
|
|
|
def _parse_shapes(raw: str) -> list[tuple[str, str]]:
|
|
"""`css:btn-primary,sym:onTrash` → [("css", "btn-primary"), ("sym", "onTrash")].
|
|
Unknown kinds and empty names are dropped, duplicates collapse, and the
|
|
list is capped — the hook's own cap, re-applied so the contract holds
|
|
for any caller."""
|
|
out: list[tuple[str, str]] = []
|
|
for part in raw.split(","):
|
|
kind, _sep, name = part.strip().partition(":")
|
|
kind, name = kind.strip(), name.strip()
|
|
if kind in ("css", "sym") and name and (kind, name) not in out:
|
|
out.append((kind, name))
|
|
if len(out) >= _SHAPES_CAP:
|
|
break
|
|
return out
|
|
|
|
|
|
@plugin_bp.get("/processes")
|
|
@login_required
|
|
async def process_manifest():
|
|
"""Stored Processes as skill-stub specs for the plugin's sync script.
|
|
|
|
The plugin's `scribe_sync_processes.sh` (run at SessionStart and via the
|
|
`/scribe:sync` command) curls this and writes one auto-surfacing local skill
|
|
per Process into ~/.claude/skills/. See services/plugin_context.
|
|
build_process_manifest. A read-scoped API key suffices.
|
|
"""
|
|
result = await plugin_ctx_svc.build_process_manifest(g.user.id)
|
|
return jsonify(result)
|
|
|
|
|
|
@plugin_bp.get("/marketplace-url")
|
|
@login_required
|
|
async def get_marketplace_url():
|
|
"""The plugin marketplace git URL, readable by any logged-in user so their
|
|
Settings page can show a copyable install command. Falls back to the
|
|
config default (this deployment's own repo) when no admin override is set."""
|
|
url = await get_admin_setting(_MARKETPLACE_KEY, default=Config.PLUGIN_MARKETPLACE_URL)
|
|
return jsonify({"marketplace_url": url})
|
|
|
|
|
|
@plugin_bp.put("/marketplace-url")
|
|
@admin_required
|
|
async def set_marketplace_url():
|
|
"""Admin-set the marketplace git URL (e.g. this app's own repo)."""
|
|
data = await request.get_json() or {}
|
|
url = (data.get("marketplace_url") or "").strip()
|
|
if url:
|
|
scheme = url.split("://")[0].lower() if "://" in url else ""
|
|
if scheme not in ("http", "https"):
|
|
return jsonify({"error": "Marketplace URL must use http or https"}), 400
|
|
await set_setting(get_current_user_id(), _MARKETPLACE_KEY, url)
|
|
return jsonify({"status": "ok"})
|