From b73a689849a7639b9274410290ee2e0ad6479170 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 5 Oct 2026 14:42:30 -0400 Subject: [PATCH] feat(moments): the human door onto mounts, mappings and per-moment telemetry (milestone 458 step 6, #4924) 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.` 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 --- frontend/src/api/moments.ts | 101 ++++++ frontend/src/api/rulebooks.ts | 5 + frontend/src/assets/moments-shared.css | 26 ++ frontend/src/components/MomentsSettings.vue | 294 ++++++++++++++++++ .../components/rules/RuleEditorSlideOver.vue | 103 +++++- frontend/src/stores/moments.ts | 56 ++++ frontend/src/views/SettingsView.vue | 12 + src/scribe/routes/retrieval.py | 34 +- src/scribe/services/retrieval_telemetry.py | 92 +++++- src/scribe/services/rulebooks.py | 17 +- tests/test_integration_rule_moments.py | 61 ++++ tests/test_routes_retrieval_tuning.py | 77 +++++ 12 files changed, 869 insertions(+), 9 deletions(-) create mode 100644 frontend/src/api/moments.ts create mode 100644 frontend/src/assets/moments-shared.css create mode 100644 frontend/src/components/MomentsSettings.vue create mode 100644 frontend/src/stores/moments.ts diff --git a/frontend/src/api/moments.ts b/frontend/src/api/moments.ts new file mode 100644 index 00000000..9a8f8581 --- /dev/null +++ b/frontend/src/api/moments.ts @@ -0,0 +1,101 @@ +import { apiGet, apiPost, apiDelete } from "@/api/client"; + +/** + * The moments of work rules mount on (milestone 458), and the actions that + * reach each one on this install — `GET /api/retrieval/moments`, the payload + * the `list_moments` MCP tool returns, plus the counts the Settings view shows + * beside each moment. + */ +export interface Moment { + name: string; + /** What is happening at this moment. */ + means: string; + /** The kinds of action that typically reach it. */ + reached_by: string; +} + +/** A shape rather than an entry: `skill.`, one moment per procedure. */ +export interface MomentFamily { + name: string; + prefix: string; + means: string; + reached_by: string; +} + +/** `default` ships with the product; `install` is this install's own. */ +export type ActionVia = "default" | "install"; + +export interface MomentAction { + tool: string; + /** Empty = every call of the tool. A command prefix for a command tool, + * `field=value` pairs for any other. */ + match: string; + via: ActionVia; +} + +/** A shipped default this install switched off. */ +export interface RemovedDefault { + id: number; + tool: string; + match: string; + moment: string; + reason: string | null; + actor: string | null; + created_at: string | null; +} + +export interface MomentUsage { + /** How many times a mounted rule arrived at this moment. */ + delivered: number; + /** How many distinct rules did. */ + rules: number; + /** Of those, how many an agent then opened — an upper bound: a pull records + * the door, not the line that prompted it. */ + opened: number; + last_delivered_at: string | null; +} + +export interface MomentsPayload { + moments: Moment[]; + families: MomentFamily[]; + total: number; + actions: Record; + removed_defaults: RemovedDefault[]; + /** Rules mounted per moment; a moment carrying none is absent. */ + mounted: Record; + /** The count read failed — `mounted` is empty for that reason, not because + * nothing is mounted. */ + mounted_failed?: boolean; + usage: { + by_moment: Record; + days: number; + /** Same distinction as `mounted_failed`, for the usage read. */ + moment_usage_failed?: boolean; + }; +} + +export interface MappingChange { + tool: string; + match?: string; + moment: string; + reason?: string; +} + +export function getMoments(days = 30): Promise { + return apiGet(`/api/retrieval/moments?days=${days}`); +} + +export function mapAction(change: MappingChange): Promise { + return apiPost("/api/retrieval/moments/mappings", change); +} + +/** Query parameters, not a body: DELETE bodies are not reliably sent. */ +export function unmapAction(change: MappingChange): Promise { + const q = new URLSearchParams({ + tool: change.tool, + match: change.match ?? "", + moment: change.moment, + reason: change.reason ?? "", + }); + return apiDelete(`/api/retrieval/moments/mappings?${q.toString()}`); +} diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 16c3c25b..e84f10c0 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -82,6 +82,9 @@ export interface Rule { updated_at: string | null; /** Present only when the rule has them (the server omits empty keys). */ systems?: { id: number; name: string }[]; + /** The moments this rule arrives at whenever they happen (milestone 458), + * in catalog order. Present only when the rule is mounted. */ + moments?: string[]; relations?: RuleRelation[]; /** The lessons that point at this rule (milestone 440) — the concrete * situations judged instances of it, plus any suggested and awaiting a @@ -210,6 +213,8 @@ export interface RuleWrite { how_to_apply: string; order_index: number; system_ids: number[]; + /** Replaces the set; [] unmounts the rule from every moment. */ + moments: string[]; arose_from_id: number | null; verify_with: string; expires_when: string; diff --git a/frontend/src/assets/moments-shared.css b/frontend/src/assets/moments-shared.css new file mode 100644 index 00000000..0bb072f0 --- /dev/null +++ b/frontend/src/assets/moments-shared.css @@ -0,0 +1,26 @@ +/* Moments (milestone 458): the pieces the rule editor's picker and the + Settings section share, so a moment's name and a chip's remove control look + the same wherever a person meets them. Loaded unscoped by both. */ + +/* The name as a session reads it in a delivered line — shown as written. */ +.moment-name { + font-family: var(--fs-font-mono); + color: var(--fs-text-primary); +} + +/* The × on a removable chip: quiet until hovered, and keyboard-reachable. */ +.chip-remove { + background: none; + border: none; + cursor: pointer; + padding: 0; + font: inherit; + color: var(--fs-text-tertiary); +} +.chip-remove:hover:not(:disabled) { color: var(--fs-text-primary); } +.chip-remove:disabled { opacity: var(--fs-disabled-opacity); cursor: default; } +.chip-remove:focus-visible { + outline: none; + box-shadow: var(--fs-focus-ring); + border-radius: var(--fs-radius-sm); +} diff --git a/frontend/src/components/MomentsSettings.vue b/frontend/src/components/MomentsSettings.vue new file mode 100644 index 00000000..b2a07a67 --- /dev/null +++ b/frontend/src/components/MomentsSettings.vue @@ -0,0 +1,294 @@ + + + + + + +