feat(500): reply shapes are delivered - the core every turn through the ledger, each slice at its moment, reply mounts before the reply (#5495)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m31s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m31s
CI & Build / Build & push image (push) Skipped
- Every turn (/api/plugin/retrieve, UserPromptSubmit): the core reply shape leads the payload - in full the first time, as its one-line reminder after that - followed by whatever is mounted on reply.report, under the shared rule ledger. Fresh keys come back as shape_keys. - The ledger is <sid>.shapes.ids in scribe-priorart (scribe_shapes_file / _seen / _append), so the compaction sweep that clears every .ids ledger is what brings the full core back after one. - At a moment (/api/plugin/moment): the slice for that reply - completion on work.finish, asks on reply.ask, plan on work.plan - ahead of the mounted rules. reachable_tools now lists tools reaching a shaped moment even on an install with nothing mounted. - Scribe's own tools (attach_moment_rules): reply_shape in the response, in full, since that door has no ledger. enter_project carries the core for clients with no prompt hook. - Telemetry: one AppLog row per delivery (plugin / reply_shape), each shape with full or pointer and the door (turn, hook, mcp). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from scribe.services import moment_actions
|
||||
from scribe.services import moment_actions, reply_shapes
|
||||
from scribe.services import retrieval_pipeline as rp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,23 +38,27 @@ async def reachable_tools(user_id: int) -> list[str]:
|
||||
What the plugin's catch-all hook reads once per session window, so the
|
||||
calls that cannot reach a mounted rule — most of them, and every one on
|
||||
an install that has mounted nothing — never leave the machine. An action
|
||||
counts only when its moment carries a mount. The skill loader counts
|
||||
whenever anything is mounted: a stored process declares its own moments,
|
||||
which only the load itself can resolve, and a load is rare enough that
|
||||
asking costs nothing.
|
||||
counts when its moment carries a mount or a default reply shape. The
|
||||
skill loader counts whenever anything is mounted: a stored process
|
||||
declares its own moments, which only the load itself can resolve, and a
|
||||
load is rare enough that asking costs nothing.
|
||||
"""
|
||||
from scribe.services import rulebooks
|
||||
|
||||
mounted = await rulebooks.mounted_moments(user_id)
|
||||
if not mounted:
|
||||
return []
|
||||
# A moment that carries a default reply shape (milestone 500) is worth a
|
||||
# request whether or not anything is mounted on it: the shape is product,
|
||||
# so every install has it.
|
||||
shaped = {s.moment for s in reply_shapes.SHAPES.values() if s.key != reply_shapes.CORE_KEY}
|
||||
mounted = set(await rulebooks.mounted_moments(user_id))
|
||||
wanted = mounted | shaped
|
||||
mappings = await moment_actions.list_mappings(user_id)
|
||||
keys = {
|
||||
moment_actions.tool_key(action.tool)
|
||||
for action, _via in moment_actions.effective_actions(mappings)
|
||||
if action.moment in mounted
|
||||
if action.moment in wanted
|
||||
}
|
||||
keys.add(moment_actions.SKILL_TOOL)
|
||||
if mounted:
|
||||
keys.add(moment_actions.SKILL_TOOL)
|
||||
return sorted(keys)
|
||||
|
||||
|
||||
@@ -86,6 +90,58 @@ async def deliver_for_act(
|
||||
return reached, result
|
||||
|
||||
|
||||
def shapes_for_act(reached: list[dict], seen: frozenset[str] = frozenset()) -> tuple[list[str], dict[str, str]]:
|
||||
"""The default reply shapes riding the moments this act reached (milestone 500).
|
||||
|
||||
Closing a task comes just before a completion report, a structured
|
||||
question is an ask, opening a plan comes before a plan is put up for
|
||||
review — so the shape for that reply arrives at the act, before the reply
|
||||
is written. In full the first time a session meets it, as its reminder
|
||||
after that; a door with no ledger passes `seen` empty.
|
||||
"""
|
||||
return reply_shapes.deliver(
|
||||
reply_shapes.for_moments([hit["moment"] for hit in reached]), seen,
|
||||
)
|
||||
|
||||
|
||||
# What the per-turn delivery says reached `reply.report`: the turn has not
|
||||
# ended yet, but every turn ends in a reply, and this is the last point
|
||||
# before it is written.
|
||||
_REACHED_BY_TURN = "the reply this turn will end with"
|
||||
|
||||
|
||||
async def deliver_for_turn(
|
||||
user_id: int, *, seen: frozenset[str] = frozenset(), project_id: int | None = None,
|
||||
exclude: frozenset[int] = frozenset(), held: frozenset[int] = frozenset(),
|
||||
) -> dict:
|
||||
"""What every turn carries before its reply is written (milestone 500 step 3).
|
||||
|
||||
The core reply shape — in full once per session and again after a
|
||||
compaction (the ledger is swept then), otherwise its one-line reminder —
|
||||
and the rules and preferences mounted on `reply.report`, under the same
|
||||
rule ledger as every other arm. The Stop hook's reply moment fires after
|
||||
the reply exists, which is too late to shape it and is kept as the
|
||||
backstop; this is the point before.
|
||||
|
||||
Returns `{context, rule_ids, shape_forms}`. Fails open to the core alone,
|
||||
and the core itself never fails: it is a constant.
|
||||
"""
|
||||
blocks, forms = reply_shapes.deliver([reply_shapes.core()], seen)
|
||||
rule_ids: list[int] = []
|
||||
try:
|
||||
reached = [{"moment": "reply.report", "tool": "", "match": _REACHED_BY_TURN,
|
||||
"via": moment_actions.DEFAULT}]
|
||||
result = await deliver_moments(
|
||||
user_id, reached, project_id=project_id, exclude=exclude, held=held,
|
||||
)
|
||||
blocks.extend(result.lines)
|
||||
rule_ids = result.rule_ids
|
||||
except Exception: # noqa: BLE001 - the mounted half never costs the core
|
||||
logger.debug("reply.report mounts not delivered for the turn", exc_info=True)
|
||||
await reply_shapes.record_delivery(user_id, forms, via="turn")
|
||||
return {"context": "\n\n".join(blocks), "rule_ids": rule_ids, "shape_forms": forms}
|
||||
|
||||
|
||||
async def attach_moment_rules(
|
||||
user_id: int, tool: str, arguments: dict | None, data: dict,
|
||||
) -> dict:
|
||||
@@ -114,6 +170,13 @@ async def attach_moment_rules(
|
||||
"rule_ids": result.shown_rule_ids,
|
||||
"open_with": "get_rule(id)",
|
||||
}
|
||||
# The reply shape for this moment, in full: no ledger reaches this
|
||||
# door, and an act like closing a task is rare enough that the shape
|
||||
# arriving each time costs less than one report written without it.
|
||||
blocks, forms = shapes_for_act(_reached)
|
||||
if blocks:
|
||||
data["reply_shape"] = "\n\n".join(blocks)
|
||||
await reply_shapes.record_delivery(user_id, forms, via="mcp")
|
||||
except Exception: # noqa: BLE001 - a decoration never breaks the payload
|
||||
logger.debug("moment rules for %s could not be attached", tool, exc_info=True)
|
||||
return data
|
||||
|
||||
Reference in New Issue
Block a user