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
+30 -4
View File
@@ -13,12 +13,15 @@ So every change to a retrieval dial goes through `set_dial`, from the UI as
much as from the MCP tool, and the only difference is the `actor` recorded.
"""
import logging
from datetime import datetime, timedelta, timezone
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services import moment_actions as moment_actions_svc
from scribe.services import moments as moments_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.retrieval_telemetry import moment_usage
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
logger = logging.getLogger(__name__)
@@ -115,16 +118,39 @@ async def moments_route():
actions that reach each one on this install.
The same payload `list_moments` returns, from the same services, so the
session and the Settings view cannot name different moments.
session and the Settings view cannot name different moments — plus what
the view shows beside each one (step 6): `mounted`, how many rules each
carries, and `usage`, the deliveries and opens per moment over `days`
(the block `retrieval_telemetry` reports as `moment_usage`).
"""
uid = get_current_user_id()
try:
days = max(1, min(int(request.args.get("days", 30)), 365))
except ValueError:
return jsonify({"error": "days must be a whole number"}), 400
out = moments_svc.catalog()
out.update(await moment_actions_svc.actions_by_moment(get_current_user_id()))
out.update(await moment_actions_svc.actions_by_moment(uid))
# The catalog is the page; the counts beside it are not worth losing it
# over. A failed count is flagged rather than shown as "nothing mounted".
try:
out["mounted"] = await rulebooks_svc.mount_counts(uid)
except Exception:
logger.warning("mount counts unreadable", exc_info=True)
out["mounted"], out["mounted_failed"] = {}, True
out["usage"] = await moment_usage(uid, datetime.now(timezone.utc) - timedelta(days=days))
out["usage"]["days"] = days
return jsonify(out)
async def _mapping_change(change):
"""map/unmap from the browser: the MCP tools' service, recorded as human."""
data = await request.get_json()
"""map/unmap from the browser: the MCP tools' service, recorded as human.
The mapping comes as a JSON body, or — for DELETE, whose body many
clients will not send — as query parameters of the same names.
"""
data = await request.get_json(silent=True)
if data is None and request.args:
data = request.args.to_dict()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if not data.get("tool") or not data.get("moment"):