/** * Turns API payloads into compact, LLM-friendly text while keeping tool outputs bounded. * * Tabular payloads are rendered as `{"columns":[…],"rows":[[…]]}` (about half the tokens of an * array of objects). Above `fullRowsThreshold` rows only the head/tail are kept, together with the * total count, the columns, and a hint to narrow the request. */ import { describeRate, type ApiResult, type RateInfo } from './client.js'; export const DEFAULT_LIMIT = 500; export const MAX_LIMIT = 5000; export const FULL_ROWS_THRESHOLD = 200; export const HEAD_TAIL = 15; export type Row = Record; export interface Extracted { rows: Row[] | null; meta: Record; /** Anything that is not the rows nor the meta (e.g. v1 `tickers` list). */ rest: Record; } /** Understand both envelopes: v1 `{count,data}` and v2 `{data,meta}`, plus bare arrays. */ export function extract(body: unknown): Extracted { if (Array.isArray(body)) return { rows: body.every(isRow) ? (body as Row[]) : null, meta: {}, rest: body.every(isRow) ? {} : { data: body } }; if (!body || typeof body !== 'object') return { rows: null, meta: {}, rest: { value: body } }; const b = body as Record; const meta: Record = typeof b.meta === 'object' && b.meta ? { ...(b.meta as Record) } : {}; if (typeof b.count === 'number' && meta.count === undefined) meta.count = b.count; const rest: Record = {}; for (const [k, v] of Object.entries(b)) if (k !== 'data' && k !== 'meta' && k !== 'count') rest[k] = v; const data = b.data; if (Array.isArray(data)) { if (data.every(isRow)) return { rows: data as Row[], meta, rest }; return { rows: null, meta, rest: { ...rest, data } }; } if (data && typeof data === 'object') return { rows: null, meta, rest: { ...rest, data } }; return { rows: null, meta, rest }; } function isRow(x: unknown): x is Row { return !!x && typeof x === 'object' && !Array.isArray(x); } export function columnsOf(rows: Row[]): string[] { const cols: string[] = []; const seen = new Set(); for (const r of rows.slice(0, 50)) for (const k of Object.keys(r)) if (!seen.has(k)) { seen.add(k); cols.push(k); } return cols; } function cell(v: unknown): unknown { if (typeof v === 'number' && !Number.isInteger(v)) return Number(v.toFixed(6)); return v === undefined ? null : v; } export function toTable(rows: Row[], columns = columnsOf(rows)): { columns: string[]; rows: unknown[][] } { return { columns, rows: rows.map((r) => columns.map((c) => cell(r[c]))) }; } export interface RenderOptions { /** Rows above which the output is summarised (head + tail). */ fullRowsThreshold?: number; headTail?: number; title?: string; hint?: string; } /** Render a full API result as text. */ export function renderResult(result: ApiResult, opts: RenderOptions = {}): string { const { rows, meta, rest } = extract(result.body); const lines: string[] = []; if (opts.title) lines.push(opts.title); const out: Record = {}; if (rows) { const threshold = opts.fullRowsThreshold ?? FULL_ROWS_THRESHOLD; const ht = opts.headTail ?? HEAD_TAIL; const total = rows.length; const columns = columnsOf(rows); out.row_count = total; if (typeof meta.count === 'number' && meta.count !== total) out.count_reported = meta.count; out.columns = columns; if (total <= threshold) { out.rows = toTable(rows, columns).rows; } else { out.truncated = true; out.first_rows = toTable(rows.slice(0, ht), columns).rows; out.last_rows = toTable(rows.slice(-ht), columns).rows; out.numeric_summary = numericSummary(rows, columns); out.hint = opts.hint ?? `Showing ${ht} first and ${ht} last rows of ${total}. Narrow the date range, use a coarser timeframe, or lower \`limit\` to see every row.`; } if (meta.next_cursor) out.next_cursor = meta.next_cursor; } const metaRest = { ...meta }; delete metaRest.count; if (Object.keys(metaRest).length) out.meta = metaRest; for (const [k, v] of Object.entries(rest)) out[k] = shrink(v); lines.push(JSON.stringify(out)); const rate = describeRate(result.rate); if (rate) lines.push(rate); return lines.join('\n'); } /** Long arrays inside non-tabular payloads (e.g. 7 000 tickers) get truncated with a count. */ export function shrink(v: unknown, max = 400): unknown { if (Array.isArray(v) && v.length > max) return { count: v.length, first: v.slice(0, max), truncated: true }; if (v && typeof v === 'object' && !Array.isArray(v)) { const o: Record = {}; for (const [k, x] of Object.entries(v as Record)) o[k] = shrink(x, max); return o; } return v; } export function numericSummary(rows: Row[], columns: string[]): Record { const s: Record = {}; for (const c of columns) { let min = Infinity, max = -Infinity, first: number | null = null, last: number | null = null, n = 0; for (const r of rows) { const v = r[c]; if (typeof v !== 'number' || !Number.isFinite(v)) continue; n++; if (first === null) first = v; last = v; if (v < min) min = v; if (v > max) max = v; } if (n >= 2 && c !== 'datetime') s[c] = { min: Number(min.toFixed(6)), max: Number(max.toFixed(6)), first, last }; } return s; } export function rateLine(rate: RateInfo | undefined): string { return describeRate(rate) ?? 'Rate limit: headers not provided by the server'; }