Skip to content

Instantly share code, notes, and snippets.

@m00nwtchr
Last active October 5, 2025 17:30
Show Gist options
  • Select an option

  • Save m00nwtchr/8ed0223973d66bc8f7bca6128c0ec1f9 to your computer and use it in GitHub Desktop.

Select an option

Save m00nwtchr/8ed0223973d66bc8f7bca6128c0ec1f9 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name ChatGPT Data Trimmer — Single Timeline (keep last N)
// @namespace https://github.com/m00nwtchr
// @version 0.3.2
// @description Intercept ChatGPT conversation JSON. Force a single timeline (current_node → client-created-root), then keep only the last N messages. The app never mounts the rest.
// @author m00nwtchr, ChatGPT
// @license MIT
// @match https://chat.openai.com/*
// @match https://chatgpt.com/*
// @run-at document-start
// @grant none
// @updateURL https://gist.github.com/m00nwtchr/8ed0223973d66bc8f7bca6128c0ec1f9/raw/b84744cd94b046069f1f784e1e67ca2b13d1ce7d/chatgpt-data-trimmer.user.js
// @downloadURL https://gist.github.com/m00nwtchr/8ed0223973d66bc8f7bca6128c0ec1f9/raw/b84744cd94b046069f1f784e1e67ca2b13d1ce7d/chatgpt-data-trimmer.user.js
// ==/UserScript==
(() => {
'use strict';
/*** ───────── CONFIG ───────── **/
const DEFAULT_KEEP_LAST = 50;
const URL_REGEX = /\/backend-api\/(conversation|conversations?\/[^/]+)(\?|$)/i;
const ROOT_ID = 'client-created-root';
/*** ───────── STATE / LOG ───────── **/
let keepLast = Number(localStorage.getItem('chatgpt-trimmer.keep')) || DEFAULT_KEEP_LAST;
const log = (...a) => console.log('[ChatGPT Trimmer]', ...a);
const warn = (...a) => console.warn('[ChatGPT Trimmer]', ...a);
let ltPS = localStorage.getItem('chatgpt-trimmer.lastTrimmedPayload');
let lastTrimmedPayload = ltPS && JSON.parse(ltPS) || null; // stash the most recent trimmed JSON we returned
/*** ───────── HELPERS ───────── **/
const parseJSON = async (res) => {
try {
const clone = res.clone();
const ct = clone.headers.get('content-type') || '';
if (!/application\/json/i.test(ct)) return null;
return await clone.json();
} catch { return null; }
};
const makeJSONResponse = (data, orig) => {
const body = JSON.stringify(data);
const headers = new Headers(orig.headers);
headers.set('content-type', 'application/json; charset=utf-8');
headers.delete('content-length');
return new Response(body, { status: orig.status, statusText: orig.statusText, headers });
};
// ── Linearization utilities: compute single path, build linear mapping, and slice last N messages ──
function computeSinglePathIds(data) {
if (!data || !data.mapping || typeof data.mapping !== 'object') return null;
const mapping = data.mapping;
const rootId = mapping[ROOT_ID] ? ROOT_ID : (Object.values(mapping).find(n => n && n.parent == null)?.id || null);
if (!rootId) return null;
let leafId = (data.current_node && mapping[data.current_node]) ? data.current_node : null;
if (!leafId) {
leafId = Object.values(mapping).find(n => n && (!n.children || !n.children.length))?.id || null;
if (!leafId) return null;
}
const pathIdsReversed = [];
let curId = leafId, guard = 0;
while (curId && mapping[curId] && guard++ < 20000) {
pathIdsReversed.push(curId);
if (curId === rootId) break;
curId = mapping[curId].parent;
}
if (pathIdsReversed[pathIdsReversed.length - 1] !== rootId) return null;
const pathIds = pathIdsReversed.slice().reverse(); // [root, ..., leaf]
return { rootId, pathIds };
}
function buildLinearMappingFromPath(mapping, pathIds) {
const newMapping = {};
for (let i = 0; i < pathIds.length; i++) {
const id = pathIds[i];
const original = mapping[id];
if (!original) continue;
const parentId = i === 0 ? null : pathIds[i - 1];
const childId = i === pathIds.length - 1 ? null : pathIds[i + 1];
newMapping[id] = { ...original, parent: parentId, children: childId ? [childId] : [] };
}
// Ensure root child points to first after root
if (pathIds.length > 1) newMapping[pathIds[0]].children = [pathIds[1]]; else newMapping[pathIds[0]].children = [];
return newMapping;
}
function sliceKeepLastMessagesFromPath(mapping, pathIds, keepCount) {
const pathNodes = pathIds.map(id => mapping[id]).filter(Boolean);
const msgIdx = [];
for (let i = 0; i < pathNodes.length; i++) {
if (i === 0) continue; // skip root
if (pathNodes[i].message && typeof pathNodes[i].message === 'object') msgIdx.push(i);
}
let startIdx = 1; // default first after root
if (msgIdx.length > keepCount) startIdx = msgIdx[msgIdx.length - keepCount];
else if (msgIdx.length > 0) startIdx = msgIdx[0];
const keptIds = [pathIds[0], ...pathIds.slice(startIdx)];
return buildLinearMappingFromPath(mapping, keptIds);
}
function linearizeAllMessages(data) {
const info = computeSinglePathIds(data); if (!info) return null;
const newMapping = buildLinearMappingFromPath(data.mapping, info.pathIds);
const out = { ...data, mapping: newMapping };
if (!newMapping[data.current_node]) out.current_node = info.pathIds[info.pathIds.length - 1];
return out;
}
function keepLastNMessages(linearData, keepCount) {
const info = computeSinglePathIds(linearData); if (!info) return null;
const newMapping = sliceKeepLastMessagesFromPath(linearData.mapping, info.pathIds, keepCount);
const out = { ...linearData, mapping: newMapping };
if (!newMapping[linearData.current_node]) out.current_node = info.pathIds[info.pathIds.length - 1];
return out;
}
// Handle both shapes: { mapping, ... } or { conversation: { mapping, ... } }
function applyTrimPossiblyWrapped(payload, keepCount) {
if (!payload || typeof payload !== 'object') return null;
const wrapped = payload.conversation && payload.conversation.mapping && typeof payload.conversation.mapping === 'object';
const target = wrapped ? payload.conversation
: (payload.mapping && typeof payload.mapping === 'object' ? payload : null);
if (!target) return null;
const before = Object.keys(target.mapping || {}).length;
// 1) Linearize once (remove other branches, keep full path)
const fullLinear = linearizeAllMessages(target);
if (!fullLinear) return null;
// 2) Minimize linear full history for export persistence
const minimalFull = minimizeForExport(fullLinear);
// 3) Create the app-facing trimmed payload by slicing the already-linear data
const trimmedLinear = keepLastNMessages(fullLinear, keepCount);
if (!trimmedLinear) return null;
const after = Object.keys(trimmedLinear.mapping || {}).length;
const result = wrapped ? { ...payload, conversation: trimmedLinear } : trimmedLinear;
return { result, beforeCount: before, afterCount: after, wrapped, minimalFull };
}
/*** ───────── FETCH PATCH ───────── **/
const origFetch = window.fetch;
window.fetch = async function(input, init) {
const res = await origFetch(input, init);
try {
const url = typeof input === 'string' ? input : (input && input.url) || '';
if (!URL_REGEX.test(url)) return res;
const json = await parseJSON(res);
if (!json) return res;
const trimmed = applyTrimPossiblyWrapped(json, keepLast);
if (!trimmed) return res;
log(`trimmed single timeline${trimmed.wrapped ? ' (wrapped)' : ''}:`,
trimmed.beforeCount, '→', trimmed.afterCount, `(keepLast=${keepLast})`);
localStorage.setItem('chatgpt-trimmer.lastTrimmedPayload', JSON.stringify(trimmed.minimalFull));
return makeJSONResponse(trimmed.result, res);
} catch (e) {
warn('failed; returning original:', e);
return res;
}
};
/*** ───────── RUNTIME API ───────── **/
/*** ───────── MINIMIZER FOR EXPORT ───────── ***/
function minimizeForExport(payload) {
const srcMap = payload && payload.mapping; if (!srcMap) return { mapping: {} };
const outMap = {};
for (const [id, node] of Object.entries(srcMap)) {
const msg = node.message || null;
let outMsg = null;
if (msg && msg.author && msg.content) {
const role = msg.author && msg.author.role ? { role: msg.author.role } : null;
let content = null;
const c = msg.content;
if (c) {
if (c.content_type === 'text' && Array.isArray(c.parts)) {
content = { content_type: 'text', parts: c.parts.filter(p => typeof p === 'string') };
} else if (c.content_type === 'multimodal_text' && Array.isArray(c.parts)) {
content = { content_type: 'multimodal_text', parts: c.parts.map(p => (typeof p === 'string' ? p : (p && typeof p.text === 'string' ? { text: p.text } : ''))).filter(Boolean) };
} else if (typeof c.text === 'string') {
content = { text: c.text };
}
}
if (role || content) outMsg = { author: role || {}, content: content || {} };
}
outMap[id] = {
parent: node.parent ?? null,
children: Array.isArray(node.children) && node.children.length ? [node.children[0]] : [],
message: outMsg
};
}
return { mapping: outMap };
}
/*** ───────── RUNTIME API ───────── ***/
window.ChatGPTTrimmer = {
getKeep: () => keepLast,
setKeep: (n) => { keepLast = Math.max(1, Number(n) || DEFAULT_KEEP_LAST); localStorage.setItem('chatgpt-trimmer.keep', String(keepLast)); log('persisted keepLast =', keepLast); },
/**
* Export the kept (trimmed) linear timeline as:
* User: <msg>\nAssistant: <msg>\n...
* Returns the string and copies it to the clipboard (best effort).
*/
export: (opts) => {
opts = opts || {};
// Build minimalist transcript lines: "User: ..." / "Assistant: ..."
let dataS = localStorage.getItem('chatgpt-trimmer.lastTrimmedPayload');
const data = (dataS && JSON.parse(dataS)) || null;
if (!data || !data.mapping) {
warn('export: no trimmed payload yet — load a conversation first.');
return [];
}
const mapping = data.mapping;
const root = mapping[ROOT_ID] || Object.values(mapping).find(n => n && n.parent == null) || null;
if (!root) {
warn('export: could not find root');
return [];
}
// Walk the linear chain into ordered lines with roles
let id = (root.children && root.children[0]) || null;
const lines = [];
let guard = 0;
while (id && mapping[id] && guard++ < 100000) {
const node = mapping[id];
const msg = node.message;
if (msg && msg.author && (msg.author.role === 'user' || msg.author.role === 'assistant')) {
let role = msg.author.role.charAt(0).toUpperCase() + msg.author.role.slice(1);
if (msg.author.role === 'user' && opts.user) {
role = opts.user;
} else if (msg.author.role === 'assistant' && opts.assistant) {
role = opts.assistant;
}
const text = extractText(msg.content).trim();
if (text) lines.push({ role: msg.author.role, line: `${role}: ${text}` });
}
id = (node.children && node.children[0]) || null; // linear chain
}
// Group into "turns" that end with an assistant line.
// Each turn = zero or more User lines + exactly one Assistant line.
// Any leftover User lines at the end become the "tail" (no assistant).
const turns = [];
let pendingUsers = [];
for (const { role, line } of lines) {
if (role === 'assistant') {
const turn = pendingUsers.length ? pendingUsers.concat(line) : [line];
turns.push(turn.join('\n'));
pendingUsers = [];
} else {
pendingUsers.push(line);
}
}
const tail = pendingUsers.length ? pendingUsers.join('\n') : '';
// Chunk turns to ~8k, ensuring chunk boundaries end on assistant (by construction).
const MAX = 1024*8;
const chunks = [];
let cur = '';
let curLen = 0;
const appendToCur = (s) => {
if (!cur) {
cur = s;
curLen = s.length;
} else {
cur += '\n' + s;
curLen += 1 + s.length;
}
};
for (const turn of turns) {
const need = (cur ? 1 : 0) + turn.length;
if (cur && (curLen + need > MAX)) {
// flush current chunk (already ends with assistant)
chunks.push(cur);
cur = '';
curLen = 0;
}
if (!cur && turn.length > MAX) {
// Single oversized turn: emit as its own chunk (no mid-message splits)
chunks.push(turn);
} else {
appendToCur(turn);
}
}
// Flush any remaining assistant-ended chunk
if (cur) chunks.push(cur);
// Handle tail (final user-only remainder).
// If possible, merge into the last chunk without exceeding MAX; otherwise, emit as its own final chunk.
if (tail) {
if (chunks.length && (chunks[chunks.length - 1].length + 1 + tail.length) <= MAX) {
chunks[chunks.length - 1] = chunks[chunks.length - 1] + '\n' + tail; // final chunk may end on user
} else {
chunks.push(tail); // final chunk may end on user
}
}
// Log each chunk separately (no clipboard side-effects)
if (!chunks.length) {
console.log('');
} else {
for (let i = 0; i < chunks.length; i++) {
console.log(`[BEGIN CHUNK ${i + 1}/${chunks.length}]\n${chunks[i]}\n[END CHUNK ${i + 1}/${chunks.length} - SUMMARISE CHUNK, PRESERVE DETAIL - DO NOT CONTINUE]`);
}
}
return chunks;
}
};
/*** ─────────────────────────── TEXT EXTRACTION ─────────────────────── ***/
function extractText(content) {
if (!content) return '';
// Common ChatGPT shapes
// 1) { content_type: "text", parts: [ "str", ... ] }
if (content.content_type === 'text' && Array.isArray(content.parts)) {
return content.parts.filter(p => typeof p === 'string').join('\n\n');
}
// 2) { content_type: "multimodal_text", parts: [string | {text}, ...] }
if (content.content_type === 'multimodal_text' && Array.isArray(content.parts)) {
return content.parts.map(p => {
if (typeof p === 'string') return p;
if (p && typeof p.text === 'string') return p.text;
return '';
}).filter(Boolean).join('\n\n');
}
// Fallbacks: some variants carry text differently
if (typeof content === 'string') return content;
if (typeof content.text === 'string') return content.text;
return '';
}
log('active — single timeline trim; keepLast =', keepLast);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment