API index and search · Build metadata
Source snapshot
packages/narrator/src/narrator.ts
1// The narration framework: per-query templates that render a resolved flat-change into prose.2//3// The position→name resolution (`resolveChange` / `subRow`) lives in @rindle/client — it is the4// pure inverse of the wire encoder and broadly useful. This module is the layer ON TOP: a small5// registry of hand-written, per-query templates keyed by `defineQuery` name + op (+ count alias),6// salience tagging (alert / info / ambient), and a `digest()` that folds a batch of rendered events7// into a salience-ranked block for an agent prompt. The templates are the only domain-specific part8// — small, because the query and relationship NAMES already carry the meaning; the app supplies the9// registry to {@link createNarrator}.1011import { resolveChange, subRow, type FlatChange, type NamedRow, type ResolvedChange, type WireSchema } from "@rindle/client";1213export type { NamedRow, ResolvedChange };1415export type Salience = "alert" | "info" | "ambient";1617/** Context a template may interpolate — e.g. a human label for the subscription's subject. */18export interface NarrateContext {19 /** A human name for what the query is scoped to ("the Smith wedding"). */20 subject?: string;21 [k: string]: unknown;22}2324export interface TemplateCtx {25 row: NamedRow;26 old?: NamedRow;27 /** The parent row, for an aggregate/nested change (e.g. the `ticket_type` behind a `sold` count). */28 parent?: NamedRow;29 /** Resolve a named sub-row of the change (only populated on `add`). */30 sub: (alias: string) => NamedRow | null;31 aggregate?: ResolvedChange["aggregate"];32 context: NarrateContext;33}3435export type Template = (ctx: TemplateCtx) => string | null;3637/** Handlers for changes to ONE nested relationship (keyed by its alias in {@link QueryNarrator.related}),38 * by op — plus an optional salience override for that relationship's events. A nested template reads39 * `row` (the nested row), `old`, and `parent` (its immediate container — the deck for a slide, the40 * slide for a component). */41export interface RelatedNarrator {42 /** Override the query's default salience for events on this relationship (else inherits it). */43 salience?: Salience;44 add?: Template;45 remove?: Template;46 edit?: Template;47}4849export interface QueryNarrator {50 /** Default importance of this query's events; an op handler may override via the return tuple. */51 salience: Salience;52 /** Handlers for changes at the ROOT level, by op. */53 root?: Partial<Record<"add" | "remove" | "edit", Template>>;54 /** Handlers for changes to a NESTED relationship, keyed by op — so ONE materialized view narrates55 * its whole tree: e.g. a deck's title at the root, slide edits under `slides`, component adds under56 * `components`. Key by the relationship's LEAF alias (`components`) for the common case, or by the57 * FULL dotted alias-chain from the root (`slides.components`) to disambiguate two relationships that58 * share a leaf alias at different tree positions (e.g. `slides.components` vs `appendix.components`).59 * A dotted key wins over a leaf key when both match; a bare leaf key still matches at any depth. */60 related?: Record<string, RelatedNarrator>;61 /** Handlers for an aggregate slot (`countAs`), keyed by the count's alias. */62 counts?: Record<string, Template>;63}6465/** A registry of {@link QueryNarrator}s, keyed by `defineQuery` name. Supplied by the app. */66export type NarratorRegistry = Record<string, QueryNarrator>;6768/** One rendered semantic event. `text === null` was suppressed by its template (too ambient). */69export interface SemanticEvent {70 query: string;71 phase: "snapshot" | "batch";72 salience: Salience;73 resolved: ResolvedChange;74 text: string | null;75}7677export interface Narrator {78 /** Resolve + render a `FlatChange[]` for a named query into semantic events. `schema` is the79 * query's `WireSchema`, captured from its `hello` frame (or read off `view.schema`) — the80 * position→name source. */81 narrate(query: string, schema: WireSchema, changes: FlatChange[], phase: "snapshot" | "batch", ctx?: NarrateContext): SemanticEvent[];82 /** Format a batch of rendered events for an agent prompt (salience-marked, suppressions dropped). */83 digest(events: SemanticEvent[]): string;84}8586/** Per-salience glyph for a rendered line. */87export const SALIENCE_MARK: Record<Salience, string> = { alert: "⚠️", info: "•", ambient: "·" };8889/** Order salience high→low. */90export const salienceRank = (s: Salience): number => (s === "alert" ? 2 : s === "info" ? 1 : 0);9192/** Build a narrator over an app-supplied {@link NarratorRegistry}. Resolution is driven entirely by93 * the per-query `WireSchema` passed to {@link Narrator.narrate}, so the narrator holds no schema94 * state of its own. */95export function createNarrator(narrators: NarratorRegistry): Narrator {96 return {97 narrate(query, schema, changes, phase, ctx = {}) {98 const spec = narrators[query];99 const out: SemanticEvent[] = [];100 for (const change of changes) {101 const resolved = resolveChange(schema, change);102 if (!resolved) continue;103 let text: string | null = null;104 let salience: Salience = spec?.salience ?? "info";105 if (spec) {106 const tctx: TemplateCtx = {107 row: resolved.row,108 old: resolved.old,109 parent: resolved.parent,110 sub: (alias) => subRow(resolved, alias),111 aggregate: resolved.aggregate,112 context: ctx,113 };114 // A nested relationship change (not the root, not an aggregate) matches a `related` template115 // keyed by its FULL dotted alias-chain (`slides.components`) if present, else its leaf alias116 // (`components`) — so two same-leaf relationships at different tree positions never collide,117 // while the common single-position case still keys by the bare alias.118 const relSpec =119 resolved.alias !== "" && !resolved.aggregate120 ? (spec.related?.[resolved.aliasChain.join(".")] ?? spec.related?.[resolved.alias])121 : undefined;122 const tmpl = resolved.aggregate123 ? spec.counts?.[resolved.aggregate.alias]124 : resolved.alias === ""125 ? spec.root?.[resolved.op]126 : relSpec?.[resolved.op];127 text = tmpl ? tmpl(tctx) : null;128 if (relSpec?.salience) salience = relSpec.salience;129 }130 out.push({ query, phase, salience, resolved, text });131 }132 return out;133 },134 digest(events) {135 return events136 .filter((e) => e.text !== null)137 .sort((a, b) => salienceRank(b.salience) - salienceRank(a.salience))138 .map((e) => `${SALIENCE_MARK[e.salience]} ${e.text}`)139 .join("\n");140 },141 };142}143