agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
1,011 lines • 59.6 kB
JavaScript
"use strict";
/**
* Commentary templates — bundled English prose for narrating an
* agentfootprint run, plus the small engine that picks the right
* template per event and substitutes payload values.
*
* Audience split (load-bearing):
* • COMMENTARY — pure prose for the bottom panel of any viewer
* (Lens, CLI tail, log file). NO technical numbers,
* NO field dumps, NO library terms.
* • DETAILS — token counts, durations, args, IDs. The right
* panel / DevTools / structured-log territory.
*
* Architecture (3 pieces):
* 1. `defaultCommentaryTemplates` — flat `key → string` map.
* i18n-ready: ship a Spanish/Japanese/etc. version with the same
* keys, pass via `commentaryTemplates` on the renderer.
* 2. `selectCommentaryKey(event)` — per-event-type routing fn.
* Returns `string` (render this key), `null` (skip — too
* low-signal for prose), or `undefined` (fall through to a
* caller-supplied default humanizer).
* 3. `extractCommentaryVars(event, ctx)` — builds the
* `{ appName, userPrompt, toolName, descClause, ... }` bag the
* template will be rendered with.
*
* Plus a tiny non-recursive `renderCommentary(template, vars)`.
*
* Why this lives in agentfootprint (not Lens):
* The keys ARE agentfootprint event types. The prose teaches
* agentfootprint concepts (slot composition, ReAct, tool-calling).
* Consumers building agentfootprint Agents ship their voice / locale
* alongside their system prompt and tool registry. Lens (or any
* other viewer) is just a renderer that consumes this surface.
*
* Verb discipline (encoded in the prose):
* • `{{appName}}` (active actor) — called, dispatched, returned,
* decided, read, built
* • LLM (passive actor) — suggested, responded, produced,
* asked for, gave
* The split reflects the architectural truth: LLMs don't act, the
* orchestrating system does.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.renderCommentary = exports.extractAgentName = exports.extractCommentaryVars = exports.selectCommentaryKey = exports.defaultCommentaryTemplates = void 0;
const artifactPhrases_js_1 = require("./artifactPhrases.js");
/**
* ONE sentence, two records (9.28.0). A tier-1 DATA-matcher route is witnessed
* on the cascade's verdict (`skill.turn_routed`) AND on the hop that carried it
* (`context.evaluated.cursorMove`) — and a graph with NO cascade has only the
* second, which is why the because-clause used to reach cascade readers alone.
* Both keys ship this one string by construction, so the two records can never
* drift into two stories about the same fact. They stay two KEYS, because
* consumers override by key and each record is still its own line.
*/
const ENTRY_WITNESS_LINE = 'A declared start rule routed this turn to `{{to}}` because the message said “{{witness}}”.';
/**
* The bundled English templates. Override per-key via the renderer's
* `templates` option — partial overrides are spread on top of these
* defaults so consumers only ship what they want to change.
*/
exports.defaultCommentaryTemplates = {
'agent.turn_start': 'User asked {{appName}}: "{{userPrompt}}".',
// `{{agentName}}` resolves to the active agent's display name when
// the event fires inside a Sequence stage / Swarm member / nested
// Agent. For single-Agent runs (no inner-agent path), it falls back
// to `{{appName}}` so existing copy reads identically.
'stream.llm_start.iter1': '{{agentName}} sent the question to the LLM.',
'stream.llm_start.iterN': "{{agentName}} sent the tool's result to the LLM for reasoning.",
'stream.llm_end.tools': 'The LLM said it needs to use a tool. {{agentName}} will do that next.',
'stream.llm_end.terminal': 'The LLM gave the final answer. {{agentName}} returned it to the user.',
// Streaming. Token chunks are NOT rendered as one line each — that
// would flood the commentary. Consumers (Lens) accumulate tokens
// into a single "live" entry that updates in place until llm_end
// arrives, then replace it with the terminal narration above.
// The two templates here are for that consumer:
// • `stream.thinking` — shown the moment llm_start fires, BEFORE
// any tokens arrive ("Chatbot is thinking…")
// • `stream.token.partial` — shown while tokens accumulate
// ("Chatbot is responding: {{partial}}")
// Selecting these is a viewer concern; the engine emits the keys
// and the renderer decides whether to mount a live line or skip.
'stream.thinking': '{{appName}} is thinking…',
'stream.token.partial': '{{appName}} is responding: {{partial}}',
'stream.tool_start': '{{agentName}} called the `{{toolName}}` tool{{descClause}}. The LLM asked for it, and {{agentName}} figured out the inputs from the conversation.',
'stream.tool_start.desc': ' — registered as "{{desc}}"',
'stream.tool_start.noDesc': '',
// A mid-call report (9.54.0). The teaching voice says THAT the call broke
// its silence, never what the author's payload said: `payload` is
// `unknown`, and a sentence that quoted it would be a field dump wearing
// prose. The words a person is meant to read live on the STATUS surface,
// where the author opted into them by naming a `message`.
'stream.tool_progress': 'The `{{toolName}}` tool reported progress while it was still running.',
'stream.tool_end': 'The tool returned its result. {{agentName}} will share it with the LLM next.',
// A call the paused batch never dispatched (9.113.0) — its `tool_start`
// carries `notDispatched`. A NEW key, not a split of `stream.tool_start`:
// every bracket that routed there before still does, so no override goes
// unconsulted (rule 5), and the shipped sentence ("called the tool") would
// be false here in any locale. One line per fact: its `tool_end` is skipped.
'stream.tool_start.notDispatched': '{{agentName}} did not call the `{{toolName}}` tool. The LLM asked for it together with ' +
'`{{pausedToolName}}`, and the run paused at `{{pausedToolName}}` before reaching it.',
'context.injected.rag': '{{appName}} retrieved relevant content and added it to the conversation.',
'context.injected.skill': '{{appName}} activated a skill — its body went into the system prompt, and its tools became available to the LLM.',
'context.injected.memory': '{{appName}} pulled prior content from memory and added it to the conversation.',
// Generic — fits always-on rules + on-tool-return predicates uniformly.
// Specialized variants below disambiguate when the trigger metadata
// is available on the event.
'context.injected.instructions': '{{appName}} added a rule to the system prompt: {{descClause}}.',
'context.injected.instructions.onToolReturn': '{{appName}} added a tool-specific reminder after `{{lastToolName}}` returned: {{descClause}}.',
'context.injected.instructions.alwaysOn': '{{appName}} added an always-on rule to the system prompt: {{descClause}}.',
'context.injected.custom': '{{appName}} injected a custom piece of context.',
'skill.activated': '{{appName}} turned on a skill — its tools and instructions are now available.',
'skill.deactivated': '{{appName}} turned off a skill.',
// Turn-start routing cascade (SG-C, 9.17.0) — one verdict per turn on
// cascade graphs, keyed by the tier that decided. The numbers (scores,
// runner-up gap, thresholds) stay on the event payload for richer consumers;
// this prose says who decided and where the turn starts.
'skill.turn_routed.continuity': '{{appName}} picked up where the conversation left off — still in `{{to}}`.',
// Near-tie hold (9.17.0): tier 2 ran but did not clear the margin, so the
// incumbent kept the turn. The two closest relevance shares ride the prose —
// the ONE case where the verdict is only honest with its numbers.
'skill.turn_routed.continuity.nearTie': 'Near-tie between `{{tieA}}` ({{tieAShare}}) and `{{tieB}}` ({{tieBShare}}) — ' +
'{{appName}} stayed put, continuing in `{{to}}`.',
'skill.turn_routed.intent': 'The new message decisively matched `{{to}}`{{scoreClause}}{{scorerClause}}, so the turn starts there.',
// Pre-rendered into {{scoreClause}} only when the event carries `scores` —
// an event without the ranked list renders the sentence without numbers.
'skill.turn_routed.intent.scored': ' — scored {{topScore}} vs {{runnerUpScore}} runner-up',
'skill.turn_routed.intent.scoredSolo': ' — scored {{topScore}}',
'skill.turn_routed.entry': 'A declared start rule routed this turn to `{{to}}`.',
// The same verdict, with its EVIDENCE (9.28.0): a DATA matcher (regex /
// keywords / all) knows the text that made it true, so the sentence quotes
// the user's own words instead of asserting "a rule matched". Rendered ONLY
// when the event carries `witness` — a `when` predicate is opaque code and
// keeps the sentence above, byte-for-byte. Shared with `context.entry_witness`
// (the same route, seen from the hop): see ENTRY_WITNESS_LINE.
'skill.turn_routed.entry.witness': ENTRY_WITNESS_LINE,
'skill.turn_routed.menu': 'No declared intent clearly claimed this message — {{appName}} was offered ' +
'{{offeredCount}} option{{offeredPlural}}{{stayClause}} and chooses for itself.',
'skill.turn_routed.dropped': 'The conversation remembered being in `{{droppedId}}`, but this graph no longer has ' +
'it — started fresh.',
'skill.turn_routed.none': 'The turn-start router decided nothing — the turn proceeds as-is.',
// `by: 'none'` WITH a recorded offer = the rails posture: the menu is on the
// record but the model is never allowed to answer it, so the turn ran on the
// base prompt. WHY it ended in a menu is stated only when the event carries
// the evidence (`selectCommentaryKey` branches on decisive/scores/floor):
// • near-tie — intent DID match, more than once, too closely to call;
// `offered` is the tied set;
// • unmatched — nothing cleared the floor; `offered` is the FULL menu,
// so "close candidates" would be a lie there;
// • no numbers → the posture-only sentence claims no reason at all.
'skill.turn_routed.none.rails': 'Routing ended in a menu, but this graph does not let the model route — the turn ran ' +
'on the base prompt. On the record: {{candidates}}.',
'skill.turn_routed.none.rails.nearTie': 'This message matched {{candidates}} too closely to call, and this graph does not let ' +
'the model break the tie — the turn ran on the base prompt.',
'skill.turn_routed.none.rails.unmatched': 'No start rule or intent matched this message strongly enough, and this graph does not ' +
'let the model route — the turn ran on the base prompt. The full menu stayed on the ' +
'record: {{candidates}}.',
// The tier-3 decider resolved the menu out-of-band (9.19.0). A 'stay'
// verdict lands under the continuity key — the event's `decider` field
// still says who answered.
'skill.turn_routed.decider': 'A dedicated decider model ({{deciderModel}}) read the menu and chose `{{to}}`.',
// Escalate-on-evidence (9.19.0): the declared refusal threshold tripped
// and the rest of the turn runs on the bigger brain. Once per turn.
// The count is pre-pluralized (`refusalPlural`) — one refusal is a real
// configuration (`afterRefusals: 1`), and "1 refused routing attempts" is
// the kind of small wrongness that makes a reader distrust the rest.
'skill.escalated': 'After {{refusals}} refused routing attempt{{refusalPlural}}, the rest of the turn ' +
'escalated from {{fromModel}} to {{toModel}}.',
// Typed tool effects (9.19.0) — the effects channel's own prose, keyed by
// kind + outcome. A refusal names itself teaching; the full sentence rides
// the event payload (`refusalReason`) for richer consumers.
// `reason` (the proposal's own words) and `refusalReason` (the teaching
// sentence) are both OPTIONAL on the payload, so both ride pre-rendered
// clauses. Through 9.26.0 the accepted line spelled the reason inline and an
// effect that carried none rendered a pair of empty quotes — the sentence
// claiming words that were never spoken.
'tools.effect.transition.accepted': '`{{toolName}}` proposed moving to the `{{targetSkillId}}` skill{{reasonClause}} and ' +
'the graph accepted it.',
'tools.effect.transition.refused': '`{{toolName}}` proposed moving to `{{targetSkillId}}` and the graph refused ' +
'it{{refusalClause}} — the teaching refusal is on the record.',
'tools.effect.transition.superseded': '`{{toolName}}` proposed moving to `{{targetSkillId}}`, but an earlier proposal in the ' +
'same batch had already won the move.',
'tools.effect.instruction.accepted': '`{{toolName}}` pushed the `{{instructionId}}` instruction into the coming ' +
'iteration(s) ({{deliveryLease}} lease).',
'tools.effect.instruction.refused': '`{{toolName}}` asked to push the `{{instructionId}}` instruction and was ' +
'refused{{refusalClause}} — the teaching refusal is on the record.',
'tools.effect.reason': ' (“{{reason}}”)',
'tools.effect.refusalReason': ' (“{{refusalReason}}”)',
// A tool's result was too big to hand back (9.20.0). The oversized payload
// entered no channel at all — not history, not the event — so the prose can
// only say HOW BIG it was and what the model was told to do about it. The
// size is in CHARACTERS because that is what the ceiling counts.
'tools.result_refused': '`{{toolName}}` returned more than it is allowed to hand back ({{size}}, against a limit ' +
'of {{limit}}) — the model got no data, only a note on how to ask for less{{narrowClause}}.',
'tools.result_refused.narrowOne': ' (narrowing by `{{narrowBy}}` would help)',
'tools.result_refused.narrowMany': ' (narrowing by {{narrowBy}} would help)',
// The nudge moment (9.26.0): same call, same answer, and the model was told
// so in a note appended to the result. Nothing was refused and nothing was
// withheld — the prose must not imply either. The fingerprints that prove
// sameness are digests, and digests are DETAILS, never prose.
'tools.repeated_call': '`{{toolName}}` was called with the same inputs and gave back the same answer as ' +
'before — that makes {{occurrences}} identical calls this turn, and the model was told so.',
// The arguments-only variant (9.62.0): this tool declared `repeatedWhen:
// 'arguments'`, so the match was on the request alone — the sentence must
// not claim the ANSWER matched too, because for a tool like this it may
// well not have (a screen tool's version stamp, a timestamp, a cursor).
'tools.repeated_call.argsOnly': '`{{toolName}}` was called with the same arguments as before — this tool does not ' +
'compare its result — that makes {{occurrences}} identical calls this turn, and the ' +
'model was told so.',
// artifacts.* — the claim-check lifecycle (9.21.0–9.23.0). One law runs
// through every line here: META ONLY. Sizes are humanized ("41.2 KB"), kinds
// and labels are the consumer's own vocabulary, and the payload itself is
// never in the sentence — nor is the ref, which is an identifier for the
// DETAILS panel, not something a reader reads.
'artifacts.minted': '`{{tool}}` checked {{subject}} into the store{{derivedClause}} — the model got a ' +
'one-line ticket, not the data.',
// No tool minted it — the run's own recording, or a host filing for its turn
// (`HostReply.turnArtifacts`). The same actor wording the resolved/refused
// lines use, and no claim about the model: no model was handed this ticket.
'artifacts.minted.host': '{{actor}} checked {{subject}} into the store{{derivedClause}}.',
'artifacts.minted.subject.labeled': '“{{label}}” ({{kind}}, {{size}})',
'artifacts.minted.subject.plain': 'a {{kind}} artifact ({{size}})',
'artifacts.minted.derived': ', built from {{parentCount}} earlier artifact{{parentPlural}}',
'artifacts.presented': 'The model handed {{subject}} to the screen to show as `{{as}}` — the screen fetches ' +
'the data itself.',
'artifacts.presented.subject.labeled': '“{{label}}” ({{kind}}, {{size}})',
'artifacts.presented.subject.plain': 'a {{kind}} artifact ({{size}})',
// `head` describes without paying for the payload; `get` pays. That is the
// render-by-ref decision, so it gets two sentences rather than one hedge.
'artifacts.resolved.head': '{{actor}} looked up what a ticket describes — a {{kind}} artifact ({{size}}) — ' +
'without reading the data.',
'artifacts.resolved.get': '{{actor}} redeemed a ticket and read the {{kind}} artifact ({{size}}).',
// `tool` is absent when the hop came through the hosting door instead of a
// tool call (9.23.0) — a screen redeeming a ref over the wire under its own
// identity. Naming a phantom tool there would be a lie a dashboard groups by.
'artifacts.actor.tool': '`{{tool}}`',
'artifacts.actor.host': 'The app itself (not a tool)',
'artifacts.refused': '{{actor}} was refused {{opPhrase}} — {{reasonPhrase}}.',
// `op: 'dispatch'` is the framework's own door: the tool never ran, because
// the data it declared it wanted could not be delivered.
'artifacts.refused.dispatch': '`{{tool}}` never ran — the artifact it asked for could not be delivered ' +
'({{reasonPhrase}}), and the model was told what it can ask for instead.',
// The app's own filing for a turn (`HostReply.turnArtifacts`) did not go
// cleanly. Class only in the data; the sentence says what happened and that
// the person's reply was unaffected.
'artifacts.hand_over_failed': 'The app’s own filing for a turn {{causePhrase}}{{opClause}} — the reply was delivered ' +
'regardless.',
'artifacts.hand_over_failed.op': ' ({{opPhrase}})',
'artifacts.expired': 'A {{kind}} artifact ({{size}}) left the store {{reasonPhrase}}{{noticedClause}} — a ' +
'ticket for it no longer resolves.',
'artifacts.expired.noticed': ', noticed while `{{tool}}` was checking something in',
// One parallel tool batch, N matched edges, different targets (9.16.0).
// Call order decides; the suppressed hop(s) stay on the record — this line is
// that record's prose. Grammar branches (was/were) are pre-rendered clauses so
// the key itself never has to split, and the head COUNTS the results
// (winner + losers) instead of hard-coding "Two" — a 3-result batch says so.
'skill.route_conflict': '{{conflictCount}} tool results wanted different next skills — `{{winnerTool}}` → `{{winnerTarget}}` ' +
'won (first in the batch){{losersClause}}.',
'skill.route_conflict.suppressedOne': '; {{losers}} was suppressed and is on the record',
'skill.route_conflict.suppressedMany': '; {{losers}} were suppressed and are on the record',
// Posture refusals on `skill.rejected` (SG-C `strictness`, 9.17.0). ONLY the
// posture cases get prose here — a plain reachability rejection keeps its
// existing fall-through (the default humanizer renders the raw event, never
// drops it), so nothing that narrated before reads differently now.
'skill.rejected.guard': 'The model asked for the `{{requestedId}}` skill, but that choice was not on the menu ' +
'it was offered — {{appName}} refused the jump and re-prompted with the allowed options.',
'skill.rejected.rails': 'The model asked for the `{{requestedId}}` skill, but this graph does not let the model ' +
'route — {{appName}} refused the jump; start rules and declared routes decide moves.',
// The cursor moved by MODEL PICK and the event says how the offer went
// (`cursorMove.offered` / `cursorMove.declinedOffer`, 9.17.0) — assist-posture
// divergence as data. Every clause renders only from fields the event carries:
// no menu on the event → no menu in the sentence.
'context.model_pick': 'The model chose the `{{to}}` skill{{menuClause}}{{offMenuClause}}{{stayClause}}.',
'context.model_pick.menu': ' from the {{offeredCount}}-option menu it was offered',
'context.model_pick.offMenu': ' — a pick that was not on the menu it was offered; the divergence is on the record',
'context.model_pick.stay': '; staying put was offered and declined',
// The cursor moved by a declared START RULE and the hop carries the words that
// made the rule true (`cursorMove.witness`, 9.28.0). A graph WITHOUT a cascade
// never fires `skill.turn_routed`, so this record is the only place that
// evidence exists — and the sentence is the cascade's, verbatim (one fact, one
// sentence, whichever record carried it). No witness on the hop → the routed
// line below, byte-for-byte.
'context.entry_witness': ENTRY_WITNESS_LINE,
// Skill-GRAPH routing (proposal 002): a decision tree or declared edge picked a
// skill this turn. Narrates WHICH skill + WHY (the matched decision) + what it
// unlocked. The full decision path + tool list rides the `context.evaluated`
// event payload (`routing`) for the lens; this prose stays concise.
'context.routed': '{{appName}} routed to the `{{skillId}}` skill{{matchClause}} — {{toolClause}} now available.',
'context.routed.matched': ' (matched “{{matchedLabel}}”)',
'context.routed.default': ' (no specific intent — default)',
'composition.fork_start': '{{appName}} fanned out into parallel branches.',
'composition.merge_end': '{{appName}} merged the parallel branches back into one.',
// Multi-agent / multi-LLM composition narration. Each composition
// primitive gets its own enter / exit template. Single-Agent runs
// never fire these; they're for Sequence / Parallel / Loop /
// Conditional shapes. Override per-key for locale or brand voice.
'composition.enter.Sequence': 'Started pipeline `{{name}}` — {{childCount}} stages chained.',
'composition.enter.Parallel': 'Forked `{{name}}` into {{childCount}} parallel branches.',
'composition.enter.Loop': 'Started loop `{{name}}` — repeat until done.',
'composition.enter.Conditional': 'Entering router `{{name}}` — picking a branch.',
'composition.enter.Generic': 'Entered composition `{{name}}` ({{kind}}) with {{childCount}} children.',
'composition.exit': '`{{name}}` finished — {{status}} in {{durationMs}}ms.',
// Inter-agent handoff (synthesized between adjacent Sequence stages).
// Surfaces "classify → respond" instead of two unrelated llm_start
// lines. Renderer derives `fromAgent` / `toAgent` from sibling
// subflow.exit / entry pair at the same depth.
'composition.handoff': 'Handed off `{{fromAgent}}` → `{{toAgent}}`.',
'pause.request': '{{appName}} paused — waiting for input from a human or external system.',
'pause.resume': '{{appName}} resumed.',
// ONE key, and an `outcome` clause the extractor pre-renders — because the
// sentence has to branch and the key must not. Through 8.13.0 this said
// "hit a cost limit and stopped", which was false for every agent that had
// one: a `costBudget` warned and the run carried on. Splitting the key into
// `.warn` / `.halt` would have read better and silently orphaned every
// consumer who had overridden `cost.limit_hit`.
'cost.limit_hit': '{{appName}} reached its {{limitNoun}} ({{actual}} of {{limit}}) — {{outcome}}.',
'cost.limit_hit.stopped': 'the run stopped there',
'cost.limit_hit.continued': 'the run carried on, because this limit only warns',
};
/**
* Pick the template key for an event. Branches encoded in the key
* suffix (no conditional logic in the templates themselves).
*
* `null` → explicit skip (baseline injections, low-signal events)
* `undefined` → fall through to caller's default humanizer
* `string` → render `templates[key]` with `extractCommentaryVars`
*/
function selectCommentaryKey(event) {
switch (event.type) {
case 'agentfootprint.agent.turn_start':
return 'agent.turn_start';
case 'agentfootprint.agent.turn_end':
return null;
case 'agentfootprint.stream.llm_start':
return event.payload.iteration === 1 ? 'stream.llm_start.iter1' : 'stream.llm_start.iterN';
case 'agentfootprint.stream.llm_end':
return event.payload.toolCallCount > 0 ? 'stream.llm_end.tools' : 'stream.llm_end.terminal';
// A bracket carrying `notDispatched` (9.113.0) is a call that never ran:
// its start gets the settled key, its end is skipped (the start said it).
case 'agentfootprint.stream.tool_start':
return event.payload.notDispatched !== undefined
? 'stream.tool_start.notDispatched'
: 'stream.tool_start';
case 'agentfootprint.stream.tool_progress':
return 'stream.tool_progress';
case 'agentfootprint.stream.tool_end':
return event.payload.notDispatched !== undefined ? null : 'stream.tool_end';
case 'agentfootprint.context.injected':
switch (event.payload.source) {
case 'rag':
return 'context.injected.rag';
case 'skill':
return 'context.injected.skill';
case 'memory':
return 'context.injected.memory';
case 'instructions':
return 'context.injected.instructions';
case 'custom':
return 'context.injected.custom';
// Baseline injections (LLM API natives, not engineering
// decisions): drop from prose.
case 'user':
case 'tool-result':
case 'assistant':
case 'base':
case 'registry':
return null;
default:
return 'context.injected.custom';
}
case 'agentfootprint.skill.activated':
return 'skill.activated';
case 'agentfootprint.skill.deactivated':
return 'skill.deactivated';
case 'agentfootprint.skill.turn_routed': {
// A dropped resume outranks the (cold) verdict it forced — the reader's
// question is "why did my conversation forget where it was?".
if (event.payload.droppedResume)
return 'skill.turn_routed.dropped';
switch (event.payload.by) {
case 'continuity':
// A near-tie hold is only a hold because the numbers were close —
// when the event carries them, the prose says so.
return event.payload.decisive === false && (event.payload.scores?.length ?? 0) >= 2
? 'skill.turn_routed.continuity.nearTie'
: 'skill.turn_routed.continuity';
case 'intent':
return 'skill.turn_routed.intent';
case 'entry':
case 'rule': // era tolerance — 'rule' is the same tier-1 verdict, older/newer vocabulary
// Evidence when the tier-1 matcher was DATA and recorded what it
// matched; today's sentence when it was a `when` predicate.
return typeof event.payload.witness?.text === 'string' &&
event.payload.witness.text.length > 0
? 'skill.turn_routed.entry.witness'
: 'skill.turn_routed.entry';
case 'decider': // the tier-3 out-of-band resolver (9.19.0)
return 'skill.turn_routed.decider';
case 'menu':
case 'model-pick': // era tolerance — a menu the model resolves
return 'skill.turn_routed.menu';
default: {
// `by: 'none'` with the offer on the record = the rails posture.
// WHY the cascade ended in a menu is stated only when the event
// carries the evidence: `decisive: false` with a floor-clearing top
// score is a near-tie (intent DID match — more than once); a top
// score at/below the recorded floor is honestly unmatched (the
// offer is the FULL menu). No numbers → the posture-only sentence.
const p = event.payload;
if ((p.offered?.length ?? 0) === 0)
return 'skill.turn_routed.none';
const top = p.scores?.[0]?.score;
const floor = p.policy?.floor;
const cleared = top !== undefined && Number.isFinite(top) && (floor === undefined || top > floor);
if (p.decisive === false && (p.scores?.length ?? 0) >= 2 && cleared)
return 'skill.turn_routed.none.rails.nearTie';
if (top !== undefined && !cleared)
return 'skill.turn_routed.none.rails.unmatched';
return 'skill.turn_routed.none.rails';
}
}
}
case 'agentfootprint.skill.route_conflict':
return 'skill.route_conflict';
case 'agentfootprint.skill.escalated':
return 'skill.escalated';
case 'agentfootprint.tools.effect': {
// Kind + outcome pick the sentence; 'superseded' exists only for
// transitions (an instruction lease is never outrun).
const p = event.payload;
const family = p.kind === 'propose-transition' ? 'transition' : 'instruction';
const outcome = p.outcome === 'superseded' ? 'superseded' : p.outcome;
return `tools.effect.${family}.${outcome}`;
}
case 'agentfootprint.tools.result_refused':
return 'tools.result_refused';
case 'agentfootprint.tools.repeated_call':
// Same event, two truths it can tell (9.62.0): the default match
// compared the result too, so the sentence can say "gave back the
// same answer"; an arguments-only match (the tool declared
// `repeatedWhen: 'arguments'`) must not claim that, because it never
// looked.
return event.payload.mode === 'arguments'
? 'tools.repeated_call.argsOnly'
: 'tools.repeated_call';
case 'agentfootprint.artifacts.minted':
// A mint no tool made has its own sentence — the tool one would print
// an empty tool name and credit a model with a ticket it never got.
return event.payload.tool ? 'artifacts.minted' : 'artifacts.minted.host';
case 'agentfootprint.artifacts.presented':
return 'artifacts.presented';
case 'agentfootprint.artifacts.resolved':
// `head` and `get` are different decisions, not a detail of one event:
// one describes the parcel, the other pays for it.
return event.payload.via === 'get' ? 'artifacts.resolved.get' : 'artifacts.resolved.head';
case 'agentfootprint.artifacts.expired':
return 'artifacts.expired';
case 'agentfootprint.artifacts.hand_over_failed':
return 'artifacts.hand_over_failed';
case 'agentfootprint.artifacts.refused':
// The dispatch door has its own sentence — but only when the event names
// the tool that never ran. Without it, the generic refusal is the honest
// line (it says who/what without inventing an actor).
return event.payload.op === 'dispatch' && event.payload.tool
? 'artifacts.refused.dispatch'
: 'artifacts.refused';
case 'agentfootprint.skill.rejected':
// Posture refusals (SG-C) narrate; the plain reachability rejection keeps
// its pre-9.17 fall-through to the caller's default humanizer (raw render,
// never dropped).
switch (event.payload.posture) {
case 'guard':
return 'skill.rejected.guard';
case 'rails':
return 'skill.rejected.rails';
default:
return undefined;
}
case 'agentfootprint.context.evaluated': {
// A model pick that resolved a turn-start menu (or diverged from it) is
// the iteration's routing verdict — it fires on exactly ONE iteration
// (`cursorMove.offered` / `declinedOffer` are stamped only then, 9.17.0)
// and outranks the generic routed line for that iteration. Old-era events
// never carry the decorations, so they keep reading exactly as before.
const cm = event.payload.cursorMove;
if (cm?.by === 'model-pick' &&
cm.to &&
((cm.offered?.length ?? 0) > 0 || cm.declinedOffer === true)) {
return 'context.model_pick';
}
// A tier-1 DATA-matcher hop that carries its own evidence (9.28.0). On a
// rules-only graph (no cascade) NOTHING fires `skill.turn_routed`, so this
// is the only record that can say WHY the turn started where it did — and
// it says it in the cascade's own words. `'rule'` is era tolerance, the
// same tolerance the `turn_routed` switch keeps. An absent or empty
// witness falls through to the routed line unchanged.
const witnessedTo = cm?.to;
const witnessText = cm?.witness?.text;
if ((cm?.by === 'entry' || cm?.by === 'rule') &&
witnessedTo &&
typeof witnessText === 'string' &&
witnessText.length > 0) {
return 'context.entry_witness';
}
// Narrate ONLY when a skillGraph() routed a skill this turn (decision tree
// or declared edge). Otherwise stay silent — bare injection evaluation is
// plumbing, not pedagogy (matches the slot-mechanics skips below).
return event.payload.routing && event.payload.routing.length > 0 ? 'context.routed' : null;
}
case 'agentfootprint.agent.iteration_start':
case 'agentfootprint.agent.iteration_end':
case 'agentfootprint.agent.route_decided':
return null; // implicit in surrounding llm.start/end narrative
case 'agentfootprint.composition.fork_start':
return 'composition.fork_start';
case 'agentfootprint.composition.merge_end':
return 'composition.merge_end';
case 'agentfootprint.composition.enter': {
// Per-kind template suffix lets each composition primitive read
// naturally (Sequence = pipeline, Parallel = fork, Loop = repeat,
// Conditional = router). Falls back to `composition.enter.Generic`
// for unknown kinds so future primitives don't break the prose.
const kind = event.payload.kind;
const specific = `composition.enter.${kind}`;
// Defer to the renderer to fall back when the specific key isn't
// present — `renderCommentary` returns empty for missing tokens,
// so a missing key is a degraded-but-not-fatal experience.
return specific;
}
case 'agentfootprint.composition.exit':
return 'composition.exit';
case 'agentfootprint.pause.request':
return 'pause.request';
case 'agentfootprint.pause.resume':
return 'pause.resume';
case 'agentfootprint.cost.limit_hit':
return 'cost.limit_hit';
// Slot mechanics — plumbing, not pedagogy. The engineered
// injections above already narrate WHAT was added; the surrounding
// llm.start line narrates WHY. Mechanical "system-prompt composed
// (iter 1, 54/4000 tokens)" leaks technical numbers and adds no
// pedagogy.
case 'agentfootprint.context.slot_composed':
case 'agentfootprint.context.evicted':
case 'agentfootprint.context.budget_pressure':
return null;
default:
return undefined; // fall through
}
}
exports.selectCommentaryKey = selectCommentaryKey;
/**
* Who redeemed / was refused at an artifact door. A tool names itself; the
* hosting door (9.23.0 — a screen resolving a ref over the wire under its own
* identity) has no tool name, and gets a sentence that says exactly that
* instead of a blank or an invented actor.
*/
function artifactActor(tool, templates) {
const named = (tool ?? '').trim();
return named
? renderCommentary(templates['artifacts.actor.tool'] ?? '', { tool: named })
: templates['artifacts.actor.host'] ?? '';
}
/** Count words for the `route_conflict` head (index = the count). Beyond the
* list the digit itself reads fine ("7 tool results wanted…"). */
const CONFLICT_COUNT_WORDS = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five', 'Six'];
/**
* Build the variable bag for a given event. Flat `name → string` map;
* `renderCommentary` substitutes by name. Templates use whatever names
* this function produces.
*
* Two-step composition for `stream.tool_start`: the optional
* `descClause` is a rendered sub-template. We pre-render it here so
* the outer template stays a single non-recursive substitution pass.
*/
function extractCommentaryVars(event, ctx, templates = exports.defaultCommentaryTemplates) {
const agentName = extractAgentName(event, ctx);
const base = { appName: ctx.appName, agentName };
switch (event.type) {
case 'agentfootprint.agent.turn_start':
return { ...base, userPrompt: event.payload.userPrompt };
case 'agentfootprint.stream.tool_start': {
const toolName = event.payload.toolName;
// The settled call's sentence names the call the run paused on — read
// off the bracket's own field, never guessed from the batch.
const paused = event.payload.notDispatched?.pausedCall;
if (paused !== undefined)
return { ...base, toolName, pausedToolName: paused.toolName };
const desc = ctx.getToolDescription?.(toolName);
const hasDesc = typeof desc === 'string' && desc.trim().length > 0;
// Pre-render the descClause sub-template so the outer template
// sees a literal string. Keeps the engine flat (non-recursive).
const descClause = hasDesc
? // hasDesc guarantees desc is a non-empty string here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
renderCommentary(templates['stream.tool_start.desc'] ?? '', { desc: desc })
: templates['stream.tool_start.noDesc'] ?? '';
return { ...base, toolName, descClause };
}
case 'agentfootprint.stream.tool_progress':
return { ...base, toolName: event.payload.toolName };
case 'agentfootprint.composition.enter': {
const p = event.payload;
return {
...base,
name: p.name,
kind: p.kind,
childCount: String(p.childCount),
};
}
case 'agentfootprint.composition.exit': {
const p = event.payload;
// CompositionExitPayload carries `name` (since v2.14.5);
// fall back to `id` for older emitters that didn't pass name.
return {
...base,
name: p.name ?? p.id,
kind: p.kind,
status: p.status,
durationMs: String(p.durationMs ?? 0),
};
}
case 'agentfootprint.context.injected': {
const p = event.payload;
// Feed the injection's content summary into `descClause` so an
// instruction/rule line reads "added a rule: <summary>" instead of an
// empty ": .". (The instructions/onToolReturn/alwaysOn templates all use
// {{descClause}}.) Empty summary → empty clause, handled by the templates.
return { ...base, descClause: (p.contentSummary ?? '').trim() };
}
case 'agentfootprint.context.evaluated': {
// Narrate the first skill-graph route (a decision tree activates exactly
// one leaf). The matched predicate = the deciding `yes` on the path; for an
// all-`no` path (the default leaf) there's none → "default" clause. The
// full path + every route ride the event payload for richer consumers.
// Model-pick decoration vars (`context.model_pick`) — computed whenever
// the event carries them; harmless extras for the `context.routed` key.
const cm = event.payload.cursorMove;
const pickVars = {};
if (cm?.by === 'model-pick' && cm.to) {
const offeredCount = cm.offered?.length ?? 0;
pickVars.to = cm.to;
pickVars.menuClause =
cm.declinedOffer !== true && offeredCount > 0
? renderCommentary(templates['context.model_pick.menu'] ?? '', {
offeredCount: String(offeredCount),
})
: '';
pickVars.offMenuClause =
cm.declinedOffer === true ? templates['context.model_pick.offMenu'] ?? '' : '';
// "Staying put was offered and declined" is derivable, not invented:
// the current cursor was ON the menu and the accepted pick left it.
pickVars.stayClause =
cm.declinedOffer !== true &&
cm.from !== undefined &&
cm.to !== cm.from &&
(cm.offered ?? []).includes(cm.from)
? templates['context.model_pick.stay'] ?? ''
: '';
}
// Start-rule witness vars (`context.entry_witness`) — the hop's own
// evidence, computed on the same terms the key selection uses. Harmless
// extras for every other key: an absent witness contributes nothing, so
// the routed line renders exactly as it always did.
const witnessedTo = cm?.to;
const witnessText = cm?.witness?.text ?? '';
if ((cm?.by === 'entry' || cm?.by === 'rule') && witnessedTo && witnessText.length > 0) {
pickVars.to = witnessedTo;
pickVars.witness = witnessText;
}
const r = event.payload.routing?.[0];
if (!r)
return { ...base, ...pickVars };
const toolCount = r.tools?.length ?? 0;
const toolClause = toolCount > 0 ? `${toolCount} tool${toolCount === 1 ? '' : 's'}` : 'no new tools';
const matchedLabel = r.path?.find((s) => s.branch === 'yes')?.label ?? r.label;
const matchClause = matchedLabel
? renderCommentary(templates['context.routed.matched'] ?? '', { matchedLabel })
: r.via === 'tree'
? templates['context.routed.default'] ?? ''
: '';
return { ...base, skillId: r.injectionId, toolClause, matchClause, ...pickVars };
}
case 'agentfootprint.skill.turn_routed': {
const p = event.payload;
const offeredCount = p.offered?.length ?? 0;
// The verdict's numbers, rendered ONLY when the event carries them. Raw
// scores read to 2 decimals; `relevance` is the full-softmax share, so it
// reads as a percentage. An event without `scores` renders no numbers.
const scores = p.scores ?? [];
const fmtScore = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
const fmtShare = (r) => `${Math.round(r * 100)}%`;
const scoreClause = scores.length >= 2
? renderCommentary(templates['skill.turn_routed.intent.scored'] ?? '', {
topScore: fmtScore(scores[0].score),
runnerUpScore: fmtScore(scores[1].score),
})
: scores.length === 1
? renderCommentary(templates['skill.turn_routed.intent.scoredSolo'] ?? '', {
topScore: fmtScore(scores[0].score),
})
: '';
return {
...base,
// Continuity verdicts hold the inherited cursor — when an emitter left
// `to` off (the cursor didn't move), `from` names where the turn stays.
to: p.to ?? (p.by === 'continuity' ? p.from ?? '' : ''),
from: p.from ?? '',
droppedId: p.droppedResume?.id ?? '',
scorerClause: p.scorer !== undefined ? ` (judged by the '${p.scorer}' scorer)` : '',
offeredCount: String(offeredCount),
offeredPlural: offeredCount === 1 ? '' : 's',
stayClause: p.stayOffered === true ? ' (including staying put)' : '',
scoreClause,
tieA: scores[0]?.id ?? '',
tieAShare: scores[0] ? fmtShare(scores[0].relevance) : '',
tieB: scores[1]?.id ?? '',
tieBShare: scores[1] ? fmtShare(scores[1].relevance) : '',
candidates: (p.offered ?? []).map((id) => `\`${id}\``).join(', '),
// The tier-1 matcher's evidence — already bounded + whitespace-collapsed
// at capture, so the sentence quotes it verbatim.
witness: p.witness?.text ?? '',
// The tier-3 decider's identity (9.19.0) — model when named, else
// the provider (a decider always has at least that).
deciderModel: p.decider?.model ?? p.decider?.provider ?? '',
};
}
case 'agentfootprint.skill.escalated': {
const p = event.payload;
return {
...base,
refusals: String(p.refusals),
refusalPlural: p.refusals === 1 ? '' : 's',
fromModel: p.from.model,
// The escalation brain may inherit its model down the chain (same
// provider) — the provider name is then the honest identity.
toModel: p.to.model ?? p.to.provider,
};
}
case 'agentfootprint.tools.effect': {
const p = event.payload;
// Both reasons are optional on the payload, so both are clauses: an
// effect that carried no words says nothing in their place.
const reason = (p.reason ?? '').trim();
const refusalReason = (p.refusalReason ?? '').trim();
return {
...base,
toolName: p.toolName,
targetSkillId: p.targetSkillId ?? '',
reason,
instructionId: p.instructionId ?? '',
deliveryLease: p.deliveryLease ?? '',
reasonClause: reason
? renderCommentary(templates['tools.effect.reason'] ?? '', { reason })
: '',
refusalClause: refusalReason
? renderCommentary(templates['tools.effect.refusalReason'] ?? '', { refusalReason })
: '',
};
}
case 'agentfootprint.tools.result_refused': {
const p = event.payload;
const narrowBy = p.narrowBy ?? [];
const narrowClause = narrowBy.length === 0
? ''
: narrowBy.length === 1
? renderCommentary(templates['tools.result_refused.narrowOne'] ?? '', {
narrowBy: narrowBy[0],
})
: renderCommentary(templates['tools.result_refused.narrowMany'] ?? '', {
narrowBy: narrowBy.map((n) => `\`${n}\``).join(' or '),
});
return {
...base,
toolName: p.toolName,
size: (0, artifactPhrases_js_1.humanizeChars)(p.sizeChars),
limit: (0, artifactPhrases_js_1.humanizeChars)(p.maxChars),
narrowClause,
};
}
case 'agentfootprint.tools.repeated_call': {
const p = event.payload;
// The fingerprints stay off the prose deliberately — they answer "is this
// the same?" and a reader already has that answer in the sentence.
return { ...base, toolName: p.toolName, occurrences: String(p.occurrences) };
}
case 'agentfootprint.artifacts.minted': {
const p = event.payload;
const label = (p.label ?? '').trim();
const size = (0, artifactPhrases_js_1.humanizeBytes)(p.bytes);
const subject = label
? renderCommentary(templates['artifacts.minted.subject.labeled'] ?? '', {
label,
kind: p.kind,
size,
})
: renderCommentary(templates['artifacts.minted.subject.plain'] ?? '', {
kind: p.kind,
size,
});
const parentCount = p.parentRefs?.length ?? 0;
return {
...base,
tool: p.tool ?? '',
actor: artifactActor(p.tool, templates),
kind: p.kind,
size,
subject,
derivedClause: parentCount > 0
? renderCommentary(templates['artifacts.minted.derived'] ?? '', {
parentCount: String(parentCount),
parentPlural: parentCount === 1 ? '' : 's',
})
: '',
};
}
case 'agentfootprint.artifacts.presented': {
const p = event.payload;
const label = (p.snapshot.label ?? '').trim();
const size = (0, artifactPhrases_js_1.humanizeBytes)(p.snapshot.bytes);
const subject = label
? renderCommentary(templates['artifacts.presented.subject.labeled'] ?? '', {
label,
kind: p.snapshot.kind,
size,
})
: renderCommentary(templates['artifacts.presented.subject.plain'] ?? '', {
kind: p.snapshot.kind,
size,
});
// `as` is the model's own consumer vocabulary, stored as data — rendered
// exactly as spoken, never normalized into a word we prefer.
return { ...base, subject, as: p.as, kind: p.snapshot.kind, size };
}
case 'agentfootprint.artifacts.resolved': {
const p = event.payload;
return {
...base,
actor: artifactActor(p.tool, templates),
kind: p.kind,
size: (0, artifactPhrases_js_1.humanizeBytes)(p.bytes),
};
}
case 'agentfootprint.artifacts.refused': {
const p = event.payload;
// `detail` (the thrown refusal sentence) stays out of prose on purpose:
// it is an error string, and error strings are the one place a secret has
// ever ridden into a log. The typed `reason` says enough to act on.
return {
...base,
actor: artifactActor(p.tool, templates),
tool: p.tool ?? '',
opPhrase: (0, artifactPhrases_js_1.phraseFor)(artifactPhrases_js_1.ARTIFACT_OP_PHRASES, p.op, 'an artifact request'),
reasonPhrase: (0, artifactPhrases_js_1.phraseFor)(artifactPhrases_js_1.ARTIFACT_REFUSAL_PHRASES, p.reason, 'the store did not say why in words this build knows'),
};
}
case 'agentfootprint.artifacts.hand_over_failed': {
const p = event.payload;
return {
...base,
causePhrase: (0, artifactPhrases_js_1.phraseFor)(artifactPhrases_js_1.HAND_OVER_FAILURE_PHRASES, p.cause, 'failed for a reason this build does not have words for'),
opClause: p.op !== undefined
? renderCommentary(templates['artifacts.hand_over_failed.op'] ?? '', {
opPhrase: (0, artifactPhrases_js_1.phraseFor)(artifactPhrases_js_1.ARTIFACT_OP_PHRASES, p.op, 'an artifact request'),
})
: '',
};
}
case 'agentfootprint.artifacts.expired': {
const p = event.payload;
const tool = (p.tool ?? '').trim();
return {
...base,
kind: p.kind,
size: (0, artifactPhrases_js_1.humanizeBytes)(p.bytes),
reasonPhrase: (0, artifactPhrases_js_1.phraseFor)(artifactPhrases_js_1.ARTIFACT_SWEEP_PHRASES, p.reason, 'for a reason this build does not have words for'),
noticedClause: tool
? renderCommentary(templates['artifacts.expired.noticed'] ?? '', { tool })
: '',
};
}
case 'agentfootprint.skill.route_conflict': {
const p = event.payload;
const losers = p.losers ?? [];
const loserList = losers.map((l) => `\`${l.toolName}\` → \`${l.target}\``).join(' and ');
// The head counts EVERY result in the conflict (winner + losers) — a
// 3-result batch says "Three", never a hard-coded "Two". An event that
// lists no losers can only honestly say "Multiple" (a conflict implies
// more than one result by definition; the emitter always lists them).
const count = losers.length + 1;
const conflictCount = losers.length === 0
? 'Multiple'
: count < CONFLICT_COUNT_WORDS.length
? CONFLICT_COUNT_WORDS[count]
: String(count);
// Pre-rendered was/were clause so the outer key never has to split.
const losersClause = losers.length === 0
? ''
: renderCommentary(templates[losers.length === 1
? 'skill.route_conflict.suppressedOne'
: 'skill.route_conflict.suppressedMany'] ?? '', { losers: loserList });
return {
...base,
conflictCount,
winnerTool: p.winner.toolName,
winnerTarget: p.winner.target,
losersClause,
};
}
case 'agentfootprint.skill.rejected':
return {
...base,
requestedId: event.payload.requestedId,
currentSkillId: event.payload.currentSkillId ?? '',
};
case 'agentfootprint.cost.limit_hit': {
const p = event.payload;
// The sentence has to say whether the run STOPPED, and only `action`
// knows: a `costBudget` may warn or halt, and `max_iterations` always
// stops. Pre-rendered here as a clause so the template stays one flat
// key that a consumer's existing override still resolves.
const outcome = p.action === 'abort'
? templates['cost.limit_hit.stopped'] ?? ''
: templates['cost.limit_hit.continued'] ?? '';
const limitNoun = p.kind === 'max_iterations'
? 'iteration limit'
: p.kind === 'max_cost'
? 'cost limit'
: p.kind === 'max_tokens'
? 'token limit'
: 'time limit';
return {
...base,
limitNoun,
limit: String(p.limit),
actual: String(p.actual),
outcome,
};
}
// Most templates only need {{appName}} / {{agentName}} — no token
// counts, no IDs, no durations make it into prose. Those live in
// DETAILS.
default:
return base;
}
}
exports.extractCommentaryVars = extractCommentaryVars;
// ─── agentName derivation ──────────────────────────────────────────
/**
* Library-internal subflow id segments that are NOT user-facing
* agent identities. When walking back through `event.meta.subflowPath`
* we skip these to find the meaningful agent / stage name.
*/
const COMMENTARY_INTERNAL_SEGMENT_PREFIXES = ['sf-', 'thinking-'];
const COMMENTARY_INTERNAL_SEGMENTS = new Set([
'sf-injection-engine',
'sf-system-prompt',
'sf-messages',
'sf-tools',
'sf-route',
'sf-tool-calls',
'sf-merge',
'sf-thinking',
'sf-cache', // v2.14 — cache decision wrapper (and its inner stages)
'sf-cache-decision',
'final', // route-decider 'final' branch — same exception as SUBFLOW_IDS.FINAL
]);
function isInternalSegment(seg) {
if (COMMENTARY_INTERNAL_SEGMENTS.has(seg))
return true;
for (const p of COMMENTARY_INTERNAL_SEGMENT_PREFIXES) {
if (seg.startsWith(p))
return true;
}
return false;
}
/**
* Resolve the agent name from an event's `meta.subflowPath`.
*
* Walks the path right-to-left, skipping library-internal segments
* (slot subflows, agent-routing subflows, thinking handlers), and
* returns the FIRST meaningful segment with the optional `step-`
* Sequence prefix stripped. For events with no meaningful path
* (single-Agent runners, top-level events), falls back to `appName`.
*/
function extractAgentName(event, ctx) {
const path = event.meta?.subflowPath ?? [];
for (let i = path.length - 1; i >= 0; i--) {
const seg = path[i];
if (!seg)
continue;
if (isInternalSegment(seg))
continue;
return seg.replace(/^step-/, '');
}
return ctx.appName;
}
exports.extractAgentName = extractAgentName;
/**
* Render a template by substituting `{{name}}` placeholders from the
* vars bag. Missing keys render as empty string — keeps prose
* forgiving when an optional field isn't present.
*
* Non-recursive: a substituted value is NOT itself processed for
* placeholders. Compose sub-templates upstream (see
* `extractCommentaryVars`).
*/
function renderCommentary(template, vars) {
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => vars[name] ?? '');
}
exports.renderCommentary = renderCommentary;
//# sourceMappingURL=commentaryTemplates.js.map