Last active
October 5, 2025 17:30
-
-
Save m00nwtchr/8ed0223973d66bc8f7bca6128c0ec1f9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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