diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f032a6..631b482 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,6 +103,7 @@ jobs: eslint.config.js \ build.cjs \ README.md \ + AMO-SOURCE-README.md \ LICENSE \ .gitignore \ .prettierrc \ @@ -158,6 +159,22 @@ jobs: with: addon-id: ${{ secrets.FIREFOX_ADDON_ID }} addon-path: light-session-${{ steps.version.outputs.VERSION }}-firefox.zip + source-path: light-session-${{ steps.version.outputs.VERSION }}-source.zip + approval-note: | + Source archive for this exact version is attached. + + Build environment: + - Node.js 24.x (see .node-version) + - npm ci (uses package-lock.json) + + Build steps: + 1. npm ci + 2. npm run build:prod:firefox + + The submitted zip was created with: + cd extension && zip -r ../light-session-VERSION-firefox.zip manifest.json dist/ popup/ icons/ -x "*.map" + + See AMO-SOURCE-README.md in the source archive for details. auth-api-issuer: ${{ secrets.FIREFOX_API_ISSUER }} auth-api-secret: ${{ secrets.FIREFOX_API_SECRET }} release-note: "See release notes at https://github.com/11me/light-session/releases/tag/v${{ steps.version.outputs.VERSION }}" diff --git a/.gitignore b/.gitignore index c123a67..656ec7c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # AI and speckit related .claude/ +.agents/ .specify/ specs/ @@ -50,3 +51,4 @@ extension/.dev *.temp .serena/ extension/manifest.json +.signum/ diff --git a/AMO-SOURCE-README.md b/AMO-SOURCE-README.md new file mode 100644 index 0000000..73f7567 --- /dev/null +++ b/AMO-SOURCE-README.md @@ -0,0 +1,39 @@ +# Source Code — LightSession Pro for ChatGPT + +This archive contains the complete source code for the submitted extension version. + +## Build Environment + +- **Node.js** 24.10.0 (see `.node-version`) +- **npm** (lockfile: `package-lock.json`) + +## Reproduce the build + +```bash +npm ci +npm run build:prod:firefox +``` + +This runs `NODE_ENV=production node build.cjs --target=firefox` which uses esbuild to bundle TypeScript source files into single JS files with minification and no sourcemaps. + +## Create the Firefox zip + +```bash +cd extension +zip -r ../light-session-firefox.zip manifest.json dist/ popup/ icons/ -x "*.map" +``` + +## Source layout + +``` +extension/src/ TypeScript source (page scripts, content scripts, popup, shared) +extension/icons/ Extension icons +extension/manifest.firefox.json Firefox manifest +extension/manifest.chrome.json Chrome manifest +build.cjs esbuild build script +tests/ Unit tests (vitest) +``` + +## No vendored or private dependencies + +All dependencies are public npm packages resolved via `package-lock.json`. diff --git a/extension/src/content/content.ts b/extension/src/content/content.ts index d171d34..db8c6d8 100644 --- a/extension/src/content/content.ts +++ b/extension/src/content/content.ts @@ -261,13 +261,6 @@ function setupNavigationDetection(): void { }); }; - // Listen for popstate events - window.addEventListener('popstate', () => { - if (location.href !== lastUrl) { - scheduleNavSideEffects('popstate'); - } - }); - // Patch history methods for SPA navigation detection // Guard against double patching (e.g. extension reload / unexpected reinjection). const PATCH_FLAG = '__lightsession_patched_history__'; @@ -275,6 +268,13 @@ function setupNavigationDetection(): void { if (patchScope[PATCH_FLAG] === true) return; patchScope[PATCH_FLAG] = true; + // Listen for popstate events (inside the guard so it's only registered once) + window.addEventListener('popstate', () => { + if (location.href !== lastUrl) { + scheduleNavSideEffects('popstate'); + } + }); + const originalPushState = history.pushState.bind(history); const originalReplaceState = history.replaceState.bind(history); diff --git a/extension/src/content/user-collapse.ts b/extension/src/content/user-collapse.ts index df5e5e9..b125df3 100644 --- a/extension/src/content/user-collapse.ts +++ b/extension/src/content/user-collapse.ts @@ -455,7 +455,9 @@ export function installUserCollapse(): UserCollapseController { updateButtonUi(btn, !expanded); requestAnimationFrame(() => { - preserveScrollAfterHeightChange(scroller!, prevScrollTop, prevScrollHeight, wasPinned); + if (scroller) { + preserveScrollAfterHeightChange(scroller, prevScrollTop, prevScrollHeight, wasPinned); + } }); }; document.addEventListener('click', onDocClick, true); diff --git a/extension/src/page/page-script.ts b/extension/src/page/page-script.ts index 6d472ba..2aa7fca 100644 --- a/extension/src/page/page-script.ts +++ b/extension/src/page/page-script.ts @@ -290,10 +290,6 @@ async function interceptedFetch( } } - if (!configReceived) { - return nativeFetch(...args); - } - // Skip if disabled if (!cfg.enabled) { return nativeFetch(...args); @@ -332,6 +328,22 @@ async function interceptedFetch( const keptAfter = trimmed.visibleKept; const removed = Math.max(0, totalBefore - keptAfter); + // Guard: no visible nodes were trimmed - return original response untouched. + // Rewriting the tree when nothing is trimmed would destroy hidden/system/tool/thinking + // nodes and alter the tree shape unnecessarily (issue #26). + if (trimmed.visibleKept === trimmed.visibleTotal) { + log( + `No visible trim needed: ${keptAfter}/${totalBefore} nodes (limit: ${cfg.limit})` + ); + dispatchStatus({ + totalBefore, + keptAfter, + removed: 0, + limit: cfg.limit, + }); + return res; + } + log( `Trimmed: ${keptAfter}/${totalBefore} nodes (limit: ${cfg.limit}), visible: ${trimmed.visibleKept}/${trimmed.visibleTotal}` ); diff --git a/extension/src/shared/logger.ts b/extension/src/shared/logger.ts index c7fd953..c4b4e2d 100644 --- a/extension/src/shared/logger.ts +++ b/extension/src/shared/logger.ts @@ -56,7 +56,7 @@ export function isDebugMode(): boolean { */ export function logDebug(message: string, ...args: unknown[]): void { if (debugEnabled) { - safeConsole.log(`${LOG_PREFIX} [DEBUG]`, message, ...args); + safeConsole.log(`[${LOG_PREFIX}DEBUG]`, message, ...args); } } @@ -64,19 +64,19 @@ export function logDebug(message: string, ...args: unknown[]): void { * Log warning (always shown) */ export function logWarn(message: string, ...args: unknown[]): void { - safeConsole.warn(`${LOG_PREFIX} [WARN]`, message, ...args); + safeConsole.warn(`[${LOG_PREFIX}WARN]`, message, ...args); } /** * Log error (always shown) */ export function logError(message: string, ...args: unknown[]): void { - safeConsole.error(`${LOG_PREFIX} [ERROR]`, message, ...args); + safeConsole.error(`[${LOG_PREFIX}ERROR]`, message, ...args); } /** * Log info (always shown) */ export function logInfo(message: string, ...args: unknown[]): void { - safeConsole.log(`${LOG_PREFIX} [INFO]`, message, ...args); + safeConsole.log(`[${LOG_PREFIX}INFO]`, message, ...args); } diff --git a/extension/src/shared/trimmer.ts b/extension/src/shared/trimmer.ts index 3024eda..a790096 100644 --- a/extension/src/shared/trimmer.ts +++ b/extension/src/shared/trimmer.ts @@ -184,7 +184,7 @@ export function trimMapping( // Preserve original root node - ChatGPT needs this "(no role)" node as tree anchor const originalRootId = path[0]; const originalRootNode = originalRootId ? mapping[originalRootId] : null; - const hasOriginalRoot = originalRootId && originalRootNode; + const hasOriginalRoot = originalRootId && originalRootNode && !isVisibleMessage(originalRootNode); // Build new mapping with kept nodes + original root const newMapping: ChatMapping = {}; diff --git a/tests/unit/logger.test.ts b/tests/unit/logger.test.ts index b931f98..9a7ba88 100644 --- a/tests/unit/logger.test.ts +++ b/tests/unit/logger.test.ts @@ -88,7 +88,7 @@ describe('logger', () => { logDebug('test message'); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, 'test message' ); }); @@ -99,7 +99,7 @@ describe('logger', () => { logDebug('test message', 'arg1', 42, { key: 'value' }); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, 'test message', 'arg1', 42, @@ -113,7 +113,7 @@ describe('logger', () => { logDebug('test message'); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, 'test message' ); }); @@ -126,7 +126,7 @@ describe('logger', () => { setDebugMode(true); logDebug('should appear'); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, 'should appear' ); }); @@ -154,7 +154,7 @@ describe('logger', () => { logInfo('info message'); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [INFO]`, + `[${LOG_PREFIX}INFO]`, 'info message' ); }); @@ -165,7 +165,7 @@ describe('logger', () => { logInfo('info message'); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [INFO]`, + `[${LOG_PREFIX}INFO]`, 'info message' ); }); @@ -174,7 +174,7 @@ describe('logger', () => { logInfo('info message', 'arg1', 42, { key: 'value' }); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [INFO]`, + `[${LOG_PREFIX}INFO]`, 'info message', 'arg1', 42, @@ -202,7 +202,7 @@ describe('logger', () => { logWarn('warning message'); expect(consoleWarnSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [WARN]`, + `[${LOG_PREFIX}WARN]`, 'warning message' ); }); @@ -213,7 +213,7 @@ describe('logger', () => { logWarn('warning message'); expect(consoleWarnSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [WARN]`, + `[${LOG_PREFIX}WARN]`, 'warning message' ); }); @@ -222,7 +222,7 @@ describe('logger', () => { logWarn('warning message', 'arg1', 42, { key: 'value' }); expect(consoleWarnSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [WARN]`, + `[${LOG_PREFIX}WARN]`, 'warning message', 'arg1', 42, @@ -250,7 +250,7 @@ describe('logger', () => { logError('error message'); expect(consoleErrorSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [ERROR]`, + `[${LOG_PREFIX}ERROR]`, 'error message' ); }); @@ -261,7 +261,7 @@ describe('logger', () => { logError('error message'); expect(consoleErrorSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [ERROR]`, + `[${LOG_PREFIX}ERROR]`, 'error message' ); }); @@ -270,7 +270,7 @@ describe('logger', () => { logError('error message', 'arg1', 42, { key: 'value' }); expect(consoleErrorSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [ERROR]`, + `[${LOG_PREFIX}ERROR]`, 'error message', 'arg1', 42, @@ -284,7 +284,7 @@ describe('logger', () => { logError('operation failed', error); expect(consoleErrorSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [ERROR]`, + `[${LOG_PREFIX}ERROR]`, 'operation failed', error ); @@ -325,7 +325,7 @@ describe('logger', () => { logDebug(specialMessage); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, specialMessage ); }); @@ -336,7 +336,7 @@ describe('logger', () => { logDebug('message', null, undefined); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, 'message', null, undefined @@ -351,7 +351,7 @@ describe('logger', () => { logDebug(longMessage); expect(consoleLogSpy).toHaveBeenCalledWith( - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, longMessage ); }); @@ -406,17 +406,17 @@ describe('logger', () => { expect(consoleLogSpy).toHaveBeenCalledTimes(3); expect(consoleLogSpy).toHaveBeenNthCalledWith( 1, - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, 'first' ); expect(consoleLogSpy).toHaveBeenNthCalledWith( 2, - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, 'second' ); expect(consoleLogSpy).toHaveBeenNthCalledWith( 3, - `${LOG_PREFIX} [DEBUG]`, + `[${LOG_PREFIX}DEBUG]`, 'third' ); }); diff --git a/tests/unit/page-script.test.ts b/tests/unit/page-script.test.ts index 6d1f5c7..9d5379e 100644 --- a/tests/unit/page-script.test.ts +++ b/tests/unit/page-script.test.ts @@ -504,6 +504,140 @@ describe('error handling', () => { }); }); +describe('fetch interception no-trim path (visibleKept === visibleTotal)', () => { + const mockedTrimMapping = vi.mocked(trimMapping); + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + localStorage.clear(); + delete (window as unknown as { __LS_PROXY_PATCHED__?: boolean }).__LS_PROXY_PATCHED__; + delete (window as unknown as { __LS_CONFIG__?: unknown }).__LS_CONFIG__; + delete (window as unknown as { __LS_DEBUG__?: boolean }).__LS_DEBUG__; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns original response when visibleKept === visibleTotal', async () => { + localStorage.setItem('ls_config', JSON.stringify({ enabled: true, limit: 10, debug: false })); + + const conversationData = createConversationData(4); + const originalResponse = createMockResponse(conversationData); + const nativeFetch = vi.fn(async () => originalResponse); + + (globalThis as unknown as { fetch: typeof fetch }).fetch = nativeFetch; + + // trimMapping returns visibleKept === visibleTotal (no visible nodes to trim) + mockedTrimMapping.mockReturnValue({ + mapping: conversationData.mapping, + current_node: 'node-3', + root: 'node-0', + keptCount: 4, + totalCount: 4, + visibleKept: 4, + visibleTotal: 4, + }); + + await import('../../extension/src/page/page-script'); + + const result = await window.fetch('https://chatgpt.com/backend-api/conversation/123'); + + // The original response object is returned unmodified + expect(result).toBe(originalResponse); + }); + + it('dispatches lightsession-status with removed === 0 when visibleKept === visibleTotal', async () => { + localStorage.setItem('ls_config', JSON.stringify({ enabled: true, limit: 10, debug: false })); + + const conversationData = createConversationData(4); + const nativeFetch = vi.fn(async () => createMockResponse(conversationData)); + + (globalThis as unknown as { fetch: typeof fetch }).fetch = nativeFetch; + + mockedTrimMapping.mockReturnValue({ + mapping: conversationData.mapping, + current_node: 'node-3', + root: 'node-0', + keptCount: 4, + totalCount: 4, + visibleKept: 4, + visibleTotal: 4, + }); + + const statusEvents: unknown[] = []; + window.addEventListener('lightsession-status', ((e: CustomEvent) => { + statusEvents.push(e.detail); + }) as EventListener); + + await import('../../extension/src/page/page-script'); + await window.fetch('https://chatgpt.com/backend-api/conversation/123'); + + expect(statusEvents.length).toBeGreaterThanOrEqual(1); + const last = statusEvents[statusEvents.length - 1] as { + totalBefore: number; + keptAfter: number; + removed: number; + }; + expect(last.totalBefore).toBe(4); + expect(last.keptAfter).toBe(4); + expect(last.removed).toBe(0); + }); + + it('returns original response when visibleKept === visibleTotal (exact limit: 5 of 5)', async () => { + localStorage.setItem('ls_config', JSON.stringify({ enabled: true, limit: 5, debug: false })); + + const conversationData = createConversationData(5); + const originalResponse = createMockResponse(conversationData); + const nativeFetch = vi.fn(async () => originalResponse); + + (globalThis as unknown as { fetch: typeof fetch }).fetch = nativeFetch; + + mockedTrimMapping.mockReturnValue({ + mapping: conversationData.mapping, + current_node: 'node-4', + root: 'node-0', + keptCount: 5, + totalCount: 5, + visibleKept: 5, + visibleTotal: 5, + }); + + await import('../../extension/src/page/page-script'); + + const result = await window.fetch('https://chatgpt.com/backend-api/conversation/123'); + + expect(result).toBe(originalResponse); + }); + + it('returns original response when visibleKept === visibleTotal (single message: 1 of 1)', async () => { + localStorage.setItem('ls_config', JSON.stringify({ enabled: true, limit: 10, debug: false })); + + const conversationData = createConversationData(1); + const originalResponse = createMockResponse(conversationData); + const nativeFetch = vi.fn(async () => originalResponse); + + (globalThis as unknown as { fetch: typeof fetch }).fetch = nativeFetch; + + mockedTrimMapping.mockReturnValue({ + mapping: conversationData.mapping, + current_node: 'node-0', + root: 'node-0', + keptCount: 1, + totalCount: 1, + visibleKept: 1, + visibleTotal: 1, + }); + + await import('../../extension/src/page/page-script'); + + const result = await window.fetch('https://chatgpt.com/backend-api/conversation/123'); + + expect(result).toBe(originalResponse); + }); +}); + // ============================================================================ // Config Gating Behavior (runtime fetch interception) // ============================================================================ diff --git a/tests/unit/trimmer.test.ts b/tests/unit/trimmer.test.ts index 7d713bd..4178ba7 100644 --- a/tests/unit/trimmer.test.ts +++ b/tests/unit/trimmer.test.ts @@ -298,6 +298,26 @@ describe('trimMapping - root node preservation', () => { // But it doesn't count as visible expect(result!.visibleKept).toBe(2); }); + + it('does not force-preserve visible first node as root anchor when trimming', () => { + // When conversation starts with a visible user message (no rootless anchor), + // the first node should NOT be duplicated as an anchor if it's trimmed away. + // [user, assistant, user, assistant] with limit=2 should trim the first 2 turns. + const { mapping, current_node } = buildConversation([ + 'user', + 'assistant', + 'user', + 'assistant', + ]); + const result = trimMapping({ mapping, current_node }, 2); + + expect(result).not.toBeNull(); + // First node (user) should be trimmed, not force-preserved as root + expect(result!.mapping['node-0']).toBeUndefined(); + // Root should be the first kept visible node, not node-0 + expect(result!.root).toBe('node-2'); + expect(result!.visibleKept).toBe(2); + }); }); // ============================================================================