import { createHash } from "node:crypto"; import { basename } from "node:path"; import { scanText } from "../secrets/scanner"; import type { AmpMetadata, ChatProviderId, ClaudeMetadata, ClineMetadata, CodexMetadata, CopilotMetadata, CursorMetadata, GeminiMetadata, GrokMetadata, OpenCodeMetadata, } from "../types/timeline"; import { type ChatMessage, type ChatRole, type ChatSession, makeMessagePreview, } from "./chat-types"; type ChatTimelineMetadata = | ClaudeMetadata | GeminiMetadata | CodexMetadata | OpenCodeMetadata | CursorMetadata | ClineMetadata | AmpMetadata | CopilotMetadata | GrokMetadata; export interface ChatUploadProject { id: string; name: string; color?: string | null; } export interface ChatUploadProjectRoot { id: string; projectId: string; path: string; detectedName?: string | null; gitRemote?: string | null; } export interface ChatUploadEvent { id: string; timestamp: string; source: ChatProviderId; sourceId: string; projectRootId: string; metadata: ChatTimelineMetadata; estimatedMinutes: number; providerId: ChatProviderId; eventType: "message"; externalThreadId: string; externalMessageId: string; content: string; contentHash: string; rawRef: string; } export interface ChatUploadEvidenceThread { id: string; providerId: ChatProviderId; externalThreadId: string; workspacePath?: string | null; workspaceHash?: string | null; title?: string | null; startedAt?: string | null; endedAt?: string | null; providerMetadata?: Record | null; } export interface ChatUploadEvidenceMessage { id: string; threadId: string; providerId: ChatProviderId; externalMessageId?: string | null; role: ChatRole; timestamp: string; contentText?: string | null; contentHash?: string | null; toolName?: string | null; toolStatus?: string | null; tokenIn?: number | null; tokenOut?: number | null; } export interface ChatUploadPayload { deviceId: string; deviceName?: string; machineId?: string; projects: ChatUploadProject[]; projectRoots: ChatUploadProjectRoot[]; events: ChatUploadEvent[]; evidenceThreads: ChatUploadEvidenceThread[]; evidenceMessages: ChatUploadEvidenceMessage[]; } export interface BuildChatUploadPayloadOptions { sessions: ChatSession[]; deviceId: string; deviceName?: string; machineId?: string; includeRoles?: readonly ChatRole[]; defaultProjectColor?: string; } export interface BuildChatUploadPayloadResult { payload: ChatUploadPayload; stats: { sessions: number; messages: number; includedMessages: number; projects: number; roots: number; events: number; }; } interface ProjectMapping { projectId: string; rootId: string; } function sanitizeChatContent(text: string): string { const findings = scanText(text); if (findings.length === 0) return text; let sanitized = text; for (const finding of findings) { if (finding.match === "[seed phrase detected]") continue; sanitized = sanitized.split(finding.match).join("[REDACTED]"); } return sanitized; } function hashText(text: string): string { return createHash("sha256").update(text).digest("hex"); } function stableId(prefix: string, ...parts: string[]): string { const digest = createHash("sha1").update(parts.join("|")).digest("hex"); return `${prefix}_${digest.slice(0, 24)}`; } function titleCaseProvider(provider: ChatProviderId): string { return provider === "opencode" ? "OpenCode" : provider[0].toUpperCase() + provider.slice(1); } function getProjectPath(session: ChatSession): string { if (session.projectPath) return session.projectPath; if (session.projectHash) { return `virtual://${session.providerId}/${session.projectHash}`; } return `virtual://${session.providerId}/thread/${session.externalThreadId}`; } function getProjectName(session: ChatSession, projectPath: string): string { const pathName = basename(projectPath); if ( pathName && pathName !== "/" && pathName !== "." && pathName !== ".." && pathName !== "thread" ) { return pathName; } if (session.projectHash) { return `${titleCaseProvider(session.providerId)} ${session.projectHash.slice(0, 8)}`; } return `${titleCaseProvider(session.providerId)} Sessions`; } function getProjectKey(session: ChatSession): string { if (session.projectPath) return `path:${session.projectPath}`; if (session.projectHash) return `hash:${session.providerId}:${session.projectHash}`; return `thread:${session.providerId}:${session.externalThreadId}`; } export function estimateChatSessionMinutes(messages: ChatMessage[]): number { if (messages.length === 0) return 0; if (messages.length === 1) return 5; const sorted = [...messages].sort( (a, b) => a.timestamp.getTime() - b.timestamp.getTime(), ); const first = sorted[0]?.timestamp; const last = sorted[sorted.length - 1]?.timestamp; if (!first || !last) return 5; const durationMinutes = Math.min( (last.getTime() - first.getTime()) / 60000, 120, ); return Math.max(Math.round(durationMinutes), 5); } export function buildChatTimelineMetadata( provider: ChatProviderId, session: ChatSession, message: ChatMessage, ): ChatTimelineMetadata { const base = { sessionId: session.externalThreadId, messagePreview: makeMessagePreview(message.text), conversationFile: session.conversationFile, projectPath: session.projectPath, providerId: provider, externalMessageId: message.externalMessageId, messageRole: message.role, }; if (provider === "gemini") { return { ...base, providerId: "gemini", projectHash: session.projectHash, }; } if (provider === "codex") { return { ...base, providerId: "codex", }; } if (provider === "opencode") { return { ...base, providerId: "opencode", }; } if (provider === "cursor") { return { ...base, providerId: "cursor", workspaceHash: session.projectHash, }; } if (provider === "cline") { return { ...base, providerId: "cline", }; } if (provider === "amp") { return { ...base, providerId: "amp", }; } if (provider === "copilot") { return { ...base, providerId: "copilot", workspaceHash: session.projectHash, }; } if (provider === "grok") { const model = session.metadata?.model; return { ...base, providerId: "grok", model: typeof model === "string" ? model : undefined, }; } return { ...base, providerId: "claude", }; } export function buildChatUploadPayload( options: BuildChatUploadPayloadOptions, ): BuildChatUploadPayloadResult { const includeRoles = new Set(options.includeRoles ?? ["user"]); const projectMap = new Map(); const threadMap = new Map(); const projects: ChatUploadProject[] = []; const projectRoots: ChatUploadProjectRoot[] = []; const events: ChatUploadEvent[] = []; const evidenceThreads: ChatUploadEvidenceThread[] = []; const evidenceMessages: ChatUploadEvidenceMessage[] = []; let totalMessages = 0; let includedMessages = 0; for (const session of options.sessions) { totalMessages += session.messages.length; const included = session.messages.filter((message) => includeRoles.has(message.role), ); if (included.length === 0) continue; const projectKey = getProjectKey(session); let mapping = projectMap.get(projectKey); if (!mapping) { const projectPath = getProjectPath(session); const projectName = getProjectName(session, projectPath); const projectId = stableId("proj", session.providerId, projectKey); const rootId = stableId( "root", session.providerId, projectPath, projectId, ); projects.push({ id: projectId, name: projectName, color: options.defaultProjectColor ?? null, }); projectRoots.push({ id: rootId, projectId, path: projectPath, detectedName: projectName, }); mapping = { projectId, rootId }; projectMap.set(projectKey, mapping); } const sessionMinutes = estimateChatSessionMinutes(session.messages); const perMessageMinutes = Math.max( Math.round(sessionMinutes / included.length), 2, ); const threadKey = `${session.providerId}:${session.externalThreadId}`; let threadId = threadMap.get(threadKey); if (!threadId) { threadId = stableId( "thread", session.providerId, session.externalThreadId, ); threadMap.set(threadKey, threadId); evidenceThreads.push({ id: threadId, providerId: session.providerId, externalThreadId: session.externalThreadId, workspacePath: session.projectPath ?? null, workspaceHash: session.projectHash ?? null, title: session.title ?? null, startedAt: session.startedAt?.toISOString() ?? null, endedAt: session.endedAt?.toISOString() ?? null, providerMetadata: session.metadata ?? null, }); } for (const message of included) { includedMessages++; const content = sanitizeChatContent(message.text); const eventId = stableId( "evt", session.providerId, session.externalThreadId, message.externalMessageId, ); events.push({ id: eventId, timestamp: message.timestamp.toISOString(), source: session.providerId, sourceId: message.externalMessageId, projectRootId: mapping.rootId, metadata: buildChatTimelineMetadata( session.providerId, session, message, ), estimatedMinutes: perMessageMinutes, providerId: session.providerId, eventType: "message", externalThreadId: session.externalThreadId, externalMessageId: message.externalMessageId, content, contentHash: hashText(content), rawRef: `${session.conversationFile}#${message.externalMessageId}`, }); evidenceMessages.push({ id: stableId( "msg", session.providerId, session.externalThreadId, message.externalMessageId, ), threadId, providerId: session.providerId, externalMessageId: message.externalMessageId, role: message.role, timestamp: message.timestamp.toISOString(), contentText: content, contentHash: hashText(content), toolName: message.toolName ?? null, toolStatus: null, tokenIn: message.tokenIn ?? null, tokenOut: message.tokenOut ?? null, }); } } return { payload: { deviceId: options.deviceId, deviceName: options.deviceName, machineId: options.machineId, projects, projectRoots, events, evidenceThreads, evidenceMessages, }, stats: { sessions: options.sessions.length, messages: totalMessages, includedMessages, projects: projects.length, roots: projectRoots.length, events: events.length, }, }; }