ContextKeeper v1.0.0 — Persistent AI coding session context Chrome extension

This commit is contained in:
Bun Bun
2026-06-21 00:22:17 +00:00
commit 73f7b13e9d
25 changed files with 4544 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.verdict
.venv/
.DS_Store
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

+50
View File
@@ -0,0 +1,50 @@
{
"manifest_version": 3,
"name": "ContextKeeper — Stateful AI Context",
"version": "1.0.0",
"description": "Capture, persist, and restore AI coding session context across sessions. Never repeat yourself to an AI again.",
"permissions": ["storage", "activeTab", "scripting"],
"host_permissions": [
"https://chat.openai.com/*",
"https://chatgpt.com/*",
"https://claude.ai/*",
"https://cursor.sh/*",
"https://aistudio.google.com/*",
"https://gemini.google.com/*",
"https://copilot.microsoft.com/*",
"https://github.com/copilot/*"
],
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"background": {
"service_worker": "src/background.ts",
"type": "module"
},
"content_scripts": [
{
"matches": [
"https://chat.openai.com/*",
"https://chatgpt.com/*",
"https://claude.ai/*",
"https://cursor.sh/*",
"https://aistudio.google.com/*",
"https://gemini.google.com/*",
"https://copilot.microsoft.com/*",
"https://github.com/copilot/*"
],
"js": ["src/content.ts"],
"run_at": "document_idle"
}
],
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+2429
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "stateful-ai-agent-gap",
"version": "1.0.0",
"description": "Persistent context layer for AI coding tools — never lose your session context again",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest run"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.28",
"@types/chrome": "^0.0.270",
"jsdom": "^29.1.1",
"typescript": "^5.5.0",
"vite": "^5.3.0",
"vitest": "^2.0.0"
}
}
+52
View File
@@ -0,0 +1,52 @@
const fs = require('fs');
const zlib = require('zlib');
function createPNG(width, height, r, g, b) {
const lineSize = width * 3 + 1;
const rawSize = height * lineSize;
const raw = Buffer.alloc(rawSize);
for (let y = 0; y < height; y++) {
raw[y * lineSize] = 0;
for (let x = 0; x < width; x++) {
const offset = y * lineSize + 1 + x * 3;
raw[offset] = r;
raw[offset + 1] = g;
raw[offset + 2] = b;
}
}
const compressed = zlib.deflateSync(raw);
function chunk(type, data) {
const typeBuf = Buffer.from(type, 'ascii');
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const crc = Buffer.alloc(4);
const crcData = Buffer.concat([typeBuf, data]);
crc.writeUInt32BE(zlib.crc32(crcData), 0);
return Buffer.concat([len, typeBuf, data, crc]);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 2;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
const ihdrChunk = chunk('IHDR', ihdr);
const idatChunk = chunk('IDAT', compressed);
const iendChunk = chunk('IEND', Buffer.alloc(0));
return Buffer.concat([sig, ihdrChunk, idatChunk, iendChunk]);
}
fs.mkdirSync('icons', { recursive: true });
fs.writeFileSync('icons/icon16.png', createPNG(16, 16, 16, 185, 129));
fs.writeFileSync('icons/icon48.png', createPNG(48, 48, 16, 185, 129));
fs.writeFileSync('icons/icon128.png', createPNG(128, 128, 16, 185, 129));
console.log('Icons generated');
+118
View File
@@ -0,0 +1,118 @@
/**
* Background Service Worker
* Handles context capture/restore messaging and badge updates
*/
const PLATFORM_PATTERNS = {
chatgpt: /chatgpt\.com|chat\.openai\.com/i,
claude: /claude\.ai/i,
cursor: /cursor\.sh/i,
gemini: /gemini\.google\.com|aistudio\.google\.com/i,
copilot: /copilot\.microsoft\.com|github\.com\/copilot/i,
};
function detectPlatform(url) {
for (const [name, pattern] of Object.entries(PLATFORM_PATTERNS)) {
if (pattern.test(url))
return name;
}
return 'unknown';
}
async function getSessions() {
const result = await chrome.storage.local.get('sessions');
return result.sessions || [];
}
async function saveSessions(sessions) {
await chrome.storage.local.set({ sessions });
}
async function updateBadge(tabId, hasContext) {
const text = hasContext ? '✓' : '';
const color = hasContext ? '#10b981' : '#6b7280';
await chrome.action.setBadgeText({ text, tabId });
await chrome.action.setBadgeBackgroundColor({ color, tabId });
}
// Message handlers
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
if (message.type === 'CAPTURE_CONTEXT') {
const { title, url, context } = message.payload;
const platform = detectPlatform(url);
const sessions = await getSessions();
const newSession = {
id: crypto.randomUUID(),
title: title || 'Untitled Session',
url,
platform,
context,
timestamp: Date.now(),
charCount: context.length,
};
// Deduplicate by URL+title — keep newest
const filtered = sessions.filter((s) => !(s.url === url && s.title === title));
filtered.unshift(newSession);
// Keep last 50 sessions
const trimmed = filtered.slice(0, 50);
await saveSessions(trimmed);
if (sender.tab?.id) {
await updateBadge(sender.tab.id, true);
}
sendResponse({ success: true, id: newSession.id, total: trimmed.length });
}
if (message.type === 'GET_CONTEXT') {
const sessions = await getSessions();
const { url, title } = message.payload;
const match = sessions.find((s) => s.url === url && s.title === title);
sendResponse({ success: true, found: !!match, context: match?.context || null });
}
if (message.type === 'LIST_SESSIONS') {
const sessions = await getSessions();
sendResponse({ success: true, sessions });
}
if (message.type === 'DELETE_SESSION') {
const sessions = await getSessions();
const filtered = sessions.filter((s) => s.id !== message.payload.id);
await saveSessions(filtered);
sendResponse({ success: true, total: filtered.length });
}
if (message.type === 'RESTORE_CONTEXT') {
// Content script will handle actual injection; background just confirms
sendResponse({ success: true });
}
if (message.type === 'CLEAR_ALL') {
await chrome.storage.local.remove('sessions');
sendResponse({ success: true });
}
})();
return true; // Keep channel open for async
});
// Update badge on tab change
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
try {
const tab = await chrome.tabs.get(tabId);
if (!tab.url)
return;
const platform = detectPlatform(tab.url);
if (platform === 'unknown') {
await chrome.action.setBadgeText({ text: '', tabId });
return;
}
const sessions = await getSessions();
const hasContext = sessions.some((s) => s.url === tab.url);
await updateBadge(tabId, hasContext);
}
catch {
// Tab may have closed
}
});
chrome.tabs.onUpdated.addListener(async (tabId, _changeInfo, tab) => {
if (!tab.url || !tab.active)
return;
const platform = detectPlatform(tab.url);
if (platform === 'unknown') {
await chrome.action.setBadgeText({ text: '', tabId });
return;
}
const sessions = await getSessions();
const hasContext = sessions.some((s) => s.url === tab.url);
await updateBadge(tabId, hasContext);
});
// Export for testing
export { detectPlatform, getSessions, saveSessions, updateBadge };
+151
View File
@@ -0,0 +1,151 @@
/**
* Background Service Worker
* Handles context capture/restore messaging and badge updates
*/
interface SavedSession {
id: string;
title: string;
url: string;
platform: string;
context: string;
timestamp: number;
charCount: number;
}
const PLATFORM_PATTERNS: Record<string, RegExp> = {
chatgpt: /chatgpt\.com|chat\.openai\.com/i,
claude: /claude\.ai/i,
cursor: /cursor\.sh/i,
gemini: /gemini\.google\.com|aistudio\.google\.com/i,
copilot: /copilot\.microsoft\.com|github\.com\/copilot/i,
};
function detectPlatform(url: string): string {
for (const [name, pattern] of Object.entries(PLATFORM_PATTERNS)) {
if (pattern.test(url)) return name;
}
return 'unknown';
}
async function getSessions(): Promise<SavedSession[]> {
const result = await chrome.storage.local.get('sessions');
return (result.sessions as SavedSession[]) || [];
}
async function saveSessions(sessions: SavedSession[]): Promise<void> {
await chrome.storage.local.set({ sessions });
}
async function updateBadge(tabId: number, hasContext: boolean): Promise<void> {
const text = hasContext ? '✓' : '';
const color = hasContext ? '#10b981' : '#6b7280';
await chrome.action.setBadgeText({ text, tabId });
await chrome.action.setBadgeBackgroundColor({ color, tabId });
}
// Message handlers
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
(async () => {
if (message.type === 'CAPTURE_CONTEXT') {
const { title, url, context } = message.payload;
const platform = detectPlatform(url);
const sessions = await getSessions();
const newSession: SavedSession = {
id: crypto.randomUUID(),
title: title || 'Untitled Session',
url,
platform,
context,
timestamp: Date.now(),
charCount: context.length,
};
// Deduplicate by URL+title — keep newest
const filtered = sessions.filter(
(s) => !(s.url === url && s.title === title)
);
filtered.unshift(newSession);
// Keep last 50 sessions
const trimmed = filtered.slice(0, 50);
await saveSessions(trimmed);
if (sender.tab?.id) {
await updateBadge(sender.tab.id, true);
}
sendResponse({ success: true, id: newSession.id, total: trimmed.length });
}
if (message.type === 'GET_CONTEXT') {
const sessions = await getSessions();
const { url, title } = message.payload;
const match = sessions.find(
(s) => s.url === url && s.title === title
);
sendResponse({ success: true, found: !!match, context: match?.context || null });
}
if (message.type === 'LIST_SESSIONS') {
const sessions = await getSessions();
sendResponse({ success: true, sessions });
}
if (message.type === 'DELETE_SESSION') {
const sessions = await getSessions();
const filtered = sessions.filter((s) => s.id !== message.payload.id);
await saveSessions(filtered);
sendResponse({ success: true, total: filtered.length });
}
if (message.type === 'RESTORE_CONTEXT') {
// Content script will handle actual injection; background just confirms
sendResponse({ success: true });
}
if (message.type === 'CLEAR_ALL') {
await chrome.storage.local.remove('sessions');
sendResponse({ success: true });
}
})();
return true; // Keep channel open for async
});
// Update badge on tab change
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
try {
const tab = await chrome.tabs.get(tabId);
if (!tab.url) return;
const platform = detectPlatform(tab.url);
if (platform === 'unknown') {
await chrome.action.setBadgeText({ text: '', tabId });
return;
}
const sessions = await getSessions();
const hasContext = sessions.some((s) => s.url === tab.url);
await updateBadge(tabId, hasContext);
} catch {
// Tab may have closed
}
});
chrome.tabs.onUpdated.addListener(async (tabId, _changeInfo, tab) => {
if (!tab.url || !tab.active) return;
const platform = detectPlatform(tab.url);
if (platform === 'unknown') {
await chrome.action.setBadgeText({ text: '', tabId });
return;
}
const sessions = await getSessions();
const hasContext = sessions.some((s) => s.url === tab.url);
await updateBadge(tabId, hasContext);
});
// Export for testing
export { detectPlatform, getSessions, saveSessions, updateBadge };
export type { SavedSession };
+222
View File
@@ -0,0 +1,222 @@
/**
* Content Script — Injected into AI tool pages
* Captures conversation context and can restore it on request
*/
import { PlatformDetector } from './lib/detector';
import { ContextExtractor } from './lib/extractor';
const detector = new PlatformDetector();
const extractor = new ContextExtractor();
let captureInterval = null;
function getPageUrl() {
return window.location.href.split('?')[0];
}
function getPageTitle() {
return document.title;
}
async function captureContext() {
const platform = detector.detect(window.location.href);
if (!platform)
return;
const context = extractor.extract(platform);
if (!context || context.trim().length < 50)
return; // Skip empty/short context
try {
await chrome.runtime.sendMessage({
type: 'CAPTURE_CONTEXT',
payload: {
title: getPageTitle(),
url: getPageUrl(),
context,
},
});
}
catch {
// Extension may be reloading
}
}
function checkForRestorableContext() {
const platform = detector.detect(window.location.href);
if (!platform)
return;
chrome.runtime.sendMessage({
type: 'GET_CONTEXT',
payload: {
title: getPageTitle(),
url: getPageUrl(),
},
}, (response) => {
if (response?.found && response.context) {
showRestoreNotification(response.context);
}
});
}
function showRestoreNotification(context) {
// Remove existing notification
const existing = document.getElementById('contextkeeper-restore');
if (existing)
existing.remove();
const el = document.createElement('div');
el.id = 'contextkeeper-restore';
el.innerHTML = `
<div style="
position: fixed;
top: 16px;
right: 16px;
z-index: 999999;
background: #1f2937;
color: #f9fafb;
padding: 16px 20px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
font-family: system-ui, -apple-system, sans-serif;
max-width: 360px;
border: 1px solid #374151;
animation: ck-slide-in 0.3s ease-out;
">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">
🧠 ContextKeeper
</div>
<div style="font-size: 13px; color: #d1d5db; margin-bottom: 12px; line-height: 1.5;">
Saved context found for this conversation (${context.length.toLocaleString()} chars). Restore it?
</div>
<div style="display: flex; gap: 8px;">
<button id="ck-restore-yes" style="
background: #10b981;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
flex: 1;
">Restore</button>
<button id="ck-restore-no" style="
background: #374151;
color: #d1d5db;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
flex: 1;
">Dismiss</button>
</div>
</div>
<style>
@keyframes ck-slide-in {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
`;
document.body.appendChild(el);
el.querySelector('#ck-restore-yes')?.addEventListener('click', () => {
restoreContextToPage(context);
el.remove();
});
el.querySelector('#ck-restore-no')?.addEventListener('click', () => {
el.remove();
});
// Auto-dismiss after 30 seconds
setTimeout(() => el.remove(), 30000);
}
function restoreContextToPage(context) {
const platform = detector.detect(window.location.href);
if (!platform)
return;
// Try to paste into the input field
const inputSelectors = {
chatgpt: ['#prompt-textarea', 'textarea[placeholder*="Message"]', 'textarea[data-id="root"]', 'div[contenteditable="true"]'],
claude: ['div[contenteditable="true"]', 'textarea[placeholder*="Message"]', 'div[role="textbox"]'],
cursor: ['textarea', 'div[contenteditable="true"]'],
gemini: ['textarea[placeholder*="Ask"]', 'div[contenteditable="true"]'],
copilot: ['textarea', 'div[contenteditable="true"]'],
};
const selectors = inputSelectors[platform] || ['textarea', 'div[contenteditable="true"]'];
for (const selector of selectors) {
const el = document.querySelector(selector);
if (!el)
continue;
if (el instanceof HTMLTextAreaElement) {
el.value = context;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.focus();
}
else if (el.isContentEditable) {
el.textContent = context;
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
el.focus();
}
break; // Stop after first successful injection
}
}
// Handle messages from popup
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'REQUEST_CAPTURE') {
const platform = detector.detect(window.location.href);
if (!platform) {
sendResponse({ success: false, reason: 'not_ai_platform' });
return true;
}
const context = extractor.extract(platform);
if (!context || context.length < 50) {
sendResponse({ success: false, reason: 'no_content' });
return true;
}
captureContext().then(() => {
sendResponse({ success: true, charCount: context.length });
}).catch(() => {
sendResponse({ success: false, reason: 'capture_failed' });
});
return true;
}
if (message.type === 'INJECT_CONTEXT') {
const { context } = message.payload;
restoreContextToPage(context);
sendResponse({ success: true });
return true;
}
return true;
});
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
}
else {
init();
}
function init() {
const platform = detector.detect(window.location.href);
if (!platform)
return;
// Check for restorable context on page load
setTimeout(checkForRestorableContext, 2000);
// Periodic capture every 30 seconds (only if content changed)
let lastContext = '';
captureInterval = setInterval(() => {
const current = extractor.extract(platform);
if (current && current !== lastContext && current.length > 50) {
lastContext = current;
captureContext();
}
}, 30000);
// Capture before page unload
window.addEventListener('beforeunload', () => {
captureContext();
});
}
// Cleanup on navigation (SPA navigation)
let currentUrl = window.location.href;
const observer = new MutationObserver(() => {
if (window.location.href !== currentUrl) {
currentUrl = window.location.href;
if (captureInterval) {
clearInterval(captureInterval);
captureInterval = null;
}
init();
}
});
observer.observe(document.body, { childList: true, subtree: true });
+249
View File
@@ -0,0 +1,249 @@
/**
* Content Script — Injected into AI tool pages
* Captures conversation context and can restore it on request
*/
import { PlatformDetector } from './lib/detector';
import { ContextExtractor } from './lib/extractor';
const detector = new PlatformDetector();
const extractor = new ContextExtractor();
let captureInterval: ReturnType<typeof setInterval> | null = null;
function getPageUrl(): string {
return window.location.href.split('?')[0];
}
function getPageTitle(): string {
return document.title;
}
async function captureContext(): Promise<void> {
const platform = detector.detect(window.location.href);
if (!platform) return;
const context = extractor.extract(platform);
if (!context || context.trim().length < 50) return; // Skip empty/short context
try {
await chrome.runtime.sendMessage({
type: 'CAPTURE_CONTEXT',
payload: {
title: getPageTitle(),
url: getPageUrl(),
context,
},
});
} catch {
// Extension may be reloading
}
}
function checkForRestorableContext(): void {
const platform = detector.detect(window.location.href);
if (!platform) return;
chrome.runtime.sendMessage(
{
type: 'GET_CONTEXT',
payload: {
title: getPageTitle(),
url: getPageUrl(),
},
},
(response) => {
if (response?.found && response.context) {
showRestoreNotification(response.context);
}
}
);
}
function showRestoreNotification(context: string): void {
// Remove existing notification
const existing = document.getElementById('contextkeeper-restore');
if (existing) existing.remove();
const el = document.createElement('div');
el.id = 'contextkeeper-restore';
el.innerHTML = `
<div style="
position: fixed;
top: 16px;
right: 16px;
z-index: 999999;
background: #1f2937;
color: #f9fafb;
padding: 16px 20px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
font-family: system-ui, -apple-system, sans-serif;
max-width: 360px;
border: 1px solid #374151;
animation: ck-slide-in 0.3s ease-out;
">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">
🧠 ContextKeeper
</div>
<div style="font-size: 13px; color: #d1d5db; margin-bottom: 12px; line-height: 1.5;">
Saved context found for this conversation (${context.length.toLocaleString()} chars). Restore it?
</div>
<div style="display: flex; gap: 8px;">
<button id="ck-restore-yes" style="
background: #10b981;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
flex: 1;
">Restore</button>
<button id="ck-restore-no" style="
background: #374151;
color: #d1d5db;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
flex: 1;
">Dismiss</button>
</div>
</div>
<style>
@keyframes ck-slide-in {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
`;
document.body.appendChild(el);
el.querySelector('#ck-restore-yes')?.addEventListener('click', () => {
restoreContextToPage(context);
el.remove();
});
el.querySelector('#ck-restore-no')?.addEventListener('click', () => {
el.remove();
});
// Auto-dismiss after 30 seconds
setTimeout(() => el.remove(), 30000);
}
function restoreContextToPage(context: string): void {
const platform = detector.detect(window.location.href);
if (!platform) return;
// Try to paste into the input field
const inputSelectors: Record<string, string[]> = {
chatgpt: ['#prompt-textarea', 'textarea[placeholder*="Message"]', 'textarea[data-id="root"]', 'div[contenteditable="true"]'],
claude: ['div[contenteditable="true"]', 'textarea[placeholder*="Message"]', 'div[role="textbox"]'],
cursor: ['textarea', 'div[contenteditable="true"]'],
gemini: ['textarea[placeholder*="Ask"]', 'div[contenteditable="true"]'],
copilot: ['textarea', 'div[contenteditable="true"]'],
};
const selectors = inputSelectors[platform] || ['textarea', 'div[contenteditable="true"]'];
for (const selector of selectors) {
const el = document.querySelector(selector) as HTMLElement;
if (!el) continue;
if (el instanceof HTMLTextAreaElement) {
el.value = context;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.focus();
} else if (el.isContentEditable) {
el.textContent = context;
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
el.focus();
}
break; // Stop after first successful injection
}
}
// Handle messages from popup
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'REQUEST_CAPTURE') {
const platform = detector.detect(window.location.href);
if (!platform) {
sendResponse({ success: false, reason: 'not_ai_platform' });
return true;
}
const context = extractor.extract(platform);
if (!context || context.length < 50) {
sendResponse({ success: false, reason: 'no_content' });
return true;
}
captureContext().then(() => {
sendResponse({ success: true, charCount: context.length });
}).catch(() => {
sendResponse({ success: false, reason: 'capture_failed' });
});
return true;
}
if (message.type === 'INJECT_CONTEXT') {
const { context } = message.payload;
restoreContextToPage(context);
sendResponse({ success: true });
return true;
}
return true;
});
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init(): void {
const platform = detector.detect(window.location.href);
if (!platform) return;
// Check for restorable context on page load
setTimeout(checkForRestorableContext, 2000);
// Periodic capture every 30 seconds (only if content changed)
let lastContext = '';
captureInterval = setInterval(() => {
const current = extractor.extract(platform);
if (current && current !== lastContext && current.length > 50) {
lastContext = current;
captureContext();
}
}, 30000);
// Capture before page unload
window.addEventListener('beforeunload', () => {
captureContext();
});
}
// Cleanup on navigation (SPA navigation)
let currentUrl = window.location.href;
const observer = new MutationObserver(() => {
if (window.location.href !== currentUrl) {
currentUrl = window.location.href;
if (captureInterval) {
clearInterval(captureInterval);
captureInterval = null;
}
init();
}
});
observer.observe(document.body, { childList: true, subtree: true });
export {};
+32
View File
@@ -0,0 +1,32 @@
/**
* Platform Detector — Identifies which AI tool the user is on
*/
export class PlatformDetector {
patterns = {
chatgpt: /chatgpt\.com|chat\.openai\.com/i,
claude: /claude\.ai/i,
cursor: /cursor\.sh/i,
gemini: /gemini\.google\.com|aistudio\.google\.com/i,
copilot: /copilot\.microsoft\.com|github\.com\/copilot/i,
};
detect(url) {
for (const [name, pattern] of Object.entries(this.patterns)) {
if (pattern.test(url))
return name;
}
return null;
}
getPlatformName(platform) {
const names = {
chatgpt: 'ChatGPT',
claude: 'Claude',
cursor: 'Cursor',
gemini: 'Gemini',
copilot: 'GitHub Copilot',
};
return names[platform] || platform;
}
getSupportedPlatforms() {
return Object.keys(this.patterns);
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Platform Detector — Identifies which AI tool the user is on
*/
export class PlatformDetector {
private readonly patterns: Record<string, RegExp> = {
chatgpt: /chatgpt\.com|chat\.openai\.com/i,
claude: /claude\.ai/i,
cursor: /cursor\.sh/i,
gemini: /gemini\.google\.com|aistudio\.google\.com/i,
copilot: /copilot\.microsoft\.com|github\.com\/copilot/i,
};
detect(url: string): string | null {
for (const [name, pattern] of Object.entries(this.patterns)) {
if (pattern.test(url)) return name;
}
return null;
}
getPlatformName(platform: string): string {
const names: Record<string, string> = {
chatgpt: 'ChatGPT',
claude: 'Claude',
cursor: 'Cursor',
gemini: 'Gemini',
copilot: 'GitHub Copilot',
};
return names[platform] || platform;
}
getSupportedPlatforms(): string[] {
return Object.keys(this.patterns);
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Context Extractor — Pulls conversation context from AI tool DOMs
*/
export class ContextExtractor {
extract(platform) {
switch (platform) {
case 'chatgpt':
return this.extractChatGPT();
case 'claude':
return this.extractClaude();
case 'cursor':
return this.extractCursor();
case 'gemini':
return this.extractGemini();
case 'copilot':
return this.extractCopilot();
default:
return '';
}
}
extractChatGPT() {
// ChatGPT: conversation turns are in [data-testid^="conversation-turn-"]
const turns = document.querySelectorAll('[data-testid^="conversation-turn-"]');
if (turns.length === 0) {
// Fallback: look for article elements or message divs
const articles = document.querySelectorAll('article');
return this.extractFromElements(articles, 'user', 'assistant');
}
return this.extractFromElements(turns, 'user', 'assistant');
}
extractClaude() {
// Claude: messages in .message or [data-testid="message"]
const messages = document.querySelectorAll('.message, [data-testid="message"]');
if (messages.length === 0) {
// Try content blocks
const contents = document.querySelectorAll('.prose, [data-testid="content"]');
return this.extractFromElements(contents, 'human', 'assistant');
}
return this.extractFromElements(messages, 'human', 'assistant');
}
extractCursor() {
// Cursor: chat messages in the sidebar
const messages = document.querySelectorAll('.chat-message, .message-bubble');
return this.extractFromElements(messages, 'user', 'assistant');
}
extractGemini() {
// Gemini: message containers
const messages = document.querySelectorAll('.message-content, [data-testid="message-content"]');
return this.extractFromElements(messages, 'user', 'model');
}
extractCopilot() {
// Copilot: chat turns
const turns = document.querySelectorAll('.turn, .chat-turn');
return this.extractFromElements(turns, 'user', 'assistant');
}
extractFromElements(elements, userLabel, assistantLabel) {
if (elements.length === 0)
return '';
const parts = [];
let turnIndex = 0;
for (const el of Array.from(elements)) {
const text = this.getTextContent(el).trim();
if (!text || text.length < 3)
continue;
const role = turnIndex % 2 === 0 ? userLabel : assistantLabel;
parts.push(`[${role}]: ${text}`);
turnIndex++;
}
return parts.join('\n\n');
}
getTextContent(el) {
// Clone to avoid modifying live DOM
const clone = el.cloneNode(true);
// Remove code copy buttons, timestamps, etc.
clone.querySelectorAll('button, .timestamp, .copy-button, [role="button"]').forEach((b) => b.remove());
return clone.textContent || '';
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Context Extractor — Pulls conversation context from AI tool DOMs
*/
export class ContextExtractor {
extract(platform: string): string {
switch (platform) {
case 'chatgpt':
return this.extractChatGPT();
case 'claude':
return this.extractClaude();
case 'cursor':
return this.extractCursor();
case 'gemini':
return this.extractGemini();
case 'copilot':
return this.extractCopilot();
default:
return '';
}
}
private extractChatGPT(): string {
// ChatGPT: conversation turns are in [data-testid^="conversation-turn-"]
const turns = document.querySelectorAll('[data-testid^="conversation-turn-"]');
if (turns.length === 0) {
// Fallback: look for article elements or message divs
const articles = document.querySelectorAll('article');
return this.extractFromElements(articles, 'user', 'assistant');
}
return this.extractFromElements(turns, 'user', 'assistant');
}
private extractClaude(): string {
// Claude: messages in .message or [data-testid="message"]
const messages = document.querySelectorAll('.message, [data-testid="message"]');
if (messages.length === 0) {
// Try content blocks
const contents = document.querySelectorAll('.prose, [data-testid="content"]');
return this.extractFromElements(contents, 'human', 'assistant');
}
return this.extractFromElements(messages, 'human', 'assistant');
}
private extractCursor(): string {
// Cursor: chat messages in the sidebar
const messages = document.querySelectorAll('.chat-message, .message-bubble');
return this.extractFromElements(messages, 'user', 'assistant');
}
private extractGemini(): string {
// Gemini: message containers
const messages = document.querySelectorAll('.message-content, [data-testid="message-content"]');
return this.extractFromElements(messages, 'user', 'model');
}
private extractCopilot(): string {
// Copilot: chat turns
const turns = document.querySelectorAll('.turn, .chat-turn');
return this.extractFromElements(turns, 'user', 'assistant');
}
private extractFromElements(
elements: NodeListOf<Element>,
userLabel: string,
assistantLabel: string
): string {
if (elements.length === 0) return '';
const parts: string[] = [];
let turnIndex = 0;
for (const el of Array.from(elements)) {
const text = this.getTextContent(el).trim();
if (!text || text.length < 3) continue;
const role = turnIndex % 2 === 0 ? userLabel : assistantLabel;
parts.push(`[${role}]: ${text}`);
turnIndex++;
}
return parts.join('\n\n');
}
private getTextContent(el: Element): string {
// Clone to avoid modifying live DOM
const clone = el.cloneNode(true) as Element;
// Remove code copy buttons, timestamps, etc.
clone.querySelectorAll('button, .timestamp, .copy-button, [role="button"]').forEach((b) => b.remove());
return clone.textContent || '';
}
}
+324
View File
@@ -0,0 +1,324 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 380px;
min-height: 400px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 16px;
}
.header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.logo {
font-size: 28px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: #1e293b;
border-radius: 12px;
border: 1px solid #334155;
}
.title {
font-size: 16px;
font-weight: 700;
color: #f8fafc;
}
.subtitle {
font-size: 12px;
color: #94a3b8;
margin-top: 2px;
}
.current-page {
background: #1e293b;
border: 1px solid #334155;
border-radius: 10px;
padding: 12px;
margin-bottom: 12px;
}
.platform-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 6px;
background: #334155;
color: #cbd5e1;
margin-bottom: 6px;
}
.platform-badge.chatgpt { background: #10a37f20; color: #10a37f; border: 1px solid #10a37f40; }
.platform-badge.claude { background: #d9775720; color: #d97757; border: 1px solid #d9775740; }
.platform-badge.cursor { background: #6366f120; color: #6366f1; border: 1px solid #6366f140; }
.platform-badge.gemini { background: #8b5cf620; color: #8b5cf6; border: 1px solid #8b5cf640; }
.platform-badge.copilot { background: #0ea5e920; color: #0ea5e9; border: 1px solid #0ea5e940; }
.page-title {
font-size: 13px;
font-weight: 500;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.btn {
flex: 1;
padding: 10px 14px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: #10b981;
color: white;
}
.btn-primary:hover:not(:disabled) { background: #059669; }
.btn-secondary {
background: #334155;
color: #e2e8f0;
border: 1px solid #475569;
}
.btn-secondary:hover:not(:disabled) { background: #475569; }
.divider {
height: 1px;
background: #334155;
margin: 14px 0;
}
.sessions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.sessions-title {
font-size: 13px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-clear {
background: transparent;
border: none;
font-size: 14px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
padding: 4px;
}
.btn-clear:hover { opacity: 1; }
.sessions-list {
max-height: 220px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.session-card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
transition: all 0.15s;
position: relative;
}
.session-card:hover {
border-color: #475569;
background: #263348;
}
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.session-title {
font-size: 12px;
font-weight: 600;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.session-platform {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
}
.session-meta {
font-size: 11px;
color: #64748b;
display: flex;
gap: 10px;
}
.session-delete {
position: absolute;
top: 8px;
right: 8px;
background: transparent;
border: none;
color: #64748b;
font-size: 12px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
padding: 2px 6px;
border-radius: 4px;
}
.session-card:hover .session-delete { opacity: 1; }
.session-delete:hover { color: #ef4444; background: #ef444420; }
.empty-state {
text-align: center;
padding: 24px;
color: #64748b;
font-size: 13px;
}
.stats {
margin-top: 10px;
text-align: center;
font-size: 11px;
color: #475569;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #64748b; }
.toast {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
background: #10b981;
color: white;
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
animation: toast-in 0.2s ease-out;
z-index: 10000;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fade-in 0.15s ease-out;
}
.confirm-box {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 20px;
width: 280px;
text-align: center;
}
.confirm-title {
font-size: 14px;
font-weight: 600;
color: #f8fafc;
margin-bottom: 8px;
}
.confirm-text {
font-size: 12px;
color: #94a3b8;
margin-bottom: 16px;
line-height: 1.5;
}
.confirm-buttons {
display: flex;
gap: 8px;
}
.confirm-btn {
flex: 1;
padding: 8px;
border-radius: 6px;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.confirm-btn-danger {
background: #ef4444;
color: white;
}
.confirm-btn-danger:hover { background: #dc2626; }
.confirm-btn-cancel {
background: #334155;
color: #e2e8f0;
}
.confirm-btn-cancel:hover { background: #475569; }
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ContextKeeper</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="header">
<div class="logo">🧠</div>
<div>
<div class="title">ContextKeeper</div>
<div class="subtitle">Never lose your AI context</div>
</div>
</div>
<div id="current-page" class="current-page">
<div class="platform-badge" id="platform-badge"></div>
<div class="page-title" id="page-title">No AI tool detected</div>
</div>
<div class="actions" id="actions">
<button id="btn-capture" class="btn btn-primary" disabled>
💾 Capture Now
</button>
<button id="btn-restore" class="btn btn-secondary" disabled>
🔄 Restore Context
</button>
</div>
<div class="divider"></div>
<div class="sessions-header">
<div class="sessions-title">Saved Sessions</div>
<button id="btn-clear" class="btn-clear" title="Clear all sessions">🗑</button>
</div>
<div id="sessions-list" class="sessions-list">
<div class="empty-state">No saved sessions yet</div>
</div>
<div class="stats" id="stats"></div>
<script src="popup.ts" type="module"></script>
</body>
</html>
+220
View File
@@ -0,0 +1,220 @@
/**
* Popup Controller — Manages the extension popup UI
*/
import { PlatformDetector } from '../lib/detector';
const detector = new PlatformDetector();
async function getCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab || null;
}
function formatTimeAgo(timestamp) {
const diff = Date.now() - timestamp;
const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (mins < 1)
return 'just now';
if (mins < 60)
return `${mins}m ago`;
if (hours < 24)
return `${hours}h ago`;
return `${days}d ago`;
}
function formatChars(n) {
if (n >= 1000)
return `${(n / 1000).toFixed(1)}k chars`;
return `${n} chars`;
}
function getPlatformColorClass(platform) {
return platform.toLowerCase();
}
async function renderSessions() {
const listEl = document.getElementById('sessions-list');
const statsEl = document.getElementById('stats');
const response = await chrome.runtime.sendMessage({ type: 'LIST_SESSIONS' });
const sessions = response?.sessions || [];
if (sessions.length === 0) {
listEl.innerHTML = '<div class="empty-state">No saved sessions yet</div>';
statsEl.textContent = '';
return;
}
listEl.innerHTML = '';
for (const session of sessions) {
const card = document.createElement('div');
card.className = 'session-card';
card.innerHTML = `
<div class="session-header">
<div class="session-title" title="${escapeHtml(session.title)}">${escapeHtml(session.title)}</div>
<div class="session-platform ${getPlatformColorClass(session.platform)}">${escapeHtml(session.platform)}</div>
</div>
<div class="session-meta">
<span>${formatTimeAgo(session.timestamp)}</span>
<span>${formatChars(session.charCount)}</span>
</div>
<button class="session-delete" data-id="${session.id}" title="Delete">×</button>
`;
card.addEventListener('click', (e) => {
if (e.target.classList.contains('session-delete'))
return;
restoreSession(session);
});
card.querySelector('.session-delete')?.addEventListener('click', (e) => {
e.stopPropagation();
deleteSession(session.id);
});
listEl.appendChild(card);
}
statsEl.textContent = `${sessions.length} session${sessions.length === 1 ? '' : 's'} · ${formatChars(sessions.reduce((sum, s) => sum + s.charCount, 0))} total`;
}
async function restoreSession(session) {
// Open the URL in the current or new tab
const tab = await getCurrentTab();
if (tab?.id) {
await chrome.tabs.update(tab.id, { url: session.url });
// After navigation, inject context via content script
setTimeout(async () => {
try {
await chrome.tabs.sendMessage(tab.id, {
type: 'INJECT_CONTEXT',
payload: { context: session.context },
});
}
catch {
// Page may not be loaded yet
}
}, 3000);
}
window.close();
}
async function deleteSession(id) {
await chrome.runtime.sendMessage({ type: 'DELETE_SESSION', payload: { id } });
await renderSessions();
showToast('Session deleted');
}
function showToast(message) {
const existing = document.querySelector('.toast');
if (existing)
existing.remove();
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
function showConfirm(title, text, onConfirm) {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-box">
<div class="confirm-title">${escapeHtml(title)}</div>
<div class="confirm-text">${escapeHtml(text)}</div>
<div class="confirm-buttons">
<button class="confirm-btn confirm-btn-danger" id="confirm-yes">Delete</button>
<button class="confirm-btn confirm-btn-cancel" id="confirm-no">Cancel</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('#confirm-yes')?.addEventListener('click', () => {
onConfirm();
overlay.remove();
});
overlay.querySelector('#confirm-no')?.addEventListener('click', () => {
overlay.remove();
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay)
overlay.remove();
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function updateCurrentPage() {
const badgeEl = document.getElementById('platform-badge');
const titleEl = document.getElementById('page-title');
const captureBtn = document.getElementById('btn-capture');
const restoreBtn = document.getElementById('btn-restore');
const tab = await getCurrentTab();
if (!tab?.url) {
badgeEl.textContent = '—';
titleEl.textContent = 'No active page';
captureBtn.disabled = true;
restoreBtn.disabled = true;
return;
}
const platform = detector.detect(tab.url);
if (!platform) {
badgeEl.textContent = '—';
badgeEl.className = 'platform-badge';
titleEl.textContent = 'No AI tool detected on this page';
captureBtn.disabled = true;
restoreBtn.disabled = true;
return;
}
badgeEl.textContent = detector.getPlatformName(platform);
badgeEl.className = `platform-badge ${getPlatformColorClass(platform)}`;
titleEl.textContent = tab.title || 'Untitled';
captureBtn.disabled = false;
restoreBtn.disabled = false;
}
async function captureCurrent() {
const tab = await getCurrentTab();
if (!tab?.id)
return;
try {
const response = await chrome.tabs.sendMessage(tab.id, { type: 'REQUEST_CAPTURE' });
if (response?.success) {
showToast('Context captured!');
await renderSessions();
}
else {
showToast('Nothing to capture');
}
}
catch {
showToast('Refresh the page first');
}
}
async function restoreCurrent() {
const tab = await getCurrentTab();
if (!tab?.id || !tab.url)
return;
const response = await chrome.runtime.sendMessage({
type: 'GET_CONTEXT',
payload: { title: tab.title || '', url: tab.url.split('?')[0] },
});
if (response?.found && response.context) {
try {
await chrome.tabs.sendMessage(tab.id, {
type: 'INJECT_CONTEXT',
payload: { context: response.context },
});
showToast('Context restored!');
}
catch {
showToast('Refresh the page first');
}
}
else {
showToast('No saved context for this page');
}
}
async function clearAll() {
showConfirm('Clear All Sessions', 'This will permanently delete all saved context. Are you sure?', async () => {
await chrome.runtime.sendMessage({ type: 'CLEAR_ALL' });
await renderSessions();
showToast('All sessions cleared');
});
}
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
await updateCurrentPage();
await renderSessions();
document.getElementById('btn-capture')?.addEventListener('click', captureCurrent);
document.getElementById('btn-restore')?.addEventListener('click', restoreCurrent);
document.getElementById('btn-clear')?.addEventListener('click', clearAll);
});
export { renderSessions, updateCurrentPage, formatTimeAgo, formatChars };
+253
View File
@@ -0,0 +1,253 @@
/**
* Popup Controller — Manages the extension popup UI
*/
import { PlatformDetector } from '../lib/detector';
interface SavedSession {
id: string;
title: string;
url: string;
platform: string;
context: string;
timestamp: number;
charCount: number;
}
const detector = new PlatformDetector();
async function getCurrentTab(): Promise<chrome.tabs.Tab | null> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab || null;
}
function formatTimeAgo(timestamp: number): string {
const diff = Date.now() - timestamp;
const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
}
function formatChars(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k chars`;
return `${n} chars`;
}
function getPlatformColorClass(platform: string): string {
return platform.toLowerCase();
}
async function renderSessions(): Promise<void> {
const listEl = document.getElementById('sessions-list')!;
const statsEl = document.getElementById('stats')!;
const response = await chrome.runtime.sendMessage({ type: 'LIST_SESSIONS' });
const sessions: SavedSession[] = response?.sessions || [];
if (sessions.length === 0) {
listEl.innerHTML = '<div class="empty-state">No saved sessions yet</div>';
statsEl.textContent = '';
return;
}
listEl.innerHTML = '';
for (const session of sessions) {
const card = document.createElement('div');
card.className = 'session-card';
card.innerHTML = `
<div class="session-header">
<div class="session-title" title="${escapeHtml(session.title)}">${escapeHtml(session.title)}</div>
<div class="session-platform ${getPlatformColorClass(session.platform)}">${escapeHtml(session.platform)}</div>
</div>
<div class="session-meta">
<span>${formatTimeAgo(session.timestamp)}</span>
<span>${formatChars(session.charCount)}</span>
</div>
<button class="session-delete" data-id="${session.id}" title="Delete">×</button>
`;
card.addEventListener('click', (e) => {
if ((e.target as HTMLElement).classList.contains('session-delete')) return;
restoreSession(session);
});
card.querySelector('.session-delete')?.addEventListener('click', (e) => {
e.stopPropagation();
deleteSession(session.id);
});
listEl.appendChild(card);
}
statsEl.textContent = `${sessions.length} session${sessions.length === 1 ? '' : 's'} · ${formatChars(
sessions.reduce((sum, s) => sum + s.charCount, 0)
)} total`;
}
async function restoreSession(session: SavedSession): Promise<void> {
// Open the URL in the current or new tab
const tab = await getCurrentTab();
if (tab?.id) {
await chrome.tabs.update(tab.id, { url: session.url });
// After navigation, inject context via content script
setTimeout(async () => {
try {
await chrome.tabs.sendMessage(tab.id!, {
type: 'INJECT_CONTEXT',
payload: { context: session.context },
});
} catch {
// Page may not be loaded yet
}
}, 3000);
}
window.close();
}
async function deleteSession(id: string): Promise<void> {
await chrome.runtime.sendMessage({ type: 'DELETE_SESSION', payload: { id } });
await renderSessions();
showToast('Session deleted');
}
function showToast(message: string): void {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
function showConfirm(title: string, text: string, onConfirm: () => void): void {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-box">
<div class="confirm-title">${escapeHtml(title)}</div>
<div class="confirm-text">${escapeHtml(text)}</div>
<div class="confirm-buttons">
<button class="confirm-btn confirm-btn-danger" id="confirm-yes">Delete</button>
<button class="confirm-btn confirm-btn-cancel" id="confirm-no">Cancel</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('#confirm-yes')?.addEventListener('click', () => {
onConfirm();
overlay.remove();
});
overlay.querySelector('#confirm-no')?.addEventListener('click', () => {
overlay.remove();
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function updateCurrentPage(): Promise<void> {
const badgeEl = document.getElementById('platform-badge')!;
const titleEl = document.getElementById('page-title')!;
const captureBtn = document.getElementById('btn-capture') as HTMLButtonElement;
const restoreBtn = document.getElementById('btn-restore') as HTMLButtonElement;
const tab = await getCurrentTab();
if (!tab?.url) {
badgeEl.textContent = '—';
titleEl.textContent = 'No active page';
captureBtn.disabled = true;
restoreBtn.disabled = true;
return;
}
const platform = detector.detect(tab.url);
if (!platform) {
badgeEl.textContent = '—';
badgeEl.className = 'platform-badge';
titleEl.textContent = 'No AI tool detected on this page';
captureBtn.disabled = true;
restoreBtn.disabled = true;
return;
}
badgeEl.textContent = detector.getPlatformName(platform);
badgeEl.className = `platform-badge ${getPlatformColorClass(platform)}`;
titleEl.textContent = tab.title || 'Untitled';
captureBtn.disabled = false;
restoreBtn.disabled = false;
}
async function captureCurrent(): Promise<void> {
const tab = await getCurrentTab();
if (!tab?.id) return;
try {
const response = await chrome.tabs.sendMessage(tab.id, { type: 'REQUEST_CAPTURE' });
if (response?.success) {
showToast('Context captured!');
await renderSessions();
} else {
showToast('Nothing to capture');
}
} catch {
showToast('Refresh the page first');
}
}
async function restoreCurrent(): Promise<void> {
const tab = await getCurrentTab();
if (!tab?.id || !tab.url) return;
const response = await chrome.runtime.sendMessage({
type: 'GET_CONTEXT',
payload: { title: tab.title || '', url: tab.url.split('?')[0] },
});
if (response?.found && response.context) {
try {
await chrome.tabs.sendMessage(tab.id, {
type: 'INJECT_CONTEXT',
payload: { context: response.context },
});
showToast('Context restored!');
} catch {
showToast('Refresh the page first');
}
} else {
showToast('No saved context for this page');
}
}
async function clearAll(): Promise<void> {
showConfirm('Clear All Sessions', 'This will permanently delete all saved context. Are you sure?', async () => {
await chrome.runtime.sendMessage({ type: 'CLEAR_ALL' });
await renderSessions();
showToast('All sessions cleared');
});
}
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
await updateCurrentPage();
await renderSessions();
document.getElementById('btn-capture')?.addEventListener('click', captureCurrent);
document.getElementById('btn-restore')?.addEventListener('click', restoreCurrent);
document.getElementById('btn-clear')?.addEventListener('click', clearAll);
});
export { renderSessions, updateCurrentPage, formatTimeAgo, formatChars };
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { PlatformDetector } from '../src/lib/detector';
describe('PlatformDetector', () => {
const detector = new PlatformDetector();
it('detects ChatGPT URLs', () => {
expect(detector.detect('https://chatgpt.com/c/123')).toBe('chatgpt');
expect(detector.detect('https://chat.openai.com/chat')).toBe('chatgpt');
});
it('detects Claude URLs', () => {
expect(detector.detect('https://claude.ai/chat/abc')).toBe('claude');
});
it('detects Cursor URLs', () => {
expect(detector.detect('https://cursor.sh/chat')).toBe('cursor');
});
it('detects Gemini URLs', () => {
expect(detector.detect('https://gemini.google.com/app')).toBe('gemini');
expect(detector.detect('https://aistudio.google.com/app')).toBe('gemini');
});
it('detects Copilot URLs', () => {
expect(detector.detect('https://copilot.microsoft.com/')).toBe('copilot');
expect(detector.detect('https://github.com/copilot')).toBe('copilot');
});
it('returns null for unrelated URLs', () => {
expect(detector.detect('https://google.com')).toBeNull();
expect(detector.detect('https://example.com')).toBeNull();
});
it('maps platform keys to display names', () => {
expect(detector.getPlatformName('chatgpt')).toBe('ChatGPT');
expect(detector.getPlatformName('claude')).toBe('Claude');
expect(detector.getPlatformName('unknown')).toBe('unknown');
});
it('lists all supported platforms', () => {
const platforms = detector.getSupportedPlatforms();
expect(platforms).toContain('chatgpt');
expect(platforms).toContain('claude');
expect(platforms).toContain('cursor');
expect(platforms).toContain('gemini');
expect(platforms).toContain('copilot');
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ContextExtractor } from '../src/lib/extractor';
describe('ContextExtractor', () => {
let extractor: ContextExtractor;
beforeEach(() => {
extractor = new ContextExtractor();
});
it('extracts nothing for unknown platforms', () => {
expect(extractor.extract('unknown')).toBe('');
});
it('extracts ChatGPT conversation turns', () => {
document.body.innerHTML = `
<div data-testid="conversation-turn-1">
<div>Hello AI</div>
</div>
<div data-testid="conversation-turn-2">
<div>Hi there!</div>
</div>
`;
const result = extractor.extract('chatgpt');
expect(result).toContain('[user]: Hello AI');
expect(result).toContain('[assistant]: Hi there!');
});
it('falls back to article elements for ChatGPT', () => {
document.body.innerHTML = `
<article>First message</article>
<article>Second message</article>
`;
const result = extractor.extract('chatgpt');
expect(result).toContain('[user]: First message');
expect(result).toContain('[assistant]: Second message');
});
it('extracts Claude messages', () => {
document.body.innerHTML = `
<div class="message">How do I refactor this?</div>
<div class="message">Here is a suggestion...</div>
`;
const result = extractor.extract('claude');
expect(result).toContain('[human]: How do I refactor this?');
expect(result).toContain('[assistant]: Here is a suggestion...');
});
it('extracts Cursor chat', () => {
document.body.innerHTML = `
<div class="chat-message">Fix this bug</div>
<div class="chat-message">The issue is...</div>
`;
const result = extractor.extract('cursor');
expect(result).toContain('[user]: Fix this bug');
expect(result).toContain('[assistant]: The issue is...');
});
it('removes buttons and timestamps from content', () => {
document.body.innerHTML = `
<article>
Some code
<button>Copy</button>
<span class="timestamp">2:30 PM</span>
</article>
`;
const result = extractor.extract('chatgpt');
expect(result).toContain('Some code');
expect(result).not.toContain('Copy');
expect(result).not.toContain('2:30 PM');
});
it('returns empty string when no elements found', () => {
document.body.innerHTML = '<div>Empty page</div>';
expect(extractor.extract('chatgpt')).toBe('');
});
it('skips very short text', () => {
document.body.innerHTML = `
<article>Hi</article>
<article></article>
`;
const result = extractor.extract('chatgpt');
expect(result).toBe('');
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["chrome", "vitest/globals"]
},
"include": ["src/**/*.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json' assert { type: 'json' }
export default defineConfig({
plugins: [crx({ manifest })],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
})