moment-fence.ts5.0 KBView on GitHub /**
* Parsing for the ```moment fence — a single coaching moment from a call.
*
* Body shape: `key=[redacted] header lines, a blank line, then up to three `## context` /
* `## moment` / `## reaction` sections whose lines are `**Speaker** (mm:ss) text`. Everything
* inside is readable markdown, so the moment still reads correctly with no node view.
*
* See apps/server/docs/coaching-agent.md §3.2 part E.
*/
export type MomentVerdict = 'good' | 'bad' | 'questionable';
export interface MomentTurn {
speaker?: string;
at?: string;
text: string;
}
export interface ParsedMoment {
/** Deal / conversation name — the first crumb, clickable when `conversation` is present. */
deal: string;
/** Cedar conversation id, so the crumb can navigate to the deal. */
conversationId?: string;
/** The meeting / event name — the second crumb. */
meeting: string;
/** The call's date — the third crumb. */
date?: string;
at?: string;
/** Criterion id, e.g. "D2" — rendered on its own row beside the readable name. */
criterion?: string;
/** Full criterion name, e.g. "Genuinely curious". */
criterionName?: string;
/** Fathom (or other provider) share link for the recording. */
recording?: string;
/**
* `crm_meeting_events.external_id` — what the recording player resolves against.
*
* The card plays the call from `externalId`, not from `recording`. A stored URL is either
* already dead (Circleback signs for 24h) or not media at all (Fathom stores a web page),
* so a document that embedded one would stop working the day after it was written. The id
* is stable forever and the server mints from it at view time.
*/
externalId?: string;
/** Canonical provider id, lowercased. Display only — the server resolves the real one. */
provider?: string;
verdict: MomentVerdict;
coaching: string;
context: MomentTurn[];
moment: MomentTurn[];
reaction: MomentTurn[];
}
const VERDICTS: readonly MomentVerdict[] = ['good', 'bad', 'questionable'];
/** `**Keenan** (12:40) When you say a mess…` → a turn. A bare line is text with no speaker. */
function parseTurn(line: string): MomentTurn | null {
const trimmed = line.trim();
if (!trimmed) return null;
const match = /^\*\*(.+?)\*\*\s*(?:\(([^)]+)\))?\s*(.*)$/.exec(trimmed);
if (!match) return { text: trimmed };
const [, speaker, at, text] = match;
return { speaker: speaker.trim(), ...(at ? { at: at.trim() } : {}), text: text.trim() };
}
/**
* Parse a fence body into a moment. Returns null when a required header key is missing or the
* `## moment` section is empty — the node then renders the body raw rather than a broken card.
*/
export function parseMoment(body: string): ParsedMoment | null {
const lines = body.split('\n');
const header: Record<string, string> = {};
let i = 0;
for (; i < lines.length; i += 1) {
const line = lines[i];
if (!line.trim()) break;
if (line.trimStart().startsWith('##')) break;
const idx = line.indexOf(':');
if (idx <= 0) break;
header[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
}
const sections: Record<string, MomentTurn[]> = { context: [], moment: [], reaction: [] };
let current: string | null = null;
for (; i < lines.length; i += 1) {
const line = lines[i];
const heading = /^##\s+(context|moment|reaction)\s*$/i.exec(line.trim());
if (heading) {
current = heading[1].toLowerCase();
continue;
}
if (!current) continue;
const turn = parseTurn(line);
if (turn) sections[current].push(turn);
}
const verdictRaw = (header.verdict ?? '').toLowerCase() as MomentVerdict;
const verdict = VERDICTS.includes(verdictRaw) ? verdictRaw : null;
if (!header.deal || !header.meeting || !verdict || !header.coaching) return null;
if (sections.moment.length === 0) return null;
// `criterion: D2 Genuinely curious` splits into id + readable name, so the card can put the id
// on its own row rather than leading with a code nobody remembers.
const criterionRaw = header.criterion ?? '';
// "D2 Genuinely curious" → id + name; a bare "D2" → id only; anything else → name only.
const criterionMatch = /^([DM]\d+)(?:\s+(.*))?$/.exec(criterionRaw);
return {
deal: header.deal,
...(header.conversation ? { conversationId: header.conversation } : {}),
meeting: header.meeting,
...(header.date ? { date: header.date } : {}),
...(header.at ? { at: header.at } : {}),
...(criterionMatch
? {
criterion: criterionMatch[1],
...(criterionMatch[2] ? { criterionName: criterionMatch[2] } : {}),
}
: criterionRaw
? { criterionName: criterionRaw }
: {}),
...(header.recording ? { recording: header.recording } : {}),
...(header.externalid ? { externalId: header.externalid } : {}),
...(header.provider ? { provider: header.provider.toLowerCase() } : {}),
verdict,
coaching: header.coaching,
context: sections.context,
moment: sections.moment,
reaction: sections.reaction,
};
}