code icon Code

Connect to Slack

Collect your Slack messages from the last 90 days for profile analysis

Source Code

import fs from "fs";
import path from "path";

const [outputPath = "session/writing-samples.json"] = process.argv.slice(2);

const ninetyDaysAgo = Math.floor(
  (Date.now() - 90 * 24 * 60 * 60 * 1000) / 1000
);
const formatDate = (d) =>
  d.toLocaleDateString("en-US", { month: "short", day: "numeric" });

console.log("Collecting your Slack messages (last 90 days)...");

try {
  // 1. Get auth, channels, DMs, and users in parallel
  console.log("Fetching workspace data...");

  const [authData, channelsData, dmsData, usersData] = await Promise.all([
    (async () => {
      const res = await fetch("https://slack.com/api/auth.test", {
        headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
      });
      const data = await res.json();
      if (!data.ok) throw new Error(`Auth failed: ${data.error}`);
      return data;
    })(),

    (async () => {
      const channels = [];
      let cursor = null;
      do {
        const params = new URLSearchParams({
          types: "public_channel,private_channel",
          limit: "200",
          exclude_archived: "true",
        });
        if (cursor) params.set("cursor", cursor);
        const res = await fetch(
          "https://slack.com/api/conversations.list?" + params,
          { headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" } }
        );
        const data = await res.json();
        if (!data.ok)
          throw new Error(`conversations.list failed: ${data.error}`);
        channels.push(...(data.channels || []));
        cursor = data.response_metadata?.next_cursor;
      } while (cursor);
      return channels;
    })(),

    (async () => {
      const res = await fetch(
        "https://slack.com/api/conversations.list?" +
          new URLSearchParams({
            types: "im,mpim",
            limit: "100",
          }),
        { headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" } }
      );
      const data = await res.json();
      return data.ok ? data.channels || [] : [];
    })(),

    (async () => {
      const users = [];
      let cursor = null;
      do {
        const params = new URLSearchParams({ limit: "200" });
        if (cursor) params.set("cursor", cursor);
        const res = await fetch("https://slack.com/api/users.list?" + params, {
          headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
        });
        const data = await res.json();
        if (!data.ok) throw new Error(`users.list failed: ${data.error}`);
        users.push(...(data.members || []));
        cursor = data.response_metadata?.next_cursor;
      } while (cursor);
      return users;
    })(),
  ]);

  const userId = authData.user_id;
  const userName = authData.user;
  const workspace = authData.team;
  console.log(`✓ Connected as @${userName} in ${workspace}`);

  // Build user lookup
  const userMap = new Map();
  for (const u of usersData) {
    userMap.set(u.id, {
      name: u.profile?.display_name || u.real_name || u.name,
      realName: u.real_name,
    });
  }

  // Helper to expand <@USERID> references to @username
  const expandUserRefs = (text) => {
    if (!text) return text;
    return text.replace(/<@(U[A-Z0-9]+)>/g, (match, id) => {
      const user = userMap.get(id);
      return user ? `@${user.name}` : match;
    });
  };

  // Get channels user is member of
  const myChannels = channelsData.filter((ch) => ch.is_member);
  console.log(
    `  ${myChannels.length} channels, ${dmsData.length} DM contacts, ${userMap.size} users`
  );

  // 2. Fetch history from channels
  console.log("Fetching channel message history...");

  const fetchHistory = async (channelId) => {
    const params = new URLSearchParams({
      channel: channelId,
      limit: "200",
      oldest: ninetyDaysAgo.toString(),
    });
    const res = await fetch(
      "https://slack.com/api/conversations.history?" + params,
      { headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" } }
    );
    const data = await res.json();
    return data.ok ? data.messages || [] : [];
  };

  // Fetch channel histories in parallel (batch to avoid rate limits)
  const histories = [];

  for (let i = 0; i < myChannels.length; i += 25) {
    const batch = myChannels.slice(i, i + 25);
    const results = await Promise.all(
      batch.map(async (conv) => ({
        conv,
        messages: await fetchHistory(conv.id),
      }))
    );
    histories.push(...results);
  }

  // 3. Extract user's messages AND mentions of user
  const userMessages = [];
  const mentionsOfUser = [];
  const mentionedUserIds = new Set();
  const mentionPattern = /<@(U[A-Z0-9]+)>/g;
  const userMentionPattern = new RegExp(`<@${userId}>`, "g");

  for (const { conv, messages } of histories) {
    for (const msg of messages) {
      if (msg.user === userId && msg.text) {
        // Messages FROM the user
        let match;
        while ((match = mentionPattern.exec(msg.text)) !== null) {
          mentionedUserIds.add(match[1]);
        }

        userMessages.push({
          text: expandUserRefs(msg.text),
          ts: msg.ts,
          timestamp: new Date(parseFloat(msg.ts) * 1000).toISOString(),
          channel: {
            id: conv.id,
            name: conv.name,
            type: conv.is_private ? "private_channel" : "channel",
          },
          thread_ts: msg.thread_ts,
          is_thread_reply: msg.thread_ts && msg.ts !== msg.thread_ts,
          reactions:
            msg.reactions?.map((r) => ({ name: r.name, count: r.count })) ||
            null,
        });
      } else if (msg.user !== userId && msg.text?.includes(`<@${userId}>`)) {
        // Mentions OF the user (others mentioning them)
        mentionsOfUser.push({
          text: expandUserRefs(msg.text),
          from: userMap.get(msg.user)?.name || msg.user,
          timestamp: new Date(parseFloat(msg.ts) * 1000).toISOString(),
          channel: {
            id: conv.id,
            name: conv.name,
          },
        });
      }
    }
  }

  console.log(
    `  Found ${userMessages.length} messages from you, ${mentionsOfUser.length} mentions of you`
  );

  if (userMessages.length === 0) {
    console.error("\n✗ No messages found from you in the last 90 days.");
    console.log(
      JSON.stringify({
        success: false,
        error: "no_messages_found",
        user: userName,
        workspace,
        channelsChecked: myChannels.length,
      })
    );
    process.exit(1);
  }

  // 4. Fetch thread context for replies (batch in parallel)
  const threadReplies = userMessages.filter((m) => m.is_thread_reply);
  console.log(
    `  ${threadReplies.length} are thread replies, fetching context...`
  );

  // Group by channel+thread
  const threadGroups = new Map();
  for (const msg of threadReplies) {
    const key = `${msg.channel.id}:${msg.thread_ts}`;
    if (!threadGroups.has(key)) {
      threadGroups.set(key, {
        channelId: msg.channel.id,
        threadTs: msg.thread_ts,
      });
    }
  }

  const threadContextMap = new Map();
  const threadParentAuthors = new Map(); // Track who started threads user replied to
  const threadEntries = [...threadGroups.entries()];

  for (let i = 0; i < threadEntries.length; i += 25) {
    const batch = threadEntries.slice(i, i + 25);
    const results = await Promise.all(
      batch.map(async ([key, { channelId, threadTs }]) => {
        const params = new URLSearchParams({
          channel: channelId,
          ts: threadTs,
          limit: "10",
          inclusive: "true",
        });
        const res = await fetch(
          "https://slack.com/api/conversations.replies?" + params,
          { headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" } }
        );
        const data = await res.json();
        if (data.ok && data.messages?.length > 0) {
          // Track thread parent author (person user is replying to)
          const parentMsg = data.messages[0];
          if (parentMsg.user && parentMsg.user !== userId) {
            threadParentAuthors.set(key, parentMsg.user);
          }

          // Get up to 5 preceding messages for context
          const contextMessages = data.messages.slice(0, 5).map((m) => ({
            text: expandUserRefs(m.text),
            user: userMap.get(m.user)?.name || m.user,
            userId: m.user,
            timestamp: new Date(parseFloat(m.ts) * 1000).toISOString(),
          }));
          return [key, contextMessages];
        }
        return [key, null];
      })
    );
    for (const [key, context] of results) {
      if (context) threadContextMap.set(key, context);
    }
  }

  console.log(`  Retrieved ${threadContextMap.size} thread parents`);

  // 5. Build final output
  const messages = userMessages.map((msg) => {
    let threadContext = null;
    if (msg.is_thread_reply) {
      const key = `${msg.channel.id}:${msg.thread_ts}`;
      const context = threadContextMap.get(key);
      if (context) {
        threadContext = context; // Array of preceding messages
      }
    }

    return {
      text: msg.text,
      timestamp: msg.timestamp,
      channel: msg.channel,
      thread_context: threadContext,
      reactions: msg.reactions || null,
    };
  });

  // Sort newest first
  messages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
  mentionsOfUser.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));

  // Build DM contacts list (we can list DMs but not read messages)
  const dmContacts = dmsData
    .filter((dm) => dm.user && userMap.has(dm.user))
    .map((dm) => ({
      id: dm.id,
      userId: dm.user,
      name: userMap.get(dm.user)?.name,
    }));

  // Build active channels map with message counts
  const channelCounts = new Map();
  for (const msg of userMessages) {
    const key = msg.channel.id;
    if (!channelCounts.has(key)) {
      channelCounts.set(key, { ...msg.channel, messageCount: 0 });
    }
    channelCounts.get(key).messageCount++;
  }
  const activeChannels = [...channelCounts.values()].sort(
    (a, b) => b.messageCount - a.messageCount
  );

  // Build collaborators with multi-signal scoring
  const collaboratorScores = new Map();
  const addScore = (targetUserId, signal, weight = 1) => {
    if (!targetUserId || targetUserId === userId) return; // Skip self
    if (!collaboratorScores.has(targetUserId)) {
      collaboratorScores.set(targetUserId, {
        repliedToMe: 0,
        iRepliedTo: 0,
        hasDM: false,
        iMentioned: 0,
      });
    }
    const scores = collaboratorScores.get(targetUserId);
    if (signal === "repliedToMe") scores.repliedToMe += weight;
    else if (signal === "iRepliedTo") scores.iRepliedTo += weight;
    else if (signal === "hasDM") scores.hasDM = true;
    else if (signal === "iMentioned") scores.iMentioned += weight;
  };

  // Signal 1: People who mentioned me (strong - they're engaging with me)
  for (const mention of mentionsOfUser) {
    const mentionerId = [...userMap.entries()].find(
      ([_, u]) => u.name === mention.from
    )?.[0];
    if (mentionerId) addScore(mentionerId, "repliedToMe");
  }

  // Signal 2: People whose threads I replied to (strong - I'm engaging with them)
  for (const [_, parentUserId] of threadParentAuthors) {
    addScore(parentUserId, "iRepliedTo");
  }

  // Signal 3: DM contacts (strong - we have a direct relationship)
  const dmContactIds = new Set(dmsData.map((dm) => dm.user).filter(Boolean));
  for (const dmUserId of dmContactIds) {
    addScore(dmUserId, "hasDM");
  }

  // Signal 4: People I mentioned (weak - could be broadcasting)
  for (const mentionedId of mentionedUserIds) {
    addScore(mentionedId, "iMentioned");
  }

  // Build collaborators array with scores
  const collaborators = [...collaboratorScores.entries()]
    .filter(([id]) => userMap.has(id))
    .map(([id, scores]) => ({
      id,
      name: userMap.get(id).name,
      signals: {
        theyMentionedMe: scores.repliedToMe,
        iRepliedToThem: scores.iRepliedTo,
        hasDM: scores.hasDM,
        iMentionedThem: scores.iMentioned,
      },
      // Score: bidirectional interaction > unidirectional
      interactionScore:
        scores.repliedToMe * 3 + // They engaged with me (strong)
        scores.iRepliedTo * 3 + // I engaged with them (strong)
        (scores.hasDM ? 5 : 0) + // We have a direct channel (strong)
        scores.iMentioned * 1, // I mentioned them (weak)
    }))
    .sort((a, b) => b.interactionScore - a.interactionScore);

  const output = {
    user: userName,
    workspace,
    period: `${formatDate(new Date(ninetyDaysAgo * 1000))} - ${formatDate(
      new Date()
    )}`,
    messagesFrom: messages,
    mentionsOf: mentionsOfUser,
    activeChannels,
    collaborators,
    dmContacts,
  };

  // Write output
  const dir = path.dirname(outputPath);
  if (dir && dir !== ".") fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));

  console.log(
    `\n✓ Collected ${messages.length} messages from, ${mentionsOfUser.length} mentions of`
  );
  console.log(`  Output: ${outputPath}`);
  console.log(
    JSON.stringify({
      success: true,
      outputPath,
      user: userName,
      workspace,
      messagesFromCount: messages.length,
      mentionsOfCount: mentionsOfUser.length,
      threadContextCount: threadContextMap.size,
    })
  );
} catch (error) {
  console.error("Failed:", error.message);
  throw error;
}
                  import fs from "fs";
