Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ LightSession uses a **Fetch Proxy** architecture:

1. **Injection** – at `document_start`, injects a script into the page context before ChatGPT loads.
2. **Interception** – patches `window.fetch` to intercept `/backend-api/` JSON responses.
3. **Trimming** – parses the conversation mapping, counts messages (role transitions), keeps the last N messages.
3. **Trimming** – parses either the legacy conversation mapping or the current flat message list, counts role transitions, and keeps the last N messages.
4. **Response** – if there are more messages than the limit, returns a modified Response with trimmed JSON; otherwise passes the original response through unchanged.

**Message counting**: A "message" is a contiguous sequence of nodes from the same role. Consecutive assistant nodes (e.g., from Extended Thinking) are aggregated as ONE message.
Expand Down
99 changes: 74 additions & 25 deletions extension/src/page/page-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {};

import {
trimMapping,
trimMessages,
type ConversationData,
} from '../shared/trimmer';
import { TIMING } from '../shared/constants';
Expand Down Expand Up @@ -153,18 +154,75 @@ function dispatchStatus(status: TrimStatus): void {

function extractConversationRequestId(url: URL): string | null {
const match = url.pathname.match(
/^\/backend-api\/(?:conversation|shared_conversation)\/([^/]+)\/?$/
/^\/backend-api\/(?:conversation|conversations|shared_conversation)\/([^/]+)\/?$/
);
return match?.[1] ?? null;
}

function looksLikeConversationData(
json: ConversationData | null
): json is ConversationData & {
mapping: NonNullable<ConversationData['mapping']>;
current_node: string;
} {
return !!json && typeof json === 'object' && !!json.mapping && typeof json.current_node === 'string';
function looksLikeConversationData(json: ConversationData | null): json is ConversationData {
if (!json || typeof json !== 'object') {
return false;
}

return (!!json.mapping && typeof json.current_node === 'string') || Array.isArray(json.messages);
}

interface TrimmedConversation {
data: ConversationData;
keptCount: number;
totalCount: number;
visibleKept: number;
visibleTotal: number;
}

function trimConversationData(data: ConversationData, limit: number): TrimmedConversation | null {
if (Array.isArray(data.messages)) {
const trimmed = trimMessages(data, limit);
if (!trimmed) {
return null;
}

const firstMessageId = trimmed.messages[0]?.id;
const lastMessageId = trimmed.messages[trimmed.messages.length - 1]?.id;
const pageInfo = data.page_info;

return {
...trimmed,
data: {
...data,
messages: trimmed.messages,
...(pageInfo
? {
page_info: {
...pageInfo,
...(firstMessageId ? { start_cursor: firstMessageId } : {}),
...(lastMessageId ? { end_cursor: lastMessageId } : {}),
// The retained suffix is intentionally the complete visible
// history for this session; don't invite the UI to page older
// records back into the DOM.
has_previous_page: false,
},
}
: {}),
},
};
}

const trimmed = trimMapping(data, limit);
if (!trimmed) {
return null;
}

return {
...trimmed,
data: {
...data,
mapping: trimmed.mapping,
current_node: trimmed.current_node,
// ChatGPT needs the legacy root to know where to start rendering.
root: trimmed.root,
},
};
}

async function attemptAuthoritativeConversationSync(conversationId: string): Promise<void> {
Expand Down Expand Up @@ -224,7 +282,7 @@ function getConfig(): LsConfig {
window.__LS_CONFIG__ = stored;
return stored;
}

// Fall back to window config (set by content script events)
const cfg = window.__LS_CONFIG__;
if (cfg) {
Expand All @@ -235,7 +293,7 @@ function getConfig(): LsConfig {
debug: cfg.debug ?? DEFAULT_CONFIG.debug,
};
}

return DEFAULT_CONFIG;
}

Expand All @@ -248,19 +306,20 @@ function isConversationRequest(method: string, url: URL): boolean {
return false;
}

// Only endpoints that return the conversation tree we can trim.
// Only endpoints that return conversation history we can trim.
// ChatGPT performs many GET /backend-api/* requests on load (/me, /models, /settings, etc.).
// Intercepting those adds unnecessary overhead (clone/json) and config gating delay.
//
// Allowed:
// - /backend-api/conversation/<id>
// - /backend-api/conversations/<id> (current paginated response)
// - /backend-api/shared_conversation/<id> (share links)
//
// Explicitly excluded by pattern (extra path segments):
// - /backend-api/conversation/<id>/stream_status
// - /backend-api/conversation/<id>/textdocs
const path = url.pathname;
return /^\/backend-api\/(conversation|shared_conversation)\/[^/]+\/?$/.test(path);
return /^\/backend-api\/(conversation|conversations|shared_conversation)\/[^/]+\/?$/.test(path);
}

/**
Expand Down Expand Up @@ -383,8 +442,8 @@ async function interceptedFetch(
completedBootstrapSyncIds.add(requestConversationId);
}

// Trim the mapping
const trimmed = trimMapping(json, cfg.limit);
// Trim the legacy mapping or the current flat messages response.
const trimmed = trimConversationData(json, cfg.limit);

if (!trimmed) {
return res;
Expand Down Expand Up @@ -423,17 +482,7 @@ async function interceptedFetch(
limit: cfg.limit,
});

// Build modified response data
const modifiedData: ConversationData = {
...json,
mapping: trimmed.mapping,
current_node: trimmed.current_node,
};

// Always set root - ChatGPT needs this to know where to start rendering
modifiedData.root = trimmed.root;

return createModifiedResponse(res, modifiedData);
return createModifiedResponse(res, trimmed.data);
} catch (error) {
// On any error, return original response
log('Error in fetch interceptor:', error);
Expand Down
127 changes: 122 additions & 5 deletions extension/src/shared/trimmer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ export interface ChatMessage {
};
}

/**
* Current conversation responses use a flat, chronological messages array
* rather than the legacy mapping tree. Keep this deliberately permissive:
* ChatGPT adds message metadata frequently, and trimming must preserve it.
*/
export interface ConversationMessage extends ChatMessage {
id?: string;
[key: string]: unknown;
}

export interface ConversationPageInfo {
start_cursor?: string;
end_cursor?: string;
has_previous_page?: boolean;
[key: string]: unknown;
}

export interface ChatNode {
parent: string | null;
children?: string[];
Expand All @@ -29,6 +46,8 @@ export interface ConversationData {
mapping?: ChatMapping;
current_node?: string;
root?: string;
messages?: ConversationMessage[];
page_info?: ConversationPageInfo;
}

export interface TrimResult {
Expand All @@ -41,6 +60,14 @@ export interface TrimResult {
visibleTotal: number;
}

export interface MessagesTrimResult {
messages: ConversationMessage[];
keptCount: number;
totalCount: number;
visibleKept: number;
visibleTotal: number;
}

// ============================================================================
// Constants
// ============================================================================
Expand All @@ -65,11 +92,19 @@ export const HIDDEN_ROLES = new Set([
* Nodes without messages (like root nodes) are not visible.
*/
export function isVisibleMessage(node: ChatNode): boolean {
const role = node.message?.author?.role;
// Must have a role to be considered a message
if (!role) return false;
// Exclude hidden/internal roles
return !HIDDEN_ROLES.has(role);
return isVisibleRole(node.message?.author?.role);
}

/**
* Check whether an item in the modern flat messages array is visible.
*/
export function isVisibleConversationMessage(message: ConversationMessage): boolean {
return isVisibleRole(message.author?.role);
}

function isVisibleRole(role: string | undefined): boolean {
// Must have a role to be considered a message.
return !!role && !HIDDEN_ROLES.has(role);
}

// ============================================================================
Expand Down Expand Up @@ -251,3 +286,85 @@ export function trimMapping(
visibleTotal,
};
}

/**
* Trim a modern flat conversation response to its last N visible turns.
*
* The current /backend-api/conversations/<id> response contains a
* chronological messages array instead of the legacy mapping tree. We retain
* the same role-transition semantics as trimMapping() and preserve hidden
* records that occur within the retained suffix (for example tool output
* between a user prompt and its final assistant answer).
*/
export function trimMessages(data: ConversationData, limit: number): MessagesTrimResult | null {
const messages = data.messages;
if (!Array.isArray(messages)) {
return null;
}

const effectiveLimit = Math.max(1, limit);
let visibleTotal = 0;
let lastVisibleRole: string | null = null;

for (const message of messages) {
if (!isVisibleConversationMessage(message)) {
continue;
}

const role = message.author?.role ?? '';
if (role !== lastVisibleRole) {
visibleTotal++;
lastVisibleRole = role;
}
}

if (visibleTotal === 0) {
return null;
}

let turnCount = 0;
let cutIndex = 0;
let lastRole: string | null = null;

for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message || !isVisibleConversationMessage(message)) {
continue;
}

const role = message.author?.role ?? '';
if (role !== lastRole) {
turnCount++;
lastRole = role;
}

if (turnCount > effectiveLimit) {
cutIndex = i + 1;
break;
}
}

const keptMessages = messages.slice(cutIndex);
let visibleKept = 0;
let lastKeptRole: string | null = null;

for (const message of keptMessages) {
if (!isVisibleConversationMessage(message)) {
continue;
}

const role = message.author?.role ?? '';
if (role !== lastKeptRole) {
visibleKept++;
lastKeptRole = role;
}
}

return {
messages: keptMessages,
keptCount: keptMessages.length,
totalCount: messages.length,
visibleKept,
visibleTotal,
};
}
1 change: 1 addition & 0 deletions tests/unit/page-script-bootstrap-dedupe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('../../extension/src/shared/trimmer', () => ({
trimMapping: vi.fn(),
trimMessages: vi.fn(),
}));

import { trimMapping } from '../../extension/src/shared/trimmer';
Expand Down
1 change: 1 addition & 0 deletions tests/unit/page-script-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('../../extension/src/shared/trimmer', () => ({
trimMapping: vi.fn(),
trimMessages: vi.fn(),
}));

import { trimMapping } from '../../extension/src/shared/trimmer';
Expand Down
Loading