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:
@@ -43,10 +43,14 @@ asserted; a sentence added to it should replace one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from scribe.services import moments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReplyShape:
|
||||
@@ -68,6 +72,10 @@ class ReplyShape:
|
||||
text: str
|
||||
"""The shape itself, as the agent reads it."""
|
||||
|
||||
reminder: str
|
||||
"""One line standing in for `text` once the session has been shown it:
|
||||
the pointer form, which also serves as the reminder on later turns."""
|
||||
|
||||
|
||||
CORE_KEY = "core"
|
||||
|
||||
@@ -173,13 +181,21 @@ A plan put up for review, before starting:
|
||||
SHAPES: dict[str, ReplyShape] = {s.key: s for s in (
|
||||
ReplyShape(CORE_KEY, "Every reply", "reply.report",
|
||||
"every turn — in full once per session and after a compaction, "
|
||||
"otherwise as a one-line pointer", _CORE),
|
||||
"otherwise as a one-line pointer", _CORE,
|
||||
"conclusion first; the shortest reply that carries the answer; place the "
|
||||
"work; settle what only you can see and ask about direction; end with the "
|
||||
"one thing they need to do, or say there is none."),
|
||||
ReplyShape("completion", "Completion report", "work.finish",
|
||||
"when a piece of work is closed", _COMPLETION),
|
||||
"when a piece of work is closed", _COMPLETION,
|
||||
"where this sits · what now works · how / why, and how it was verified · "
|
||||
"needs you (theirs to decide, and blocking) · next."),
|
||||
ReplyShape("asks", "Asking them to decide or act", "reply.ask",
|
||||
"when a structured question is put to them", _ASKS),
|
||||
"when a structured question is put to them", _ASKS,
|
||||
"the question first and your recommendation first among the options; "
|
||||
"settle what you can see, and ask only about direction."),
|
||||
ReplyShape("plan", "A plan for review", "work.plan",
|
||||
"when a plan is opened", _PLAN),
|
||||
"when a plan is opened", _PLAN,
|
||||
"goal · steps · how you will know it works · open questions."),
|
||||
)}
|
||||
|
||||
|
||||
@@ -205,3 +221,68 @@ def catalog() -> dict:
|
||||
],
|
||||
"total": len(SHAPES),
|
||||
}
|
||||
|
||||
|
||||
# ── Delivery: what a door puts in front of the agent ────────────────────
|
||||
|
||||
# Said once, in the full form: the default is a floor an operator can raise.
|
||||
_FULL_HEAD = ("Reply shape · {title} — the default shape for {what}. Where an operator "
|
||||
"preference shown beside it differs, the preference is what they asked for.")
|
||||
_POINTER = ("Reply shape · {title}, shown in full earlier this session "
|
||||
"(list_reply_shapes has it): {reminder}")
|
||||
|
||||
FULL = "full"
|
||||
POINTER = "pointer"
|
||||
|
||||
|
||||
def render(shape: ReplyShape, *, full: bool) -> str:
|
||||
"""The shape as a door delivers it: in full the first time a session sees
|
||||
it, as its one-line reminder after that."""
|
||||
if not full:
|
||||
return _POINTER.format(title=shape.title, reminder=shape.reminder)
|
||||
what = moments.MOMENTS[shape.moment].means
|
||||
return _FULL_HEAD.format(title=shape.title, what=what) + "\n\n" + shape.text
|
||||
|
||||
|
||||
def deliver(shapes: list[ReplyShape], seen: set[str] | frozenset[str]) -> tuple[list[str], dict[str, str]]:
|
||||
"""Render these shapes against what the session was already shown.
|
||||
|
||||
Returns the blocks and `{key: form}` — every shape delivered and whether
|
||||
it went out in full or as a pointer. The keys sent in full are the ones a
|
||||
door's ledger appends; a door with no ledger passes `seen` empty and
|
||||
every shape goes out in full.
|
||||
"""
|
||||
blocks, forms = [], {}
|
||||
for shape in shapes:
|
||||
full = shape.key not in seen
|
||||
blocks.append(render(shape, full=full))
|
||||
forms[shape.key] = FULL if full else POINTER
|
||||
return blocks, forms
|
||||
|
||||
|
||||
def parse_seen(raw: str | None) -> frozenset[str]:
|
||||
"""A ledger's comma-separated keys, keeping only shapes that exist."""
|
||||
return frozenset(k for k in (p.strip() for p in (raw or "").split(",")) if k in SHAPES)
|
||||
|
||||
|
||||
async def record_delivery(user_id: int | None, forms: dict[str, str], *, via: str) -> None:
|
||||
"""One `reply_shape` row per delivery: which shapes, in which form, by which
|
||||
door. Fire-and-forget — telemetry never takes a shape away from the reader.
|
||||
|
||||
In AppLog beside 409's `report_check`, so the two readings of one reply —
|
||||
was the shape delivered, did the reply carry it — sit in one place.
|
||||
"""
|
||||
if not forms:
|
||||
return
|
||||
try:
|
||||
from scribe.models import async_session
|
||||
from scribe.models.app_log import AppLog
|
||||
|
||||
async with async_session() as session:
|
||||
session.add(AppLog(
|
||||
category="plugin", user_id=user_id, action="reply_shape",
|
||||
details=json.dumps({"shapes": forms, "via": via}),
|
||||
))
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 - observation never breaks the observed
|
||||
logger.debug("reply shape delivery not recorded", exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user