| 1 | /** Versioned, vendor-neutral trace and signal contracts. All internal time is ms. */ |
| 2 | export const CATEGORIES = [ |
| 3 | 'reasoning', 'tool', 'memory', 'code', 'filesystem', 'network', 'browser', |
| 4 | 'communication', 'agent', 'orchestration', 'error', 'human', 'other', |
| 5 | ] as const; |
| 6 | export type Category = typeof CATEGORIES[number]; |
| 7 | export type Status = 'pending' | 'running' | 'success' | 'error' | 'unknown'; |
| 8 | export type PrivacyMode = 'redact' | 'metadata' | 'retain'; |
| 9 | export interface WhaleEvent { |
| 10 | observation?: import('./evidence.js').Observation; |
| 11 | schemaVersion: 1; |
| 12 | id: string; traceId: string; parentId?: string; |
| 13 | startTime: number; endTime: number; openEnded?: boolean; |
| 14 | agentId: string; agentType?: string; parentAgentId?: string; |
| 15 | category: Category; subtype?: string; name: string; |
| 16 | model?: string; provider?: string; tool?: string; |
| 17 | inputTokens?: number; outputTokens?: number; cachedTokens?: number; |
| 18 | cost?: number; costCurrency?: string; latency?: number; |
| 19 | contextTokens?: number; contextLimit?: number; retry?: number; |
| 20 | status: Status; |
| 21 | sourceId?: string; targetId?: string; targetType?: string; |
| 22 | links?: { traceId: string; spanId: string; attributes?: Record<string, unknown> }[]; |
| 23 | attributes: Record<string, unknown>; payload?: unknown; raw?: unknown; |
| 24 | } |
| 25 | export interface Trace { |
| 26 | id: string; name: string; description?: string; events: WhaleEvent[]; |
| 27 | duration: number; originTime?: string; source: 'demo' | 'jsonl' | 'otlp' | 'codewhale' | 'platform'; |
| 28 | privacy: PrivacyMode; warnings: string[]; metadata: Record<string, unknown>; |
| 29 | } |
| 30 | export interface BinLevel { |
| 31 | binMs: number; length: number; |
| 32 | /** Channel-major arrays, index = channel * length + time bin. */ |
| 33 | onsets: Float64Array; activeMs: Float64Array; outputTokens: Float64Array; |
| 34 | cost: Float64Array; errors: Float64Array; peak: Float64Array; |
| 35 | } |
| 36 | export interface SignalPyramid { |
| 37 | duration: number; channels: readonly Category[]; levels: BinLevel[]; |
| 38 | /** Fixed calibration across zoom levels; sharing this locks A/B scales. */ |
| 39 | calibration: Record<Metric, number>; |
| 40 | } |
| 41 | export type Metric = 'activity' | 'onsets' | 'tokens' | 'cost'; |
| 42 | export type FindingKind = 'loop' | 'burst' | 'gap' | 'retry' | 'divergence' | 'spawn' | 'context' | 'boundary'; |
| 43 | export interface Finding { |
| 44 | id: string; kind: FindingKind; severity: 'info' | 'warning' | 'critical'; |
| 45 | title: string; detail: string; startTime: number; endTime: number; |
| 46 | agentId?: string; eventIds: string[]; evidence: Record<string, unknown>; |
| 47 | } |
| 48 | export interface Fingerprint { |
| 49 | version: 1; channels: number[]; temporal: number[]; autocorrelation: number[]; |
| 50 | features: Record<string, number>; vector: number[]; |
| 51 | } |
| 52 | export interface Statistics { |
| 53 | events: number; agents: number; duration: number; errors: number; retries: number; |
| 54 | inputTokens: number; outputTokens: number; cachedTokens: number; |
| 55 | cost: number; costKnown: boolean; costCount: number; tokenCount: number; outputTokenCount: number; inputTokenCount: number; |
| 56 | latencyP50: number; latencyP95: number; observedGapRatio: number; |
| 57 | } |
| 58 | export interface Analysis { |
| 59 | sessionId?: string; |
| 60 | trace: Trace; pyramid: SignalPyramid; findings: Finding[]; |
| 61 | fingerprint: Fingerprint; stats: Statistics; computeMs: number; |
| 62 | } |
| 63 | export interface Filters { |
| 64 | query: string; category: string; agent: string; model: string; tool: string; status: string; |
| 65 | } |
| 66 | export const EMPTY_FILTERS: Filters = { query: '', category: '', agent: '', model: '', tool: '', status: '' }; |
| 67 | export function eventMatches(e: WhaleEvent, f: Filters): boolean { |
| 68 | if (f.category && e.category !== f.category) return false; |
| 69 | if (f.agent && e.agentId !== f.agent) return false; |
| 70 | if (f.model && e.model !== f.model) return false; |
| 71 | if (f.tool && e.tool !== f.tool) return false; |
| 72 | if (f.status && e.status !== f.status) return false; |
| 73 | if (f.query) { |
| 74 | const q = f.query.toLowerCase(); |
| 75 | // Search is deliberately content-aware but runs only over locally retained fields. |
| 76 | if (![e.name, e.id, e.agentId, e.model, e.tool, e.provider, e.category, |
| 77 | JSON.stringify(e.attributes), JSON.stringify(e.payload), JSON.stringify(e.raw)].filter(Boolean).join(' ').toLowerCase().includes(q)) return false; |
| 78 | } |
| 79 | return true; |
| 80 | } |
| 81 | export const durationOf = (e: WhaleEvent): number => Math.max(0, e.endTime - e.startTime); |
| 82 | /** An explicit failure receipt can arrive after a span began or ended. Keep |
| 83 | * its timestamp distinct from the operation onset in every signal view. */ |
| 84 | export function errorOnsetOf(e: WhaleEvent): number { |
| 85 | const time = e.attributes['whalesong.error_onset_ms']; |
| 86 | if (time === undefined) return e.startTime; |
| 87 | if (typeof time !== 'number' || !Number.isFinite(time) || time < e.startTime) |
| 88 | throw new Error('Invalid failure observation time.'); |
| 89 | return time; |
| 90 | } |
| 91 | export function stableHash(text: string, seed = 2166136261): number { |
| 92 | let h = seed; |
| 93 | for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); } |
| 94 | return h >>> 0; |
| 95 | } |
| 96 | export function quantile(a: number[], q: number): number { |
| 97 | if (!a.length) return 0; |
| 98 | const s = [...a].sort((a, b) => a - b), x = Math.min(1, Math.max(0, q)) * (s.length - 1); |
| 99 | return s[Math.floor(x)] + (s[Math.ceil(x)] - s[Math.floor(x)]) * (x % 1); |
| 100 | } |
| 101 | export function clamp(x: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, x)); } |
| 102 | export function formatTime(ms: number, precise = false): string { |
| 103 | const m = Math.floor(Math.max(0, ms) / 60000), s = Math.floor(Math.max(0, ms) / 1000) % 60; |
| 104 | return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}${precise ? '.' + String(Math.floor(ms % 1000)).padStart(3, '0') : ''}`; |
| 105 | } |
| 106 |