Copilot Conversation Exporter

Export Microsoft Copilot conversations to Markdown or JSON (optionally includes AI Thoughts when present)

As of 2025-09-27. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Copilot Conversation Exporter
// @namespace    https://github.com/NoahTheGinger/Userscripts/
// @version      2.0.0
// @description  Export Microsoft Copilot conversations to Markdown or JSON (optionally includes AI Thoughts when present)
// @author       NoahTheGinger
// @match        https://copilot.microsoft.com/*
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // ==========
  // Config
  // ==========
  const ORIGIN = new URL(location.href).origin; // https://copilot.microsoft.com
  const API_BASE = `${ORIGIN}/c/api`;
  const API_VERSION = '2'; // history endpoint supports ?api-version=2

  // ==========
  // Auth capturing (best-effort)
  // ==========
  let capturedAuthToken = null;

  function tryCaptureAuthFromHeaders(headersLike) {
    try {
      if (!headersLike) return;
      const headers = headersLike instanceof Headers ? headersLike : new Headers(headersLike);
      const auth = headers.get('Authorization') || headers.get('authorization');
      if (auth && auth.startsWith('Bearer ')) {
        capturedAuthToken = auth.replace(/^Bearer\s+/, '');
      }
    } catch {}
  }

  // Patch fetch at document-start to sniff Authorization headers sent by the app
  (function patchFetch() {
    const origFetch = window.fetch;
    window.fetch = function patchedFetch(input, init) {
      try {
        if (init && init.headers) tryCaptureAuthFromHeaders(init.headers);
        if (input && typeof input === 'object' && input.headers) tryCaptureAuthFromHeaders(input.headers);
      } catch {}
      return origFetch.apply(this, arguments);
    };
  })();

  // Patch XMLHttpRequest to sniff Authorization headers
  (function patchXHR() {
    const origOpen = XMLHttpRequest.prototype.open;
    const origSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;

    XMLHttpRequest.prototype.open = function (method, url) {
      this._url = url;
      this._method = method;
      return origOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
      try {
        if (typeof name === 'string' && name.toLowerCase() === 'authorization' && value && value.startsWith('Bearer ')) {
          capturedAuthToken = value.replace(/^Bearer\s+/, '');
        }
      } catch {}
      return origSetRequestHeader.apply(this, arguments);
    };
  })();

  // ==========
  // Utilities
  // ==========
  function sanitizeFilename(title) {
    return title.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 200);
  }

  function downloadFile(filename, mimeType, content) {
    const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.style.display = 'none';
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    }, 0);
  }

  function standardizeLineBreaks(text) {
    return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  }

  function getCurrentTimestamp() {
    return new Date().toISOString().slice(0, 19).replace(/:/g, '-');
  }

  function getChatIdFromUrl() {
    // Expected: https://copilot.microsoft.com/chats/{chatId}
    const m = location.pathname.match(/^\/chats\/([A-Za-z0-9_-]+)/i);
    return m ? m[1] : null;
  }

  function getUILanguage() {
    return document.documentElement.getAttribute('lang') || navigator.language || 'en-US';
  }

  async function ensureSession() {
    // Lightly poke the start endpoint (this sometimes ensures cookies/session are initialized)
    try {
      await fetch(`${API_BASE}/start`, {
        method: 'POST',
        credentials: 'include'
      });
    } catch {}
  }

  async function fetchJSON(url, options = {}) {
    const opts = {
      method: 'GET',
      credentials: 'include',
      headers: {},
      ...options
    };

    opts.headers = new Headers(opts.headers);

    // Prefer captured Authorization token if available
    if (capturedAuthToken && !opts.headers.has('Authorization')) {
      opts.headers.set('Authorization', `Bearer ${capturedAuthToken}`);
    }

    // Send UI language if available (consistent with site)
    const lang = getUILanguage();
    if (!opts.headers.has('x-search-uilang') && lang) {
      opts.headers.set('x-search-uilang', lang.toLowerCase());
    }

    const res = await fetch(url, opts);
    if (!res.ok) {
      let detail = res.statusText || '';
      try {
        const data = await res.json();
        detail = data?.error || data?.message || JSON.stringify(data) || detail;
      } catch {}
      throw new Error(`Request failed (${res.status}): ${detail}`);
    }
    return res.json();
  }

  async function fetchConversationHistory(chatId) {
    if (!chatId) throw new Error('No chat ID found in URL');

    // Warm session first (harmless if already warm)
    await ensureSession();

    const url = `${API_BASE}/conversations/${encodeURIComponent(chatId)}/history?api-version=${API_VERSION}`;

    try {
      return await fetchJSON(url);
    } catch (err) {
      // Retry after warming session again (in case of race/expiry)
      await ensureSession();
      return await fetchJSON(url);
    }
  }

  async function fetchConversationsList() {
    const url = `${API_BASE}/conversations${API_VERSION ? `?api-version=${API_VERSION}` : ''}`;
    try {
      return await fetchJSON(url);
    } catch {
      return null;
    }
  }

  async function lookupConversationTitle(chatId) {
    if (!chatId) return 'Copilot Conversation';
    const list = await fetchConversationsList();
    // Expected shape: maybe { results: [ { id, title, ... }, ... ] } or just an array
    try {
      const items = Array.isArray(list?.results) ? list.results : (Array.isArray(list) ? list : []);
      const found = items.find(x => x?.id === chatId);
      return found?.title || 'Copilot Conversation';
    } catch {
      return 'Copilot Conversation';
    }
  }

  // ==========
  // Transform Copilot history -> Markdown
  // ==========
  function transformAuthor(author) {
    // In history JSON: author: { type: "ai" | "human" }
    if (!author || !author.type) return 'Unknown';
    return author.type === 'ai' ? 'Assistant' : author.type === 'human' ? 'User' : author.type;
  }

  function sortByCreatedAtAsc(results) {
    try {
      return [...results].sort((a, b) => {
        const at = new Date(a.createdAt).getTime() || 0;
        const bt = new Date(b.createdAt).getTime() || 0;
        return at - bt;
      });
    } catch {
      return results;
    }
  }

  function partsToMarkdown(parts) {
    if (!Array.isArray(parts)) return { body: '', thoughts: '' };

    const textBlocks = [];
    const imageBlocks = [];
    const citations = [];
    const thoughtsBlocks = [];

    for (const part of parts) {
      switch (part?.type) {
        case 'text':
          if (typeof part.text === 'string' && part.text.trim()) {
            textBlocks.push(part.text);
          }
          break;
        case 'image': {
          // Embed image, optionally include prompt as a caption line
          if (part.url) {
            const alt = 'image';
            imageBlocks.push(`![${alt}](${part.url})`);
            if (part.prompt && String(part.prompt).trim()) {
              imageBlocks.push(`*Prompt: ${part.prompt}*`);
            }
          }
          break;
        }
        case 'citation':
          // Collect for optional "Sources" listing; avoid exact duplicates
          if (part.url) {
            const key = `${part.title || part.url}::${part.url}`;
            if (!citations.some(c => c._k === key)) {
              citations.push({ _k: key, title: part.title || part.url, url: part.url });
            }
          }
          break;
        case 'chainOfThought':
          // Reasoning content (include only if present)
          if (typeof part.text === 'string' && part.text.trim()) {
            thoughtsBlocks.push(part.text);
          }
          if (part.screenshotUrl) {
            thoughtsBlocks.push(`![screenshot](${part.screenshotUrl})`);
          }
          break;
        default:
          // Ignore unsupported part types silently
          break;
      }
    }

    const sections = [];

    if (textBlocks.length) {
      sections.push(textBlocks.join('\n\n'));
    }

    if (imageBlocks.length) {
      sections.push(imageBlocks.join('\n\n'));
    }

    // If you prefer to show citations, uncomment the block below to include a sources list:
    // if (citations.length) {
    //   const list = citations.map(c => `- [${c.title}](${c.url})`).join('\n');
    //   sections.push(`#### Sources\n${list}`);
    // }

    const body = sections.filter(Boolean).join('\n\n');
    const thoughts = thoughtsBlocks.length ? thoughtsBlocks.join('\n\n') : '';

    return { body, thoughts };
  }

    // ----------------------------------------------------------
    //  Updated markdown conversion – thoughts come first
    // ----------------------------------------------------------
  function conversationToMarkdown({ title, results }) {
    // Ensure the messages are in chronological order
    const sorted = sortByCreatedAtAsc(results || []);

    const lines = [];

    // Title
    lines.push(`# ${title || 'Copilot Conversation'}`);
    lines.push(''); // blank line after title

    // Walk through each message
    for (const msg of sorted) {
      const authorLabel = transformAuthor(msg.author);
      const { body, thoughts } = partsToMarkdown(msg.content);

      const hasBody    = body    && body.trim().length   > 0;
      const hasThought = thoughts && thoughts.trim().length > 0;

      // Skip totally empty turns
      if (!hasBody && !hasThought) continue;

      // -----------------------------------------------------------------
      // Assistant – we want Thoughts *before* the answer
      // -----------------------------------------------------------------
      if (authorLabel === 'Assistant') {
        // 1️⃣  Thoughts (if any)
        if (hasThought) {
          lines.push('#### Thoughts');
          lines.push(thoughts);
          lines.push(''); // blank line after thoughts
        }

        // 2️⃣  Assistant answer (if any)
        if (hasBody) {
          lines.push(`#### ${authorLabel}:`);
          lines.push(body);
          lines.push(''); // blank line after answer
        }

        continue; // everything for this turn is done
      }

      // -----------------------------------------------------------------
      // Any other speaker (User, Tool, …) – keep the old order
      // -----------------------------------------------------------------
      lines.push(`#### ${authorLabel}:`);
      if (hasBody) lines.push(body);
      // (No “Thoughts” section for non‑assistant messages)
      lines.push(''); // blank line after the turn
    }

    // Trim trailing new‑lines but keep a final newline for nice file endings
    return lines.join('\n').trim() + '\n';
  }


  // ==========
  // Exporters
  // ==========
  async function exportToMarkdown() {
    try {
      const chatId = getChatIdFromUrl();
      if (!chatId) {
        alert('Open a conversation page first (URL should be /chats/{id}).');
        return;
      }

      console.log('[Copilot Exporter] Fetching history...');
      const history = await fetchConversationHistory(chatId);

      console.log('[Copilot Exporter] Looking up title...');
      const title = await lookupConversationTitle(chatId);

      console.log('[Copilot Exporter] Converting to Markdown...');
      const md = conversationToMarkdown({
        title: title || 'Copilot Conversation',
        results: Array.isArray(history?.results) ? history.results : []
      });

      const safeTitle = sanitizeFilename(title || 'Copilot Conversation');
      const timestamp = getCurrentTimestamp();
      const filename = `${safeTitle}_${timestamp}.md`;

      downloadFile(filename, 'text/markdown', standardizeLineBreaks(md));
      console.log('[Copilot Exporter] Markdown export complete.');
    } catch (err) {
      console.error('[Copilot Exporter] Markdown export failed:', err);
      alert(`Markdown export failed: ${err.message}`);
    }
  }

  async function exportToJSON() {
    try {
      const chatId = getChatIdFromUrl();
      if (!chatId) {
        alert('Open a conversation page first (URL should be /chats/{id}).');
        return;
      }

      console.log('[Copilot Exporter] Fetching history (JSON)...');
      const history = await fetchConversationHistory(chatId);

      console.log('[Copilot Exporter] Looking up title...');
      const title = await lookupConversationTitle(chatId);

      const jsonContent = JSON.stringify(history, null, 2);
      const safeTitle = sanitizeFilename(title || 'Copilot Conversation');
      const timestamp = getCurrentTimestamp();
      const filename = `${safeTitle}_${timestamp}.json`;

      downloadFile(filename, 'application/json', jsonContent);
      console.log('[Copilot Exporter] JSON export complete.');
    } catch (err) {
      console.error('[Copilot Exporter] JSON export failed:', err);
      alert(`JSON export failed: ${err.message}`);
    }
  }

  // ==========
  // UI (Export button + modal)
  // ==========
  function showExportDialog() {
    const modal = document.createElement('div');
    modal.style.cssText = `
      position: fixed; inset: 0;
      background: rgba(0,0,0,0.5);
      z-index: 2147483647;
      display: flex; align-items: center; justify-content: center;
    `;

    const dialog = document.createElement('div');
    dialog.style.cssText = `
      background: white;
      border-radius: 8px;
      padding: 24px;
      min-width: 300px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
    `;

    dialog.innerHTML = `
      <h3 style="margin:0 0 16px 0; color:#333; font-size:18px;">Choose Export Format</h3>
      <p style="margin:0 0 20px 0; color:#666; font-size:14px;">Select the format you'd like to export this conversation in:</p>
      <div style="display:flex; gap:12px; justify-content:flex-end;">
        <button id="export-markdown-btn" style="
          background:#10a37f; color:white; border:none; border-radius:6px;
          padding:8px 16px; font-size:14px; cursor:pointer;
        ">Markdown (.md)</button>
        <button id="export-json-btn" style="
          background:#2563eb; color:white; border:none; border-radius:6px;
          padding:8px 16px; font-size:14px; cursor:pointer;
        ">JSON (.json)</button>
        <button id="export-cancel-btn" style="
          background:#6b7280; color:white; border:none; border-radius:6px;
          padding:8px 16px; font-size:14px; cursor:pointer;
        ">Cancel</button>
      </div>
    `;

    modal.appendChild(dialog);
    document.body.appendChild(modal);

    const markdownBtn = dialog.querySelector('#export-markdown-btn');
    const jsonBtn = dialog.querySelector('#export-json-btn');
    const cancelBtn = dialog.querySelector('#export-cancel-btn');

    markdownBtn.addEventListener('mouseenter', () => markdownBtn.style.background = '#0d9568');
    markdownBtn.addEventListener('mouseleave', () => markdownBtn.style.background = '#10a37f');

    jsonBtn.addEventListener('mouseenter', () => jsonBtn.style.background = '#1d4ed8');
    jsonBtn.addEventListener('mouseleave', () => jsonBtn.style.background = '#2563eb');

    cancelBtn.addEventListener('mouseenter', () => cancelBtn.style.background = '#4b5563');
    cancelBtn.addEventListener('mouseleave', () => cancelBtn.style.background = '#6b7280');

    markdownBtn.addEventListener('click', () => {
      document.body.removeChild(modal);
      exportToMarkdown();
    });
    jsonBtn.addEventListener('click', () => {
      document.body.removeChild(modal);
      exportToJSON();
    });
    cancelBtn.addEventListener('click', () => document.body.removeChild(modal));

    const onBgClick = (e) => {
      if (e.target === modal) {
        document.body.removeChild(modal);
        window.removeEventListener('click', onBgClick);
      }
    };
    window.addEventListener('click', onBgClick);

    const handleEscape = (e) => {
      if (e.key === 'Escape') {
        document.body.removeChild(modal);
        document.removeEventListener('keydown', handleEscape);
      }
    };
    document.addEventListener('keydown', handleEscape);
  }

  function createExportButton() {
    const btn = document.createElement('button');
    btn.id = 'copilot-export-btn';
    btn.textContent = 'Export';
    btn.title = 'Export conversation to Markdown or JSON';
    btn.style.cssText = `
      position: fixed; top: 20px; right: 20px;
      z-index: 2147483646;
      background: #10a37f; color: white;
      border: none; border-radius: 6px;
      padding: 8px 12px; font-size: 14px; font-weight: 500;
      cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    `;

    btn.addEventListener('mouseenter', () => btn.style.background = '#0d9568');
    btn.addEventListener('mouseleave', () => btn.style.background = '#10a37f');
    btn.addEventListener('click', showExportDialog);
    return btn;
  }

  function addButton() {
    try {
      const existing = document.getElementById('copilot-export-btn');
      if (existing) existing.remove();
      const btn = createExportButton();
      document.body.appendChild(btn);
    } catch (e) {
      console.warn('[Copilot Exporter] Unable to add button yet. Will retry.');
    }
  }

  function ready(fn) {
    if (document.readyState !== 'loading') {
      fn();
    } else {
      document.addEventListener('DOMContentLoaded', fn);
    }
  }

  // ==========
  // Init
  // ==========
  ready(addButton);

  // Re-inject button on SPA navigations
  let lastHref = location.href;
  setInterval(() => {
    if (location.href !== lastHref) {
      lastHref = location.href;
      setTimeout(addButton, 800);
    }
  }, 800);

})();