import path from "path";

const [outputPath = "session/writing-samples.json"] = process.argv.slice(2);

const ninetyDaysAgo = Math.floor(
  (Date.now() - 90 * 24 * 60 * 60 * 1000) / 1000
);
const formatDate = (d) =>
  d.toLocaleDateString("en-US", { month: "short", day: "numeric" });

console.log("Collecting your Slack messages (last 90 days)...");

try {
  // 1. Get auth, channels, DMs, and users in parallel
  console.log("Fetching workspace data...");

  const [authData, channelsData, dmsData, usersData] = await Promise.all([
    (async () => {
      const res = await fetch("https://slack.com/api/auth.test", {
        headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
      });
      const data = await res.json();
      if (!data.ok) throw new Error(`Auth failed: ${data.error}`);
      return data;
    })(),

    (async () => {
      const channels = [];
      let cursor = null;
      do {
        const params = new URLSearchParams({
          types: "public_channel,private_channel",
          limit: "200",
          exclude_archived: "true",
        });
        if (cursor) params.set("cursor", cursor);
        const res = await fetch(
          "https://slack.com/api/conversations.list?" + params,
          { headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" } }
        );
        const data = await res.json();
        if (!data.ok)
          throw new Error(`conversations.list failed: ${data.error}`);
        channels.push(...(data.channels || []));
        cursor = data.response_metadata?.next_cursor;
      } while (cursor);
      return channels;
    })(),

    (async () => {
      const res = await fetch(
        "https://slack.com/api/conversations.list?" +
          new URLSearchParams({
            types: "im,mpim",
            limit: "100",
          }),
        { headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" } }
      );
      const data = await res.json();
      return data.ok ? data.channels || [] : [];
    })(),

    (async () => {
      const users = [];
      let cursor = null;
      do {
        const params = new URLSearchParams({ limit: "200" });
        if (cursor) params.set("cursor", cursor);
        const res = await fetch("https://slack.com/api/users.list?" + params, {
          headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
        });
        const data = await res.json();
        if (!data.ok) throw new Error(`users.list failed: ${data.error}`);
        users.push(...(data.members || []));
        cursor = data.response_metadata?.next_cursor;
      } while (cursor);
      return users;
    })(),
  ]);

  const userId = authData.user_id;
  const userName = authData.user;
  const workspace = authData.team;
  console.log(`✓ Connected as @${userName} in ${workspace}`);

  // Build user lookup
  const userMap = new Map();
  for (const u of usersData) {
    userMap.set(u.id, {
      name: u.profile?.display_name || u.real_name || u.name,
      realName: u.real_name,
    });
  }

  // Helper to expand <@USERID> references to @username
  const expandUserRefs = (text) => {
    if (!text) return text;
    return text.replace(/<@(U[A-Z0-9]+)>/g, (match, id) => {
      const user = userMap.get(id);
      return user ? `@${user.name}` : match;
    });
  };

  // Get channels user is member of
  const myChannels = channelsData.filter((ch) => ch.is_member);
  console.log(
    `  ${myChannels.length} channels, ${dmsData.length} DM contacts, ${userMap.size} users`
  );

  // 2. Fetch history from channels
  console.log("Fetching channel message history...");

  const fetchHistory = async (channelId) => {
    const params = new URLSearchParams({
      channel: channelId,
      limit: "200",
      oldest: ninetyDaysAgo.toString(),
    });
    const res = await fetch(
      "https://slack.com/api/conversations.history?" + params,
      { headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" } }
    );
    const data = await res.json();
    return data.ok ? data.messages || [] : [];
  };

  // Fetch channel histories in parallel (batch to avoid rate limits)
  const histories = [];

  for (let i = 0; i < myChannels.length; i += 25) {
    const batch = myChannels.slice(i, i + 25);
    const results = await Promise.all(
      batch.map(async (conv) => ({
        conv,
        messages: await fetchHistory(conv.id),
      }))
    );
    histories.push(...results);
  }

  // 3. Extract user's messages AND mentions of user
  const userMessages = [];
  const mentionsOfUser = [];
  const mentionedUserIds = new Set();
  const mentionPattern = /<@(U[A-Z0-9]+)>/g;
  const userMentionPattern = new RegExp(`<@${userId}>`, "g");

  for (const { conv, messages } of histories) {
    for (const msg of messages) {
      if (msg.user === userId && msg.text) {
        // Messages FROM the user
        let match;
        while ((match = mentionPattern.exec(msg.text)) !== null) {
          mentionedUserIds.add(match[1]);
        }

        userMessages.push({
          text: expandUserRefs(msg.text),
          ts: msg.ts,
          timestamp: new Date(parseFloat(msg.ts) * 1000).toISOString(),
          channel: {
            id: conv.id,
            name: conv.name,
            type: conv.is_private ? "private_channel" : "channel",
          },
          thread_ts: msg.thread_ts,
          is_thread_reply: msg.thread_ts && msg.ts !== msg.thread_ts,
          reactions:
            msg.reactions?.map((r) => ({ name: r.name, count: r.count })) ||
            null,
        });
      } else if (msg.user !== userId && msg.text?.includes(`<@${userId}>`)) {
        // Mentions OF the user (others mentioning them)
        mentionsOfUser.push({
          text: expandUserRefs(msg.text),
          from: userMap.get(msg.user)?.name || msg.user,
          timestamp: new Date(parseFloat(msg.ts) * 1000).toISOString(),
          channel: {
            id: conv.id,
            name: conv.name,
          },
        });
      }
    }
  }

  console.log(
    `  Found ${userMessages.length} messages from you, ${mentionsOfUser.length} mentions of you`
  );

  if (userMessages.length === 0) {
    console.error("\n✗ No messages found from you in the last 90 days.");
    console.log(
      JSON.stringify({
        success: false,
        error: "no_messages_found",
        user: userName,
        workspace,
        channelsChecked: myChannels.length,
      })
    );
    process.exit(1);
  }

  // 4. Fetch thread context for replies (batch in parallel)
  const threadReplies = userMessages.filter((m) => m.is_thread_reply);
  console.log(
    `  ${threadReplies.length} are thread replies, fetching context...`
  );

  // Group by channel+thread
  const threadGroups = new Map();
  for (const msg of threadReplies) {
    const key = `${msg.channel.id}:${msg.thread_ts}`;
    if (!threadGroups.has(key)) {
      threadGroups.set(key, {
        channelId: msg.channel.id,
        threadTs: msg.thread_ts,
      });
    }
  }

  const threadContextMap = new Map();
  const threadParentAuthors = new Map(); // Track who started threads user replied to
  const threadEntries = [...threadGroups.entries()];

  for (let i = 0; i < threadEntries.length; i += 25) {
    const batch = threadEntries.slice(i, i + 25);
    const results = await Promise.all(
      batch.map(async ([key, { channelId, threadTs }]) => {
        const params = new URLSearchParams({
          channel: channelId,
          ts: threadTs,
          limit: "10",
          inclusive: "true",
        });
        const res = await fetch(
          "https://slack.com/api/conversations.replies?" + params,
          { headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" } }
        );
        const data = await res.json();
        if (data.ok && data.messages?.length > 0) {
          // Track thread parent author (person user is replying to)
          const parentMsg = data.messages[0];
          if (parentMsg.user && parentMsg.user !== userId) {
            threadParentAuthors.set(key, parentMsg.user);
          }

          // Get up to 5 preceding messages for context
          const contextMessages = data.messages.slice(0, 5).map((m) => ({
            text: expandUserRefs(m.text),
            user: userMap.get(m.user)?.name || m.user,
            userId: m.user,
            timestamp: new Date(parseFloat(m.ts) * 1000).toISOString(),
          }));
          return [key, contextMessages];
        }
        return [key, null];
      })
    );
    for (const [key, context] of results) {
      if (context) threadContextMap.set(key, context);
    }
  }

  console.log(`  Retrieved ${threadContextMap.size} thread parents`);

  // 5. Build final output
  const messages = userMessages.map((msg) => {
    let threadContext = null;
    if (msg.is_thread_reply) {
      const key = `${msg.channel.id}:${msg.thread_ts}`;
      const context = threadContextMap.get(key);
      if (context) {
        threadContext = context; // Array of preceding messages
      }
    }

    return {
      text: msg.text,
      timestamp: msg.timestamp,
      channel: msg.channel,
      thread_context: threadContext,
      reactions: msg.reactions || null,
    };
  });

  // Sort newest first
  messages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
  mentionsOfUser.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));

  // Build DM contacts list (we can list DMs but not read messages)
  const dmContacts = dmsData
    .filter((dm) => dm.user && userMap.has(dm.user))
    .map((dm) => ({
      id: dm.id,
      userId: dm.user,
      name: userMap.get(dm.user)?.name,
    }));

  // Build active channels map with message counts
  const channelCounts = new Map();
  for (const msg of userMessages) {
    const key = msg.channel.id;
    if (!channelCounts.has(key)) {
      channelCounts.set(key, { ...msg.channel, messageCount: 0 });
    }
    channelCounts.get(key).messageCount++;
  }
  const activeChannels = [...channelCounts.values()].sort(
    (a, b) => b.messageCount - a.messageCount
  );

  // Build collaborators with multi-signal scoring
  const collaboratorScores = new Map();
  const addScore = (targetUserId, signal, weight = 1) => {
    if (!targetUserId || targetUserId === userId) return; // Skip self
    if (!collaboratorScores.has(targetUserId)) {
      collaboratorScores.set(targetUserId, {
        repliedToMe: 0,
        iRepliedTo: 0,
        hasDM: false,
        iMentioned: 0,
      });
    }
    const scores = collaboratorScores.get(targetUserId);
    if (signal === "repliedToMe") scores.repliedToMe += weight;
    else if (signal === "iRepliedTo") scores.iRepliedTo += weight;
    else if (signal === "hasDM") scores.hasDM = true;
    else if (signal === "iMentioned") scores.iMentioned += weight;
  };

  // Signal 1: People who mentioned me (strong - they're engaging with me)
  for (const mention of mentionsOfUser) {
    const mentionerId = [...userMap.entries()].find(
      ([_, u]) => u.name === mention.from
    )?.[0];
    if (mentionerId) addScore(mentionerId, "repliedToMe");
  }

  // Signal 2: People whose threads I replied to (strong - I'm engaging with them)
  for (const [_, parentUserId] of threadParentAuthors) {
    addScore(parentUserId, "iRepliedTo");
  }

  // Signal 3: DM contacts (strong - we have a direct relationship)
  const dmContactIds = new Set(dmsData.map((dm) => dm.user).filter(Boolean));
  for (const dmUserId of dmContactIds) {
    addScore(dmUserId, "hasDM");
  }

  // Signal 4: People I mentioned (weak - could be broadcasting)
  for (const mentionedId of mentionedUserIds) {
    addScore(mentionedId, "iMentioned");
  }

  // Build collaborators array with scores
  const collaborators = [...collaboratorScores.entries()]
    .filter(([id]) => userMap.has(id))
    .map(([id, scores]) => ({
      id,
      name: userMap.get(id).name,
      signals: {
        theyMentionedMe: scores.repliedToMe,
        iRepliedToThem: scores.iRepliedTo,
        hasDM: scores.hasDM,
        iMentionedThem: scores.iMentioned,
      },
      // Score: bidirectional interaction > unidirectional
      interactionScore:
        scores.repliedToMe * 3 + // They engaged with me (strong)
        scores.iRepliedTo * 3 + // I engaged with them (strong)
        (scores.hasDM ? 5 : 0) + // We have a direct channel (strong)
        scores.iMentioned * 1, // I mentioned them (weak)
    }))
    .sort((a, b) => b.interactionScore - a.interactionScore);

  const output = {
    user: userName,
    workspace,
    period: `${formatDate(new Date(ninetyDaysAgo * 1000))} - ${formatDate(
      new Date()
    )}`,
    messagesFrom: messages,
    mentionsOf: mentionsOfUser,
    activeChannels,
    collaborators,
    dmContacts,
  };

  // Write output
  const dir = path.dirname(outputPath);
  if (dir && dir !== ".") fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));

  console.log(
    `\n✓ Collected ${messages.length} messages from, ${mentionsOfUser.length} mentions of`
  );
  console.log(`  Output: ${outputPath}`);
  console.log(
    JSON.stringify({
      success: true,
      outputPath,
      user: userName,
      workspace,
      messagesFromCount: messages.length,
      mentionsOfCount: mentionsOfUser.length,
      threadContextCount: threadContextMap.size,
    })
  );
} catch (error) {
  console.error("Failed:", error.message);
  throw error;
}