Rindle

API index and search · Build metadata

Source snapshot

packages/narrator/src/narrator.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
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