// The EarningsCall server's pure half: the requests EarningsCall reads, its answers read the // forgiving way, refusals put in words, and a plain transcript cut into paragraphs. No network and no // MCP here, so it runs under the tests; server.ts does the fetching and the tools. // // A plain transcript (the only kind a Starter key gets) is one string: machine transcribed, no // speaker names, no line breaks. What marks a change of speaker is what the operator says, so the // paragraphs break there: after someone addresses the operator, before the operator introduces a // question, and after the operator gives the line away. Everywhere else a paragraph runs to about six // hundred characters. It is a reading aid, not a speaker map, and it can be wrong. export const API = 'https://v2.api.earningscall.biz' /** symbols-v2.txt names an exchange by its index in this list; a ticker on two of them resolves to the first. */ export const EXCHANGES = ['NYSE', 'NASDAQ', 'AMEX', 'TSX', 'TSXV', 'OTC', 'LSE', 'CBOE', 'STO'] export interface Company { exchange: string symbol: string name: string } export interface Call { /** The company's fiscal year and quarter, as EarningsCall labels the call. */ year: number quarter: number /** When the call was held, ISO with the offset EarningsCall gave. */ date: string | null published: boolean } export interface CalendarRow { exchange: string symbol: string company: string year: number quarter: number date: string | null ready: boolean } export interface ToolResult { [key: string]: unknown content: { type: 'text'; text: string }[] structuredContent: Record } // Symbols. /** Tab-separated lines: exchange index, ticker, name, sector index, industry index. */ export function parseSymbols(text: string): Company[] { const companies: Company[] = [] for (const line of text.split('\n')) { const [index, symbol, name] = line.replace(/\r$/, '').split('\t') const exchange = EXCHANGES[Number(index)] if (!exchange || !symbol || index === '') continue companies.push({ exchange, symbol, name: name ?? symbol }) } return companies } export function findCompany(companies: Company[], symbol: string, exchange?: string): Company | null { const ticker = symbol.trim().toUpperCase() const on = exchange?.trim().toUpperCase() const listings = companies.filter((c) => c.symbol === ticker && (!on || c.exchange === on)) listings.sort((a, b) => EXCHANGES.indexOf(a.exchange) - EXCHANGES.indexOf(b.exchange)) return listings[0] ?? null } // Requests. export function symbolsUrl(apikey: string): string { return url('symbols-v2.txt', apikey, {}) } export function eventsUrl(apikey: string, company: { exchange: string; symbol: string }): string { return url('events', apikey, { exchange: company.exchange, symbol: company.symbol }) } /** Level 1, the plain transcript: every plan has it, and the enhanced levels are refused on Starter. */ export function transcriptUrl(apikey: string, company: { exchange: string; symbol: string }, year: number, quarter: number): string { return url('transcript', apikey, { exchange: company.exchange, symbol: company.symbol, year, quarter, level: 1 }) } /** The calendar takes the day in three parts; a `date` parameter is refused. */ export function calendarUrl(apikey: string, date: string): string { const [year, month, day] = date.split('-').map(Number) return url('calendar', apikey, { year: year!, month: month!, day: day! }) } function url(path: string, apikey: string, params: Record): string { const u = new URL(`${API}/${path}`) u.searchParams.set('apikey', apikey) for (const [key, value] of Object.entries(params)) u.searchParams.set(key, String(value)) return u.href } /** A day as YYYY-MM-DD in the zone the server runs in, which is the user's. */ export function localDay(at: Date): string { const pad = (n: number): string => String(n).padStart(2, '0') return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}` } // Refusals. /** What EarningsCall's status means, in words. `what` names the thing asked for. */ export function failure(status: number, what: string, headers: { get(name: string): string | null }): string { const plan = headers.get('x-plan-name') if (status === 401) return `EarningsCall refused the API key asking for ${what}. Check the key in Connections.` if (status === 403) return `EarningsCall refused ${what}: the ${plan ?? 'current'} plan does not include it.` if (status === 404) return `EarningsCall does not have ${what}.` if (status === 429) { const wait = headers.get('retry-after') return `EarningsCall's rate limit${plan ? ` on the ${plan} plan` : ''} was reached asking for ${what}; try again${wait ? ` in ${wait} s` : ' in a minute'}.` } return `EarningsCall answered ${status} asking for ${what}.` } /** The text with every occurrence of the key taken out. */ export function scrub(text: string, secret: string): string { return secret ? text.split(secret).join('…') : text } // Answers. export function readEvents(body: unknown): { company: string | null; calls: Call[] } { const value = record(body) const events = Array.isArray(value?.['events']) ? value['events'] : [] const calls: Call[] = [] for (const entry of events) { const event = record(entry) const year = event?.['year'] const quarter = event?.['quarter'] if (!isInt(year) || !isInt(quarter)) continue calls.push({ year, quarter, date: text(event?.['conference_date']), published: event?.['is_published'] !== false }) } calls.sort((a, b) => b.year - a.year || b.quarter - a.quarter) return { company: text(value?.['company_name']), calls } } /** The newest call with a transcript out; one listed ahead of its date is not. */ export function latestCall(calls: Call[]): Call | null { return calls.find((call) => call.published) ?? null } export function readCalendar(body: unknown): CalendarRow[] { const rows: CalendarRow[] = [] for (const entry of Array.isArray(body) ? body : []) { const event = record(entry) const exchange = text(event?.['exchange']) const symbol = text(event?.['symbol']) const year = event?.['year'] const quarter = event?.['quarter'] if (!exchange || !symbol || !isInt(year) || !isInt(quarter)) continue rows.push({ exchange, symbol, company: text(event?.['company_name']) ?? symbol, year, quarter, date: text(event?.['conference_date']), ready: event?.['transcript_ready'] === true, }) } return rows.sort((a, b) => instant(a.date) - instant(b.date) || (a.symbol < b.symbol ? -1 : a.symbol > b.symbol ? 1 : 0)) } // Paragraphs. const TARGET = 600 /** A question introduced after less than this stays with the operator's lead-in ("Certainly."). */ const LEAD_IN = 80 /** How far before the first question a handover ("let's open the call to questions") still starts the Q&A. */ const HANDOVER_REACH = 1500 const QUESTION = /\b(?:first|next|final|last|following) question\b[^.?!]{0,30}?\bfrom\b/i const HANDOVER = /\bopen (?:up )?the (?:call|line|lines|floor) (?:up )?(?:to|for) (?:questions|q ?& ?a)\b|\b(?:go|move|turn|proceed) (?:over )?to (?:the )?q ?& ?a\b|\bquestion[- ]and[- ]answer session\b/i const TO_OPERATOR = /^Operator,/ const GIVES_LINE = /(?:please (?:proceed|go ahead)|your line is (?:now )?open)[.!]?$/i export function sentences(text: string): string[] { const flat = text.replace(/\s+/g, ' ').trim() if (!flat) return [] const out: string[] = [] let start = 0 for (const stop of flat.matchAll(/[.?!]+["”’)]*(?= ["“(]?[A-Z0-9$])/g)) { const end = stop.index + stop[0].length out.push(flat.slice(start, end)) start = end + 1 } out.push(flat.slice(start)) return out } export function paragraphs(text: string): { paragraphs: string[]; qaStart: number | null } { const all = sentences(text) const qa = qaSentence(all) const out: string[] = [] let qaStart: number | null = null let current = '' let turnEnded = false all.forEach((sentence, i) => { const breakHere = current !== '' && (i === qa || turnEnded || current.length >= TARGET || (QUESTION.test(sentence) && current.length > LEAD_IN)) if (breakHere) { out.push(current) current = '' } if (i === qa) qaStart = out.length current = current ? `${current} ${sentence}` : sentence turnEnded = TO_OPERATOR.test(sentence) || GIVES_LINE.test(sentence) }) if (current) out.push(current) return { paragraphs: out, qaStart } } /** The sentence the Q&A opens on: after the handover just before the first question, or the first question itself. */ function qaSentence(all: string[]): number | null { const offsets: number[] = [] let at = 0 for (const sentence of all) { offsets.push(at) at += sentence.length + 1 } const first = all.findIndex((sentence) => QUESTION.test(sentence)) if (first === -1) return null for (let i = 0; i < first; i++) { if (offsets[first]! - offsets[i]! <= HANDOVER_REACH && HANDOVER.test(all[i]!)) return i + 1 } return first } // Tool results: text for the model, rows for the views. export function callsResult(company: Company, calls: Call[]): ToolResult { const lines = calls.map((call) => `${label(call)} ${day(call.date)}${call.published ? '' : ' (not published yet)'}`) const head = `${company.name} (${ticker(company)}): ${calls.length} earnings call${calls.length === 1 ? '' : 's'} on EarningsCall, newest first. Quarters are the company's fiscal ones.` return { content: [{ type: 'text', text: [head, ...lines].join('\n') }], structuredContent: { exchange: company.exchange, symbol: company.symbol, company: company.name, rows: calls }, } } export function transcriptResult(company: Company, call: { year: number; quarter: number; date: string | null }, transcript: string): ToolResult { const { paragraphs: parts, qaStart } = paragraphs(transcript) const head = `${company.name} (${ticker(company)}), ${label(call)} earnings call, ${day(call.date)}. Machine-transcribed plain transcript from EarningsCall: no speaker names, and the paragraph breaks are inferred.` const body = parts.flatMap((part, i) => (i === qaStart ? ['— Questions and answers —', part] : [part])) return { content: [{ type: 'text', text: [head, ...body].join('\n\n') }], structuredContent: { exchange: company.exchange, symbol: company.symbol, company: company.name, year: call.year, quarter: call.quarter, date: call.date, qaStart, rows: parts.map((part, i) => ({ n: i + 1, text: part })), }, } } export function calendarResult(date: string, rows: CalendarRow[]): ToolResult { const lines = rows.map( (row) => `${row.date ?? 'time unknown'} ${ticker(row)} ${row.company} ${label(row)} ${row.ready ? 'transcript ready' : 'no transcript yet'}`, ) const head = rows.length ? `Earnings calls on ${date}: ${rows.length}.` : `No earnings calls on ${date}.` return { content: [{ type: 'text', text: [head, ...lines].join('\n') }], structuredContent: { date, rows }, } } function label(call: { year: number; quarter: number }): string { return `Q${call.quarter} ${call.year}` } function ticker(company: { exchange: string; symbol: string }): string { return `${company.exchange}:${company.symbol}` } function day(date: string | null): string { return date ? date.slice(0, 10) : 'date unknown' } // Small readers. function record(value: unknown): Record | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : null } function text(value: unknown): string | null { return typeof value === 'string' && value !== '' ? value : null } function isInt(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) } function instant(date: string | null): number { const time = date ? Date.parse(date) : NaN return Number.isNaN(time) ? Number.MAX_SAFE_INTEGER : time }