// ==UserScript==
// @name ChatGPT AutoCleaner v5
// @version 1.4
// @description Bugfix & speed-up for ChatGPT: automatically cleans the conversation chat window by trimming old messages (both in DOM and React memory). Keeps only the latest N turns visible, preventing lag and memory bloat on long sessions. Includes manual “Clean now” button and skips auto-clean when the tab is hidden.
// @author Aleksey Maximov <[email protected]>
// @match https://chat.openai.com/*
// @match https://chatgpt.com/*
// @grant none
// @namespace 81e29c9d-b6e3-4210-b862-c93cb160f09a
// @license MIT
// ==/UserScript==
/*
WHY THIS FIX EXISTS (read this once):
Problem:
- The ChatGPT web app keeps conversation history not only in the DOM, but also in React state.
- Multiple components hold their own `props.messages` (and hook-derivatives). Deleting DOM nodes alone does NOT free memory.
- During streaming the assistant produces many “draft” chunks (in_progress). Arrays grow → GC pressure → UI lag.
What this script changes:
- On a timer, it does TWO things:
1) Trims old DOM turns (visual cleanup).
2) Walks the React fiber tree, finds every `props.messages` array, and trims it IN PLACE to the last N.
It also ALWAYS drops streaming drafts first (only final messages are kept).
- Adds a **Clean now** button to trigger a one-off trim even if auto-clean is disabled.
- Auto-clean is skipped when the tab is hidden (no background CPU churn).
Why you might see “duplicates”:
- Different components can own separate arrays. We re-find and de-duplicate by identity on every run, then trim each safely.
Result:
- Only the latest N messages are kept in both DOM and React memory; old objects become unreachable → GC can reclaim memory.
- This reduces lag on long sessions without intercepting network or patching app code.
*/
(function () {
'use strict';
// ---------- UI ----------
function injectUI() {
if (document.getElementById("chatgpt-cleaner-panel")) return;
const defaults = { leaveOnly: 5, intervalSec: 10, enabled: false };
const stored = {
leaveOnly: parseInt(localStorage.getItem("chatgpt-leaveOnly")) || defaults.leaveOnly,
intervalSec: parseInt(localStorage.getItem("chatgpt-intervalSec")) || defaults.intervalSec,
enabled: localStorage.getItem("chatgpt-enabled") !== "false"
};
const container = document.createElement("div");
container.id = "chatgpt-cleaner-wrapper";
Object.assign(container.style, {
position: "fixed", bottom: "8px", right: "8px", zIndex: 9999, fontFamily: "sans-serif"
});
const toggleButton = document.createElement("button");
toggleButton.textContent = "⚙";
Object.assign(toggleButton.style, {
background: stored.enabled ? "#444" : "red", color: "#fff", border: "none",
borderRadius: "4px", padding: "2px 6px", cursor: "pointer", fontSize: "14px"
});
toggleButton.title = "Toggle cleaner panel";
const panel = document.createElement("div");
panel.id = "chatgpt-cleaner-panel";
Object.assign(panel.style, {
display: "none", marginTop: "4px", background: "#222", color: "#fff",
padding: "10px 12px 10px 10px", borderRadius: "6px", fontSize: "12px",
boxShadow: "0 0 6px rgba(0,0,0,0.5)", border: "1px solid #555", position: "relative", opacity: "0.95"
});
panel.innerHTML = `
<div id="chatgpt-close" style="position:absolute;top:0px;right:2px;font-size:16px;font-weight:bold;color:#ccc;cursor:pointer;">✖</div>
<label>
Keep <input id="chatgpt-keep-count" type="number" value="${stored.leaveOnly}" min="1"
style="width:52px;min-width:52px;padding:2px 6px 2px 4px;font-size:12px;background:#111;color:#fff;border:1px solid #555;box-sizing:border-box;"> messages
</label>
<br>
<label>
Interval <input id="chatgpt-interval" type="number" value="${stored.intervalSec}" min="2"
style="width:52px;min-width:52px;padding:2px 6px 2px 4px;font-size:12px;background:#111;color:#fff;border:1px solid #555;box-sizing:border-box;"> sec
</label>
<br>
<label><input type="checkbox" id="chatgpt-enabled" ${stored.enabled ? "checked" : ""}> Auto-clean enabled</label>
<br>
<button id="chatgpt-clean-now" style="
margin-top:6px;background:#008000;color:#fff;border:none;border-radius:4px;
padding:2px 8px;cursor:pointer;font-size:12px;">Clean now</button>
`;
toggleButton.onclick = () => { panel.style.display = "block"; toggleButton.style.display = "none"; };
container.appendChild(toggleButton);
container.appendChild(panel);
document.body.appendChild(container);
const countInput = panel.querySelector("#chatgpt-keep-count");
const intervalInput = panel.querySelector("#chatgpt-interval");
const enabledCheckbox = panel.querySelector("#chatgpt-enabled");
const cleanNowBtn = panel.querySelector("#chatgpt-clean-now");
const closeBtn = panel.querySelector("#chatgpt-close");
let leaveOnly = stored.leaveOnly;
let intervalMs = Math.max(2000, stored.intervalSec * 1000);
let enabled = stored.enabled;
let intervalId = null;
// ---------- React fiber helpers ----------
const reactKeyOf = el =>
el && Object.getOwnPropertyNames(el).find(n => n.startsWith("__reactFiber$")) || "__reactFiber";
function getRootFiber() {
try {
const nodes = [...document.querySelectorAll('[data-testid^="conversation-turn-"]')];
for (let i = nodes.length - 1; i >= 0; i--) {
let n = nodes[i];
while (n) {
const f = n[reactKeyOf(n)];
if (f) { let r = f; while (r.return) r = r.return; return r; }
n = n.parentElement;
}
}
const rootEl = document.getElementById("__next") || document.body.firstElementChild;
const f = rootEl && rootEl[reactKeyOf(rootEl)];
if (!f) return null;
let r = f; while (r.return) r = r.return; return r;
} catch { return null; }
}
const looksMsg = o => o && typeof o === "object" && (o.content && (o.author || o.role));
function pickMsgArray(x) {
if (!x) return null;
if (Array.isArray(x) && x.some(looksMsg)) return { arr: x, key: "<array>" };
const v = x.messages;
if (Array.isArray(v) && v.some(looksMsg)) return { arr: v, key: "messages" };
return null;
}
function collectPropsMessagesArrays() {
const root = getRootFiber();
if (!root) return [];
const found = [];
const seenFibers = new Set();
const stack = [root];
while (stack.length) {
const f = stack.pop();
if (!f || seenFibers.has(f)) continue;
seenFibers.add(f);
try {
// props
const mp = f.memoizedProps;
const pa = pickMsgArray(mp);
if (pa && pa.key === "messages") found.push(pa.arr);
// class state object
const ms = f.memoizedState;
if (ms && !ms?.next && typeof ms === "object") {
const sa = pickMsgArray(ms);
if (sa && sa.key === "messages") found.push(sa.arr);
}
// hooks list
let h = f.memoizedState, guard = 0;
while (h && h.next !== undefined && guard++ < 5000) {
const a1 = pickMsgArray(h.memoizedState);
if (a1 && a1.key === "messages") found.push(a1.arr);
const a2 = pickMsgArray(h.baseState);
if (a2 && a2.key === "messages") found.push(a2.arr);
h = h.next;
}
} catch { /* ignore */ }
if (f.child) stack.push(f.child);
if (f.sibling) stack.push(f.sibling);
if (f.alternate) stack.push(f.alternate);
}
// dedupe by identity
const uniq = [];
const seenArr = new Set();
for (const a of found) if (!seenArr.has(a)) { seenArr.add(a); uniq.push(a); }
return uniq;
}
// ---------- trimming ----------
function safeReplaceArray(arr, kept) {
try {
arr.splice(0, arr.length, ...kept);
return true;
} catch (e) {
console.warn("safeReplaceArray: failed to replace array", e);
return false;
}
}
function trimMessagesArray(arr, keep, dropDraftsFlag = true) {
const MIN_KEEP = 1;
const KEEP_SYSTEM = true;
if (!Array.isArray(arr) || !arr.length) return { before: 0, after: 0 };
if (Object.isFrozen(arr)) {
// console.debug("trimMessagesArray: skipped frozen array, length =", arr.length);
return { before: arr.length, after: arr.length, skipped: true };
}
const before = arr.length;
const first = arr[0];
const isSystem = !!(first && (first.author?.role === 'system' || first.role === 'system'));
const systemMsg = (KEEP_SYSTEM && isSystem) ? first : null;
let finals = dropDraftsFlag
? arr.filter(m => m && typeof m === 'object' &&
(m.status === 'finished_successfully' || m.end_turn === true || m.status !== 'in_progress'))
: arr.slice();
if (finals.length === 0) finals = [arr[arr.length - 1]];
let kept = finals.length > keep ? finals.slice(-keep) : finals;
if (systemMsg && kept[0] !== systemMsg) kept = [systemMsg, ...kept];
if (!kept.length) kept = [arr[arr.length - 1]];
if (kept.length < MIN_KEEP && arr.length) kept = [arr[arr.length - 1]];
const ok = safeReplaceArray(arr, kept);
const after = ok ? arr.length : before;
// console.debug(`trimMessagesArray: trimmed ${before} → ${after} (keep=${keep})`);
return { before, after, skipped: !ok };
}
// ---------- main cleaner ----------
function cleanOldMessages(manual = false) {
if (!manual) {
if (!enabled) return;
if (document.hidden) return;
}
try {
if (manual) {
console.log("[ChatGPT AutoCleaner] Manual clean triggered");
}
// 1) Trim DOM (visual only)
const all = document.querySelectorAll('[data-testid^="conversation-turn-"]');
if (all.length) {
const lastAttr = all[all.length - 1].getAttribute("data-testid");
const last = parseInt(lastAttr?.split("-")[2]);
if (!isNaN(last)) {
all.forEach(item => {
const idx = parseInt(item.getAttribute("data-testid")?.split("-")[2]);
if (!isNaN(idx) && idx < last - leaveOnly) item.remove();
});
}
}
// 2) Trim props.messages
const arrays = collectPropsMessagesArrays();
arrays.forEach(arr => { void trimMessagesArray(arr, leaveOnly, true); });
} catch (e) {
console.warn("cleanOldMessages error:", e);
}
}
function startCleaner() {
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(() => cleanOldMessages(false), intervalMs);
console.log(`[ChatGPT AutoCleaner] Started with interval ${intervalMs} ms, keeping last ${leaveOnly} messages`);
}
// ---------- handlers ----------
enabledCheckbox.onchange = () => {
enabled = enabledCheckbox.checked;
localStorage.setItem("chatgpt-enabled", enabled);
toggleButton.style.background = enabled ? "#444" : "red";
};
countInput.oninput = () => {
const val = parseInt(countInput.value);
if (!isNaN(val) && val > 0) {
leaveOnly = val;
localStorage.setItem("chatgpt-leaveOnly", val);
}
};
intervalInput.oninput = () => {
const val = parseInt(intervalInput.value);
if (!isNaN(val) && val > 1) {
intervalMs = Math.max(2000, val * 1000);
localStorage.setItem("chatgpt-intervalSec", val);
startCleaner();
}
};
cleanNowBtn.onclick = () => {
cleanOldMessages(true); // manual run regardless of enabled/visibility
panel.style.display = "none";
toggleButton.style.display = "inline-block";
};
closeBtn.onclick = () => {
panel.style.display = "none";
toggleButton.style.display = "inline-block";
};
startCleaner();
}
if (document.readyState === "complete" || document.readyState === "interactive") {
injectUI();
} else {
window.addEventListener("DOMContentLoaded", injectUI);
}
const observer = new MutationObserver(() => {
if (!document.getElementById("chatgpt-cleaner-wrapper")) injectUI();
});
observer.observe(document.body, { childList: true, subtree: true });
})();