feat(moments): the human door onto mounts, mappings and per-moment telemetry (milestone 458 step 6, #4924)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m34s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 2m7s

Everything an agent can do with moments, a person can now see and change
in the app.

- Rule editor: a moment picker beside the trigger. Catalog moments are
  ticked; a named procedure's `skill.<name>` is typed and checked as the
  server checks it. `moments` is always sent, so unticking the last moment
  unmounts the rule.
- Settings, Moments section (General tab): for each moment, what it
  means, the actions that reach it on this install (shipped ones can be
  switched off, the install's own removed), how many rules are mounted on
  it, and deliveries and agent opens over the window. Below that: named
  procedures with mounts, switched-off defaults with Restore, and a form to
  add an action.
- retrieval_telemetry.moment_usage: per moment, `delivered`, `rules`,
  `opened` (agent pulls after the first delivery there; an upper bound, as
  by_source is) and `last_delivered_at`. No ratio, because a mount is a
  person's statement, not a ranker's guess. Guarded on its own, and also
  reported in retrieval_summary as `moment_usage`.
- rulebooks.mount_counts; mounted_moments now derives from it.
- GET /api/retrieval/moments carries `mounted` and `usage` (?days=).
  DELETE /moments/mappings also reads the mapping from query parameters,
  since the browser's DELETE sends no body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 14:42:30 -04:00
co-authored by Claude Opus 5.5
parent c489452a2d
commit b73a689849
12 changed files with 869 additions and 9 deletions
+14 -3
View File
@@ -1208,6 +1208,16 @@ async def mounted_moments(user_id: int) -> set[str]:
tool whose moments carry nothing. A superset is the safe direction — the
delivery read still scopes by project.
"""
return set(await mount_counts(user_id))
async def mount_counts(user_id: int) -> dict[str, int]:
"""How many of this caller's live rules are mounted on each moment.
The same read as `mounted_moments`, counted — what the Settings view
shows beside each moment, so a moment carrying nothing reads as such.
Moments with no mount are absent rather than zero.
"""
from scribe.models.rulebook import rule_moments as rule_moments_t
from scribe.services.rule_scope import joined_to_homes, rule_home
@@ -1215,13 +1225,14 @@ async def mounted_moments(user_id: int) -> set[str]:
async with async_session() as session:
rows = (await session.execute(
joined_to_homes(
select(rule_moments_t.c.moment).distinct()
select(rule_moments_t.c.moment, func.count(func.distinct(Rule.id)))
.select_from(rule_moments_t)
.join(Rule, Rule.id == rule_moments_t.c.rule_id)
)
.where(Rule.deleted_at.is_(None), home)
)).scalars().all()
return set(rows)
.group_by(rule_moments_t.c.moment)
)).all()
return {moment: int(n) for moment, n in rows}
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]: