/** * Perplexity Adapter — parse Perplexity conversation exports. * * Perplexity's export (via account → settings → data export) delivers * threads as JSON. Two shapes seen in the wild: * * { * "threads": [{ "id", "title", "created_at", "messages": [...] }] * } * * or a bare array of threads. Also handles a per-thread "messages" * variant where each message has: role, content, sources? (citations). * * Sources/citations per assistant message are flattened into the text * body as "Sources: , " — they're the distinguishing * feature of Perplexity answers and should survive into the harvest * pipeline for downstream attribution. */ import { randomUUID } from 'node:crypto'; import type { SourceAdapter, UniversalImportItem, ConversationMessage } from './types.js'; export class PerplexityAdapter implements SourceAdapter { readonly sourceType = 'perplexity' as const; readonly displayName = 'Perplexity'; parse(input: unknown): UniversalImportItem[] { if (Array.isArray(input)) { return this.parseThreadArray(input); } const root = input as Record | null; if (!root || typeof root !== 'object') return []; // Common wrapper keys observed in Perplexity exports if (Array.isArray(root.threads)) { return this.parseThreadArray(root.threads as unknown[]); } if (Array.isArray(root.conversations)) { return this.parseThreadArray(root.conversations as unknown[]); } if (Array.isArray(root.items)) { return this.parseThreadArray(root.items as unknown[]); } // Single-thread shape: the root itself has messages if (Array.isArray(root.messages)) { return this.parseSingleThread(root); } return []; } private parseThreadArray(threads: unknown[]): UniversalImportItem[] { const items: UniversalImportItem[] = []; for (const raw of threads) { const thread = raw as Record | null; if (!thread || typeof thread !== 'object') continue; const built = this.buildItem(thread); if (built) items.push(built); } return items; } private parseSingleThread(thread: Record): UniversalImportItem[] { const built = this.buildItem(thread); return built ? [built] : []; } private buildItem(thread: Record): UniversalImportItem | null { const rawMessages = (thread.messages ?? thread.turns ?? []) as unknown[]; if (!Array.isArray(rawMessages) || rawMessages.length === 0) return null; const messages: ConversationMessage[] = []; for (const rawMsg of rawMessages) { const msg = rawMsg as Record | null; if (!msg) continue; const role = this.resolveRole(msg); if (!role || role === 'system') continue; const text = this.extractText(msg); if (!text) continue; // Flatten citations/sources into the text so they survive the pipeline. const sources = this.extractSources(msg); const textWithSources = sources.length > 0 ? `${text}\n\nSources: ${sources.join(', ')}` : text; messages.push({ role, text: textWithSources, timestamp: this.extractTimestamp(msg), }); } if (messages.length === 0) return null; const title = (thread.title as string) ?? (thread.name as string) ?? 'Perplexity Thread'; return { id: randomUUID(), source: 'perplexity', type: 'conversation', title, content: messages.map(m => `${m.role}: ${m.text}`).join('\n\n'), messages, timestamp: this.extractTimestamp(thread) ?? new Date().toISOString(), metadata: { threadId: thread.id ?? thread.threadId, messageCount: messages.length, hasCitations: messages.some(m => m.text.includes('Sources:')), }, }; } private resolveRole(entry: Record): 'user' | 'assistant' | 'system' | null { const raw = entry.role ?? entry.author ?? entry.sender ?? entry.type; if (!raw) return null; const r = String(raw).toLowerCase(); if (r === 'user' || r === 'human' || r === 'question') return 'user'; if (r === 'assistant' || r === 'ai' || r === 'perplexity' || r === 'answer') return 'assistant'; if (r === 'system') return 'system'; return null; } private extractText(entry: Record): string { // Try common text field names if (typeof entry.content === 'string') return entry.content.trim(); if (typeof entry.text === 'string') return entry.text.trim(); if (typeof entry.answer === 'string') return entry.answer.trim(); if (typeof entry.query === 'string') return entry.query.trim(); // ChatGPT-like structured content: { parts: [...] } const content = entry.content as Record | undefined; if (content && Array.isArray(content.parts)) { return content.parts.filter(p => typeof p === 'string').join('\n').trim(); } return ''; } private extractSources(entry: Record): string[] { const raw = entry.sources ?? entry.citations ?? entry.web_results; if (!Array.isArray(raw)) return []; const urls: string[] = []; for (const src of raw) { if (typeof src === 'string') { urls.push(src); } else if (src && typeof src === 'object') { const s = src as Record; const url = s.url ?? s.link ?? s.href; if (typeof url === 'string') urls.push(url); } } return urls; } private extractTimestamp(entry: Record): string | undefined { const raw = entry.timestamp ?? entry.created_at ?? entry.createdAt ?? entry.time; if (typeof raw === 'string') return raw; if (typeof raw === 'number') return new Date(raw).toISOString(); return undefined; } }