diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3240386 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +# CI quality gates — runs lint, typecheck, test, and build on every PR to main. +# The workflow name "CI" is used as a required status check in branch protection. + +name: CI + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + ci: + name: Quality Gates + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + id: checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + id: setup-pnpm + uses: pnpm/action-setup@v5 + + - name: Set up Node.js + id: setup-node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + id: install-deps + run: pnpm install --frozen-lockfile + + - name: Run lint + id: lint + run: pnpm lint + + - name: Run typecheck + id: typecheck + run: pnpm typecheck + + - name: Run tests + id: test + run: pnpm test + + - name: Run build + id: build + run: pnpm build diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..6cb4b0b --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,39 @@ +# Release Please — analyzes conventional commits on main, creates/updates a release PR +# with version bumps and changelog, then enables auto-merge so CI gates are the only barrier. + +name: Release Please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + id: checkout + uses: actions/checkout@v4 + + - name: Run Release Please + id: release-please + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Enable auto-merge so the release PR merges automatically once CI passes. + # GR-5: no error suppression — if auto-merge can't be enabled, this step fails the workflow. + - name: Enable auto-merge for release PR + id: auto-merge + if: ${{ steps.release-please.outputs.pr }} + run: gh pr merge --auto --squash "$PR_NUMBER" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ fromJSON(steps.release-please.outputs.pr).number }} diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..6784dee --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,126 @@ +# Release Publish — triggers after Release Please completes, rebuilds from clean state, +# packages the extension zip, and uploads the artifact to the GitHub Release created by Release Please. +# +# Guardrails enforced: +# GR-1: Only proceeds if triggering workflow succeeded +# GR-2: Checks out ref:main explicitly (not default workflow_run context) +# GR-3: Early-exit if no release was created (idempotent Release Please re-run) +# GR-4: Explicit minimal permissions + +name: Release Publish + +on: + workflow_run: + workflows: ["Release Please"] + types: [completed] + +permissions: + contents: write + +jobs: + publish: + name: Build and Publish Release + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release-please.outputs['apps/extension--release_created'] }} + tag_name: ${{ steps.release-please.outputs['apps/extension--tag_name'] }} + version: ${{ steps.release-please.outputs['apps/extension--version'] }} + # GR-1: only run if the triggering workflow succeeded + if: ${{ github.event.workflow_run.conclusion == 'success' }} + + steps: + # GR-2: checkout ref:main explicitly — workflow_run default context is the PR head, not the merge commit + - name: Checkout repository + id: checkout + uses: actions/checkout@v4 + with: + ref: main + + # Re-run Release Please (idempotent) to access component-keyed outputs. + # workflow_run cannot access the triggering workflow's step outputs. + - name: Run Release Please + id: release-please + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # GR-3: skip all remaining steps if no release was created + - name: Set up pnpm + id: setup-pnpm + if: ${{ steps.release-please.outputs['apps/extension--release_created'] }} + uses: pnpm/action-setup@v5 + + - name: Set up Node.js + id: setup-node + if: ${{ steps.release-please.outputs['apps/extension--release_created'] }} + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + id: install-deps + if: ${{ steps.release-please.outputs['apps/extension--release_created'] }} + run: pnpm install --frozen-lockfile + + - name: Run build + id: build + if: ${{ steps.release-please.outputs['apps/extension--release_created'] }} + run: pnpm --filter @vxrtx/extension build + + - name: Validate version alignment + id: validate-versions + if: ${{ steps.release-please.outputs['apps/extension--release_created'] }} + run: | + node -e " + const pkg = require('./apps/extension/package.json'); + const manifest = require('./apps/extension/public/manifest.json'); + if (pkg.version !== manifest.version) { + console.log('::error::Version mismatch: package.json=' + pkg.version + ' manifest.json=' + manifest.version); + process.exit(1); + } + console.log('Versions aligned: ' + pkg.version); + " + + - name: Package extension zip + id: package-zip + if: ${{ steps.release-please.outputs['apps/extension--release_created'] }} + run: pnpm --filter @vxrtx/extension package:zip + + # Release Please already creates the GitHub Release with changelog notes. + # We just upload the zip artifact to the existing release. + - name: Upload release artifact + id: upload-artifact + if: ${{ steps.release-please.outputs['apps/extension--release_created'] }} + run: gh release upload "$TAG_NAME" apps/extension/vxrtx-extension-*.zip --clobber + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ steps.release-please.outputs['apps/extension--tag_name'] }} + + # Phase 2: Chrome Web Store draft upload. + # Runs as a separate job so CWS failures don't affect the GitHub Release. + # Skipped entirely until CWS_EXTENSION_ID is configured as a repository variable. + cws-upload: + name: Upload to Chrome Web Store + runs-on: ubuntu-latest + needs: publish + if: ${{ needs.publish.outputs.release_created == 'true' && vars.CWS_EXTENSION_ID != '' }} + + steps: + - name: Download release artifact + id: download-artifact + run: gh release download "$TAG_NAME" --pattern "vxrtx-extension-*.zip" --dir . + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ needs.publish.outputs.tag_name }} + + - name: Upload draft to Chrome Web Store + id: cws-upload + run: npx chrome-webstore-upload-cli@3.5.0 upload --source vxrtx-extension-*.zip --auto-publish false + env: + EXTENSION_ID: ${{ vars.CWS_EXTENSION_ID }} + CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }} + REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }} diff --git a/.gitignore b/.gitignore index 81341c0..31bad0b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ .turbo/ *.local .DS_Store +docs/ # Environment variables .env diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..cc6602d --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + "apps/extension": "0.1.0" +} diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore new file mode 100644 index 0000000..c4c4ffc --- /dev/null +++ b/apps/extension/.gitignore @@ -0,0 +1 @@ +*.zip diff --git a/apps/extension/package.json b/apps/extension/package.json index 9766f89..92427d8 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -10,7 +10,9 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "lint": "eslint src/" + "lint": "biome check src/", + "typecheck": "tsc --noEmit", + "package:zip": "[ -d dist ] && [ \"$(ls -A dist)\" ] && rm -f vxrtx-extension-*.zip && cd dist && zip -r ../vxrtx-extension-$npm_package_version.zip . || { echo '::error::dist/ is missing or empty — run build first'; exit 1; }" }, "dependencies": { "react": "^19.2.4", @@ -24,7 +26,6 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.27", - "eslint": "^10.1.0", "postcss": "^8.5.8", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", diff --git a/apps/extension/src/ai/parser.ts b/apps/extension/src/ai/parser.ts index 9e6bea5..9557af3 100644 --- a/apps/extension/src/ai/parser.ts +++ b/apps/extension/src/ai/parser.ts @@ -1,17 +1,27 @@ import { z } from "zod"; -import type { TabOrganizationAIResult } from "./types"; import type { TabGroupColor } from "@/shared/types"; +import type { TabOrganizationAIResult } from "./types"; const TAB_GROUP_COLORS: TabGroupColor[] = [ - "grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange", + "grey", + "blue", + "red", + "yellow", + "green", + "pink", + "purple", + "cyan", + "orange", ]; const TabGroupSuggestionSchema = z.object({ - name: z.string(), - color: z.string().transform((c) => - TAB_GROUP_COLORS.includes(c as TabGroupColor) ? c as TabGroupColor : "grey" - ), - tabIds: z.array(z.number()), + name: z.string().trim().min(1, "Group name must not be empty"), + color: z + .string() + .transform((c) => + TAB_GROUP_COLORS.includes(c as TabGroupColor) ? (c as TabGroupColor) : "grey", + ), + tabIds: z.array(z.number()).min(1, "Group must contain at least one tab"), }); const TabOrganizationSchema = z.object({ @@ -22,8 +32,16 @@ const TabOrganizationSchema = z.object({ }); const BookmarkFolderSchema = z.object({ - name: z.string(), - bookmarkIds: z.array(z.string()), + name: z + .string() + .trim() + .min(1, "Folder name must not be empty") + .refine( + (n) => !n.startsWith("/") && !n.endsWith("/") && !n.includes("//"), + "Folder name must not have leading/trailing or double slashes", + ) + .refine((n) => n.split("/").length <= 3, "Folder nesting must not exceed 3 levels"), + bookmarkIds: z.array(z.string()).min(1, "Folder must contain at least one bookmark"), }); const BookmarkOrganizationSchema = z.object({ @@ -43,6 +61,55 @@ const BookmarkLocationSchema = z.object({ suggestions: z.array(LocationSuggestionSchema), }); +class JsonExtractionError extends Error { + constructor(message: string) { + super(message); + this.name = "JsonExtractionError"; + } +} + +function isRetryableParseError(err: unknown): boolean { + return ( + err instanceof SyntaxError || err instanceof z.ZodError || err instanceof JsonExtractionError + ); +} + +/** + * Retry wrapper: attempts parse, on failure retries the LLM call with error context. + * Max 1 retry (2 total attempts). Only retries on parse/validation failures — API and runtime errors propagate immediately. + */ +export async function withRetry( + completeFn: (errorContext?: string) => Promise, + parseFn: (raw: string) => T, + onStatus?: (message: string) => void, +): Promise { + // First attempt: let API errors propagate, only catch retryable parse/validation failures + onStatus?.("Waiting for AI response..."); + const raw = await completeFn(); + onStatus?.("Processing AI response..."); + try { + return parseFn(raw); + } catch (parseErr) { + if (!isRetryableParseError(parseErr)) { + throw parseErr; + } + // Parse failed — retry with error context + onStatus?.("Response invalid, retrying..."); + const errMsg = (parseErr instanceof Error ? parseErr.message : String(parseErr)).slice(0, 500); + const errorContext = `Your previous response failed validation: ${errMsg}. Return ONLY valid JSON matching the schema exactly.`; + const retryRaw = await completeFn(errorContext); + onStatus?.("Processing retry response..."); + try { + return parseFn(retryRaw); + } catch (secondErr) { + throw new Error( + `AI response could not be parsed after 2 attempts. Last error: ${secondErr instanceof Error ? secondErr.message : String(secondErr)}`, + { cause: secondErr }, + ); + } + } +} + export function parseTabOrganization(raw: string): TabOrganizationAIResult { const json = extractJson(raw); const parsed = TabOrganizationSchema.parse(json); @@ -77,6 +144,6 @@ function extractJson(text: string): unknown { return JSON.parse(text.slice(firstBrace, lastBrace + 1)); } - throw new Error("No valid JSON found in AI response"); + throw new JsonExtractionError("No valid JSON found in AI response"); } } diff --git a/apps/extension/src/ai/prompts/bookmark-grouping.ts b/apps/extension/src/ai/prompts/bookmark-grouping.ts index 6c2e218..b71ecd8 100644 --- a/apps/extension/src/ai/prompts/bookmark-grouping.ts +++ b/apps/extension/src/ai/prompts/bookmark-grouping.ts @@ -6,67 +6,165 @@ interface BookmarkInput { url?: string; } +// ─── Module: Rules ────────────────────────────────────────────────── + +function rules(): string { + return `RULES: +- Use short, descriptive folder names (1-3 words per segment) +- Use "/" to create folder hierarchy (e.g., "Dev/Frontend", "Personal/Finance") +- Top-level names are fine when nesting isn't needed (e.g., "Reference") +- Maximum nesting depth: 3 levels (e.g., "Work/Projects/Active") +- Each bookmark should belong to exactly one folder +- Identify duplicate bookmarks (same URL) +- Provide brief reasoning`; +} + +// ─── Module: Granularity ──────────────────────────────────────────── + function granularityInstruction(g: GroupingGranularity): string { - switch (g) { - case 1: - return "Create very few, broad folders (2-4 max). Merge related topics aggressively. Prefer general categories like 'Work', 'Personal', 'Reference'."; - case 2: - return "Create fewer folders with broader categories. Combine loosely related bookmarks. Aim for 3-6 folders."; - case 3: - return "Create a balanced number of folders. Group by topic or purpose. Aim for a natural number of categories."; - case 4: - return "Create more specific folders. Split topics into distinct sub-categories. More folders is better than fewer."; - case 5: - return "Create many fine-grained folders. Each distinct topic, tool, or domain should get its own folder. Prefer specificity."; - } + const instructions: Record = { + 1: "Create very few, broad folders (2-4 max). Do NOT nest — use only flat top-level names like 'Work', 'Personal', 'Reference'.", + 2: "Create fewer folders with broader categories. Minimal nesting — prefer flat top-level folders, use sub-folders only when clearly needed. Aim for 3-6 folders.", + 3: "Create a balanced number of folders. Use 1-level nesting where natural groupings exist (e.g., 'Dev/Frontend', 'Dev/Backend'). Aim for a natural number of categories.", + 4: "Create more specific folders. Use nesting to separate sub-categories (e.g., 'Work/Tools', 'Work/Docs'). More specific sub-folders are better than one large folder.", + 5: "Create many fine-grained folders with rich hierarchy. Use 2-3 levels of nesting (e.g., 'Dev/Languages/TypeScript'). Each distinct topic, tool, or domain should get its own folder path.", + }; + return `GROUPING DETAIL LEVEL: ${g}/5\n${instructions[g]}`; } -export function buildBookmarkOrganizePrompt( - bookmarks: BookmarkInput[], - options: { includeUrls: boolean; granularity?: GroupingGranularity }, -): string { - const bookmarkList = bookmarks - .map((b) => { - const parts = [`id:"${b.id}"`, `title:"${b.title}"`]; - if (options.includeUrls && b.url) parts.push(`url:"${b.url}"`); - return ` { ${parts.join(", ")} }`; - }) - .join("\n"); +// ─── Module: Few-Shot Examples ────────────────────────────────────── - const granularity = options.granularity ?? 3; +function fewShotExamples(): string { + return `EXAMPLES: - return `You are a bookmark organizer. Analyze these bookmarks and suggest a folder structure. +Example 1 — Dev tools + learning resources (with nesting): +Input: + { id:"a1", title:"TypeScript Handbook" } + { id:"a2", title:"GitHub - vxrtx" } + { id:"a3", title:"MDN: Array.prototype.map()" } + { id:"a4", title:"VS Code Keyboard Shortcuts" } + { id:"a5", title:"Tailwind CSS Documentation" } + { id:"a6", title:"GitHub - react" } +Output: +{ + "folders": [ + { "name": "Dev/Documentation", "bookmarkIds": ["a1", "a3", "a5"] }, + { "name": "Dev/Repos", "bookmarkIds": ["a2", "a6"] }, + { "name": "Dev/Tools", "bookmarkIds": ["a4"] } + ], + "duplicates": [], + "reasoning": "Nested under Dev parent. Docs, repos, and tools separated as sub-folders." +} -BOOKMARKS: -${bookmarkList} +Example 2 — Personal + finance + travel (with nesting): +Input: + { id:"b1", title:"Mint - Budget Dashboard" } + { id:"b2", title:"Airbnb: Tokyo Apartments" } + { id:"b3", title:"Google Flights" } + { id:"b4", title:"Vanguard - 401k" } + { id:"b5", title:"AllTrails: Best Hikes Near Me" } + { id:"b6", title:"Mint - Budget Dashboard" } +Output: +{ + "folders": [ + { "name": "Personal/Finance", "bookmarkIds": ["b1", "b4", "b6"] }, + { "name": "Personal/Travel", "bookmarkIds": ["b2", "b3"] }, + { "name": "Personal/Outdoors", "bookmarkIds": ["b5"] } + ], + "duplicates": [["b1", "b6"]], + "reasoning": "All personal bookmarks nested under Personal parent. Finance, travel, and outdoors as sub-folders. Duplicate Mint detected." +} -GROUPING DETAIL LEVEL: ${granularity}/5 -${granularityInstruction(granularity)} +Example 3 — Mixed work + media (flat + nested): +Input: + { id:"c1", title:"Notion - Team Wiki" } + { id:"c2", title:"Spotify Web Player" } + { id:"c3", title:"Slack - Engineering" } + { id:"c4", title:"Notion - Team Wiki" } + { id:"c5", title:"YouTube - Conference Talk: System Design" } + { id:"c6", title:"Linear - Roadmap" } +Output: +{ + "folders": [ + { "name": "Work/Collaboration", "bookmarkIds": ["c1", "c3", "c4", "c6"] }, + { "name": "Media", "bookmarkIds": ["c2", "c5"] } + ], + "duplicates": [["c1", "c4"]], + "reasoning": "Work tools nested under Work/Collaboration. Media stays flat — not enough items to justify sub-folders. Duplicate Notion detected." +} -RULES: -- Use short, descriptive folder names (1-3 words) -- Each bookmark should belong to exactly one folder -- Identify duplicate bookmarks (same URL) -- Provide brief reasoning +Now organize the real bookmarks below using the same approach:`; +} -Respond with ONLY valid JSON matching this schema: +// ─── Module: Data Block ───────────────────────────────────────────── + +function dataBlock(bookmarks: BookmarkInput[], options: { includeUrls: boolean }): string { + const lines = bookmarks.map((b) => { + const parts = [`id:"${b.id}"`, `title:"${b.title}"`]; + if (options.includeUrls && b.url) parts.push(`url:"${b.url}"`); + return ` { ${parts.join(", ")} }`; + }); + return `BOOKMARKS:\n${lines.join("\n")}`; +} + +// ─── Module: Schema Block ─────────────────────────────────────────── + +function schemaBlock(): string { + return `Respond with ONLY valid JSON matching this schema: { "folders": [ - { "name": "string", "bookmarkIds": ["string"] } + { "name": "string (use '/' for hierarchy, e.g. 'Dev/Frontend')", "bookmarkIds": ["string"] } ], "duplicates": [["string", "string"]], "reasoning": "string" }`; } +// ─── Builders ─────────────────────────────────────────────────────── + +export interface PromptParts { + cached: string; + dynamic: string; +} + +export interface BookmarkPromptOptions { + includeUrls: boolean; + granularity?: GroupingGranularity; + guidance?: string; +} + +export function buildBookmarkOrganizePrompt( + bookmarks: BookmarkInput[], + options: BookmarkPromptOptions, +): string { + const parts = buildBookmarkOrganizePromptParts(bookmarks, options); + return `${parts.cached}\n\n${parts.dynamic}`; +} + +export function buildBookmarkOrganizePromptParts( + bookmarks: BookmarkInput[], + options: BookmarkPromptOptions, +): PromptParts { + const dynamicSections = [granularityInstruction(options.granularity ?? 3)]; + if (options.guidance?.trim()) { + dynamicSections.push( + `USER GUIDANCE:\n${options.guidance.trim()}\nFollow this guidance when organizing the bookmarks below.`, + ); + } + dynamicSections.push(dataBlock(bookmarks, options)); + + return { + cached: [rules(), fewShotExamples(), schemaBlock()].join("\n\n"), + dynamic: dynamicSections.join("\n\n"), + }; +} + export function buildBookmarkLocationPrompt( bookmark: BookmarkInput, folders: { id: string; path: string }[], options: { includeUrls: boolean }, ): string { - const folderList = folders - .map((f) => ` { id:"${f.id}", path:"${f.path}" }`) - .join("\n"); + const folderList = folders.map((f) => ` { id:"${f.id}", path:"${f.path}" }`).join("\n"); const bookmarkDesc = options.includeUrls && bookmark.url @@ -90,9 +188,9 @@ Respond with ONLY valid JSON matching this schema: Return up to 3 suggestions, ranked by confidence (0-1).`; } -export function bookmarksToYoloInput( - bookmarks: BookmarkInfo[], -): BookmarkInput[] { +// ─── Input Mappers ────────────────────────────────────────────────── + +export function bookmarksToYoloInput(bookmarks: BookmarkInfo[]): BookmarkInput[] { return bookmarks.map((b) => ({ id: b.id, title: b.title, @@ -100,9 +198,7 @@ export function bookmarksToYoloInput( })); } -export function bookmarksToRelaxedInput( - bookmarks: BookmarkInfo[], -): BookmarkInput[] { +export function bookmarksToRelaxedInput(bookmarks: BookmarkInfo[]): BookmarkInput[] { return bookmarks.map((b) => ({ id: b.id, title: b.title, diff --git a/apps/extension/src/ai/prompts/tab-grouping.ts b/apps/extension/src/ai/prompts/tab-grouping.ts index 37709c1..a5e088f 100644 --- a/apps/extension/src/ai/prompts/tab-grouping.ts +++ b/apps/extension/src/ai/prompts/tab-grouping.ts @@ -1,4 +1,5 @@ -import type { TabInfo, GroupingGranularity } from "@/shared/types"; +import { correctionsBlock } from "@/core/corrections"; +import type { CorrectionSignal, GroupingGranularity, TabInfo } from "@/shared/types"; interface TabInput { id: number; @@ -7,50 +8,10 @@ interface TabInput { lastAccessed?: number; } -function granularityInstruction(g: GroupingGranularity): string { - switch (g) { - case 1: - return "Create very few, broad groups (2-4 max). Merge related topics aggressively. Prefer general categories like 'Work', 'Personal', 'Reference'."; - case 2: - return "Create fewer groups with broader categories. It's OK to combine loosely related tabs. Aim for 3-6 groups."; - case 3: - return "Create a balanced number of groups. Group by topic or project. Aim for a natural number of categories."; - case 4: - return "Create more specific groups. Split topics into distinct sub-categories where it makes sense. More groups is better than fewer."; - case 5: - return "Create many fine-grained groups. Each distinct topic, project, or domain should get its own group. Prefer specificity over brevity."; - } -} +// ─── Module: Rules ────────────────────────────────────────────────── -export function buildTabGroupingPrompt( - tabs: TabInput[], - options: { includeUrls: boolean; granularity?: GroupingGranularity }, -): string { - const tabList = tabs - .map((t) => { - const parts = [`id:${t.id}`, `title:"${t.title}"`]; - if (options.includeUrls && t.url) parts.push(`url:"${t.url}"`); - if (t.lastAccessed) { - const daysAgo = Math.floor( - (Date.now() - t.lastAccessed) / (1000 * 60 * 60 * 24), - ); - parts.push(`last_accessed:${daysAgo}d_ago`); - } - return ` { ${parts.join(", ")} }`; - }) - .join("\n"); - - const granularity = options.granularity ?? 3; - - return `You are a browser tab organizer. Analyze these open tabs and suggest logical groupings. - -TABS: -${tabList} - -GROUPING DETAIL LEVEL: ${granularity}/5 -${granularityInstruction(granularity)} - -RULES: +function rules(): string { + return `RULES: - Group tabs by topic, project, or activity (not just domain) - Use short, descriptive group names (1-3 words) - Assign a color from: grey, blue, red, yellow, green, pink, purple, cyan, orange @@ -59,9 +20,111 @@ RULES: - Tabs that don't fit any group can be omitted - Flag tabs as "stale" if last_accessed is more than 7 days ago and they seem unimportant - Flag exact duplicate URLs (same URL appearing multiple times) — list sets of duplicate tab IDs -- Provide brief reasoning for your grouping choices +- Provide brief reasoning for your grouping choices`; +} + +// ─── Module: Granularity ──────────────────────────────────────────── -Respond with ONLY valid JSON matching this schema: +function granularityInstruction(g: GroupingGranularity): string { + const instructions: Record = { + 1: "Create very few, broad groups (2-4 max). Merge related topics aggressively. Prefer general categories like 'Work', 'Personal', 'Reference'.", + 2: "Create fewer groups with broader categories. It's OK to combine loosely related tabs. Aim for 3-6 groups.", + 3: "Create a balanced number of groups. Group by topic or project. Aim for a natural number of categories.", + 4: "Create more specific groups. Split topics into distinct sub-categories where it makes sense. More groups is better than fewer.", + 5: "Create many fine-grained groups. Each distinct topic, project, or domain should get its own group. Prefer specificity over brevity.", + }; + return `GROUPING DETAIL LEVEL: ${g}/5\n${instructions[g]}`; +} + +// ─── Module: Few-Shot Examples ────────────────────────────────────── + +function fewShotExamples(): string { + return `EXAMPLES: + +Example 1 — Dev + documentation mix: +Input: + { id:1, title:"React useState Hook – React Docs" } + { id:2, title:"GitHub - myapp: Pull request #42" } + { id:3, title:"Stack Overflow: useEffect cleanup" } + { id:4, title:"Jira Board - Sprint 12" } + { id:5, title:"AWS S3 Console" } + { id:6, title:"GitHub - myapp: Actions workflow runs" } +Output: +{ + "groups": [ + { "name": "React Research", "color": "blue", "tabIds": [1, 3] }, + { "name": "myapp Dev", "color": "purple", "tabIds": [2, 6] }, + { "name": "Project Mgmt", "color": "yellow", "tabIds": [4] }, + { "name": "Infrastructure", "color": "orange", "tabIds": [5] } + ], + "stale": [], + "duplicates": [], + "reasoning": "Grouped React docs together, myapp GitHub tabs together, separated project management and infra." +} + +Example 2 — Shopping + social + media: +Input: + { id:10, title:"Amazon.com: Wireless Headphones" } + { id:11, title:"YouTube - Lo-fi Hip Hop Radio" } + { id:12, title:"Reddit - r/headphones buying guide" } + { id:13, title:"Amazon.com: USB-C Cable 3-Pack" } + { id:14, title:"Twitter / X - Home" } + { id:15, title:"Netflix - Continue Watching" } +Output: +{ + "groups": [ + { "name": "Shopping", "color": "green", "tabIds": [10, 13] }, + { "name": "Audio Research", "color": "blue", "tabIds": [12] }, + { "name": "Media", "color": "red", "tabIds": [11, 15] }, + { "name": "Social", "color": "cyan", "tabIds": [14] } + ], + "stale": [], + "duplicates": [], + "reasoning": "Amazon tabs grouped as Shopping, Reddit headphone guide kept separate as research, streaming and music grouped as Media." +} + +Example 3 — Mixed with stale and duplicates: +Input: + { id:20, title:"Google Docs - Q3 Planning", last_accessed:14d_ago } + { id:21, title:"Figma - Dashboard Redesign" } + { id:22, title:"Figma - Dashboard Redesign" } + { id:23, title:"Linear - Issue ENG-301" } + { id:24, title:"ChatGPT", last_accessed:21d_ago } + { id:25, title:"MDN Web Docs: CSS Grid" } +Output: +{ + "groups": [ + { "name": "Design", "color": "pink", "tabIds": [21, 22] }, + { "name": "Engineering", "color": "purple", "tabIds": [23] }, + { "name": "Reference", "color": "grey", "tabIds": [25] } + ], + "stale": [20, 24], + "duplicates": [[21, 22]], + "reasoning": "Flagged Q3 Planning (14d) and ChatGPT (21d) as stale. Detected duplicate Figma tabs. Grouped remaining by function." +} + +Now analyze the real tabs below using the same approach:`; +} + +// ─── Module: Data Block ───────────────────────────────────────────── + +function dataBlock(tabs: TabInput[], options: { includeUrls: boolean }): string { + const lines = tabs.map((t) => { + const parts = [`id:${t.id}`, `title:"${t.title}"`]; + if (options.includeUrls && t.url) parts.push(`url:"${t.url}"`); + if (t.lastAccessed) { + const daysAgo = Math.floor((Date.now() - t.lastAccessed) / (1000 * 60 * 60 * 24)); + parts.push(`last_accessed:${daysAgo}d_ago`); + } + return ` { ${parts.join(", ")} }`; + }); + return `TABS:\n${lines.join("\n")}`; +} + +// ─── Module: Schema Block ─────────────────────────────────────────── + +function schemaBlock(): string { + return `Respond with ONLY valid JSON matching this schema: { "groups": [ { "name": "string", "color": "string", "tabIds": [number] } @@ -72,6 +135,49 @@ Respond with ONLY valid JSON matching this schema: }`; } +// ─── Builder ──────────────────────────────────────────────────────── + +export interface PromptParts { + /** Static portion (rules + few-shot + schema) — identical across calls, cacheable. */ + cached: string; + /** Dynamic portion (granularity + data) — changes per request. */ + dynamic: string; +} + +export interface TabPromptOptions { + includeUrls: boolean; + granularity?: GroupingGranularity; + corrections?: CorrectionSignal[]; + guidance?: string; +} + +export function buildTabGroupingPrompt(tabs: TabInput[], options: TabPromptOptions): string { + const parts = buildTabGroupingPromptParts(tabs, options); + return `${parts.cached}\n\n${parts.dynamic}`; +} + +export function buildTabGroupingPromptParts( + tabs: TabInput[], + options: TabPromptOptions, +): PromptParts { + const dynamicSections = [granularityInstruction(options.granularity ?? 3)]; + if (options.guidance?.trim()) { + dynamicSections.push( + `USER GUIDANCE:\n${options.guidance.trim()}\nFollow this guidance when grouping the tabs below.`, + ); + } + const corrBlock = correctionsBlock(options.corrections ?? []); + if (corrBlock) dynamicSections.push(corrBlock); + dynamicSections.push(dataBlock(tabs, options)); + + return { + cached: [rules(), fewShotExamples(), schemaBlock()].join("\n\n"), + dynamic: dynamicSections.join("\n\n"), + }; +} + +// ─── Input Mappers ────────────────────────────────────────────────── + export function tabsToYoloInput(tabs: TabInfo[]): TabInput[] { return tabs.map((t) => ({ id: t.id, diff --git a/apps/extension/src/ai/provider.ts b/apps/extension/src/ai/provider.ts index ef448a8..f02cc10 100644 --- a/apps/extension/src/ai/provider.ts +++ b/apps/extension/src/ai/provider.ts @@ -1,10 +1,11 @@ -import type { AIProvider } from "./types"; -import type { Settings } from "@/shared/types"; import { getSettings } from "@/core/storage"; -import { LocalProvider } from "./providers/local"; +import type { Settings } from "@/shared/types"; +import { ChromeAIProvider } from "./providers/chrome-ai"; +import { OllamaProvider } from "./providers/ollama"; +import { OpenRouterProvider } from "./providers/openrouter"; import { RelaxedProvider } from "./providers/relaxed"; import { YoloProvider } from "./providers/yolo"; -import { OpenRouterProvider } from "./providers/openrouter"; +import type { AIProvider } from "./types"; export async function getAIProvider(): Promise { const settings = await getSettings(); @@ -14,19 +15,14 @@ export async function getAIProvider(): Promise { function createProvider(settings: Settings): AIProvider { const includeUrls = settings.aiTier === "yolo"; - // OpenRouter handles both tiers via the includeUrls flag - if (settings.aiModelProvider === "openrouter") { - if (settings.aiTier === "secure") return new LocalProvider(); - return new OpenRouterProvider( - settings.openrouterApiKey, - settings.openrouterModel, - includeUrls, - ); + // OpenRouter handles both relaxed/yolo tiers via the includeUrls flag + if (settings.aiModelProvider === "openrouter" && settings.aiTier !== "secure") { + return new OpenRouterProvider(settings.openrouterApiKey, settings.openrouterModel, includeUrls); } switch (settings.aiTier) { case "secure": - return new LocalProvider(); + return createLocalProvider(settings); case "relaxed": return new RelaxedProvider( settings.aiModelProvider, @@ -40,6 +36,19 @@ function createProvider(settings: Settings): AIProvider { settings.openaiApiKey, ); default: - return new LocalProvider(); + return createLocalProvider(settings); + } +} + +function createLocalProvider(settings: Settings): AIProvider { + switch (settings.localAIProvider) { + case "ollama": + return new OllamaProvider(settings.ollamaUrl, settings.ollamaModel); + case "chrome-ai": + return new ChromeAIProvider(); + default: + // Rule-based is handled in the service worker before reaching the provider. + // If we get here, throw a clear error rather than silently failing. + throw new Error("Rule-based mode does not use an AI provider."); } } diff --git a/apps/extension/src/ai/providers/chrome-ai.ts b/apps/extension/src/ai/providers/chrome-ai.ts new file mode 100644 index 0000000..9515b37 --- /dev/null +++ b/apps/extension/src/ai/providers/chrome-ai.ts @@ -0,0 +1,158 @@ +import type { + BookmarkInfo, + BookmarkOrganizationResult, + LocationSuggestion, + TabInfo, +} from "@/shared/types"; +import { + parseBookmarkLocation, + parseBookmarkOrganization, + parseTabOrganization, + withRetry, +} from "../parser"; +import { + bookmarksToRelaxedInput, + buildBookmarkLocationPrompt, + buildBookmarkOrganizePrompt, +} from "../prompts/bookmark-grouping"; +import { buildTabGroupingPrompt, tabsToRelaxedInput } from "../prompts/tab-grouping"; +import { + type AIProvider, + type OrganizeBookmarksOptions, + type OrganizeTabsOptions, + type StatusCallback, + SYSTEM_MESSAGE, + type TabOrganizationAIResult, +} from "../types"; + +// Chrome's LanguageModel API types (not in TS lib yet) +declare const LanguageModel: + | { + availability(): Promise<"available" | "downloadable" | "downloading" | "unavailable">; + create(options?: { + systemPrompt?: string; + temperature?: number; + topK?: number; + }): Promise; + } + | undefined; + +interface ChromeAISession { + prompt(text: string): Promise; + destroy(): void; +} + +/** + * Check if Chrome's built-in AI (Prompt API) is available. + */ +export async function isChromeAIAvailable(): Promise { + try { + if (typeof LanguageModel === "undefined") return false; + const status = await LanguageModel.availability(); + return status === "available" || status === "downloadable"; + } catch { + return false; + } +} + +/** + * Chrome Built-in AI provider — uses Gemini Nano on-device via Chrome's Prompt API. + * Available in Chrome 138+ extensions. No network, fully private. + * Model quality is lower than cloud APIs but works offline. + */ +export class ChromeAIProvider implements AIProvider { + async organizeTabs( + tabs: TabInfo[], + options?: OrganizeTabsOptions, + ): Promise { + const { granularity, corrections, guidance, onStatus } = options ?? {}; + const input = tabsToRelaxedInput(tabs); + const prompt = buildTabGroupingPrompt(input, { + includeUrls: false, + granularity, + corrections, + guidance, + }); + return withRetry( + (errorContext) => this.complete(prompt, errorContext), + parseTabOrganization, + onStatus, + ); + } + + async organizeBookmarks( + bookmarks: BookmarkInfo[], + options?: OrganizeBookmarksOptions, + ): Promise { + const { granularity, guidance, onStatus } = options ?? {}; + const input = bookmarksToRelaxedInput(bookmarks); + const prompt = buildBookmarkOrganizePrompt(input, { + includeUrls: false, + granularity, + guidance, + }); + const parsed = await withRetry( + (errorContext) => this.complete(prompt, errorContext), + parseBookmarkOrganization, + onStatus, + ); + return { + folders: parsed.folders.map((f) => ({ ...f, parentId: undefined })), + moves: [], + duplicates: parsed.duplicates, + newFolders: [], + reasoning: parsed.reasoning, + }; + } + + async suggestBookmarkLocation( + bookmark: BookmarkInfo, + folders: { id: string; path: string }[], + onStatus?: StatusCallback, + ): Promise { + const input = { id: bookmark.id, title: bookmark.title }; + const prompt = buildBookmarkLocationPrompt(input, folders, { includeUrls: false }); + const parsed = await withRetry( + (errorContext) => this.complete(prompt, errorContext), + parseBookmarkLocation, + onStatus, + ); + return parsed.suggestions; + } + + private async complete(prompt: string, errorContext?: string): Promise { + if (typeof LanguageModel === "undefined") { + throw new Error( + "Chrome Built-in AI is not available. Enable it in chrome://flags or use Ollama instead.", + ); + } + + const status = await LanguageModel.availability(); + if (status === "unavailable") { + throw new Error("Chrome AI model is unavailable on this device. Try Ollama instead."); + } + if (status === "downloadable" || status === "downloading") { + console.log(`[vxrtx] Chrome AI model status: ${status}, triggering download...`); + } + + const userContent = errorContext ? `${prompt}\n\n${errorContext}` : prompt; + + console.log(`[vxrtx] Chrome AI request: prompt=${userContent.length} chars`); + const startTime = Date.now(); + + let session: ChromeAISession | undefined; + try { + session = await LanguageModel.create({ + systemPrompt: SYSTEM_MESSAGE, + temperature: 0.0, + }); + + const result = await session.prompt(userContent); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`[vxrtx] Chrome AI response: ${elapsed}s, ${result.length} chars`); + return result; + } finally { + session?.destroy(); + } + } +} diff --git a/apps/extension/src/ai/providers/local.ts b/apps/extension/src/ai/providers/local.ts index 2867d05..4f04935 100644 --- a/apps/extension/src/ai/providers/local.ts +++ b/apps/extension/src/ai/providers/local.ts @@ -1,10 +1,10 @@ -import type { AIProvider, TabOrganizationAIResult } from "../types"; import type { - TabInfo, BookmarkInfo, BookmarkOrganizationResult, LocationSuggestion, + TabInfo, } from "@/shared/types"; +import type { AIProvider, TabOrganizationAIResult } from "../types"; /** * Local/Secure provider stub. @@ -12,27 +12,20 @@ import type { * For now, returns an error directing users to choose a different tier. */ export class LocalProvider implements AIProvider { - async organizeTabs(_tabs: TabInfo[], _granularity?: number): Promise { + async organizeTabs(_tabs: TabInfo[]): Promise { throw new Error( "Local AI not yet available. Use rule-based grouping or switch to Relaxed/YOLO tier in Settings.", ); } - async organizeBookmarks( - _bookmarks: BookmarkInfo[], - _granularity?: number, - ): Promise { - throw new Error( - "Local AI not yet available. Switch to Relaxed/YOLO tier in Settings.", - ); + async organizeBookmarks(_bookmarks: BookmarkInfo[]): Promise { + throw new Error("Local AI not yet available. Switch to Relaxed/YOLO tier in Settings."); } async suggestBookmarkLocation( _bookmark: BookmarkInfo, _folders: { id: string; path: string }[], ): Promise { - throw new Error( - "Local AI not yet available. Switch to Relaxed/YOLO tier in Settings.", - ); + throw new Error("Local AI not yet available. Switch to Relaxed/YOLO tier in Settings."); } } diff --git a/apps/extension/src/ai/providers/ollama.ts b/apps/extension/src/ai/providers/ollama.ts new file mode 100644 index 0000000..60f31b4 --- /dev/null +++ b/apps/extension/src/ai/providers/ollama.ts @@ -0,0 +1,156 @@ +import type { + BookmarkInfo, + BookmarkOrganizationResult, + LocationSuggestion, + TabInfo, +} from "@/shared/types"; +import { + parseBookmarkLocation, + parseBookmarkOrganization, + parseTabOrganization, + withRetry, +} from "../parser"; +import { + bookmarksToRelaxedInput, + buildBookmarkLocationPrompt, + buildBookmarkOrganizePrompt, +} from "../prompts/bookmark-grouping"; +import { buildTabGroupingPrompt, tabsToRelaxedInput } from "../prompts/tab-grouping"; +import { + type AIProvider, + aiMaxTokens, + aiTimeoutMs, + fetchWithTimeout, + type OrganizeBookmarksOptions, + type OrganizeTabsOptions, + type StatusCallback, + SYSTEM_MESSAGE, + type TabOrganizationAIResult, +} from "../types"; + +/** + * Ollama provider — connects to a local Ollama instance via its OpenAI-compatible API. + * Runs entirely on the user's machine. No data leaves the device. + * Requires Ollama to be installed and running (ollama.com). + */ +export class OllamaProvider implements AIProvider { + constructor( + private baseUrl: string, + private model: string, + ) {} + + async organizeTabs( + tabs: TabInfo[], + options?: OrganizeTabsOptions, + ): Promise { + const { granularity, corrections, guidance, onStatus } = options ?? {}; + const input = tabsToRelaxedInput(tabs); // Secure tier: titles only, no URLs + const prompt = buildTabGroupingPrompt(input, { + includeUrls: false, + granularity, + corrections, + guidance, + }); + return withRetry( + (errorContext) => this.complete(prompt, tabs.length, errorContext), + parseTabOrganization, + onStatus, + ); + } + + async organizeBookmarks( + bookmarks: BookmarkInfo[], + options?: OrganizeBookmarksOptions, + ): Promise { + const { granularity, guidance, onStatus } = options ?? {}; + const input = bookmarksToRelaxedInput(bookmarks); + const prompt = buildBookmarkOrganizePrompt(input, { + includeUrls: false, + granularity, + guidance, + }); + const parsed = await withRetry( + (errorContext) => this.complete(prompt, bookmarks.length, errorContext), + parseBookmarkOrganization, + onStatus, + ); + return { + folders: parsed.folders.map((f) => ({ ...f, parentId: undefined })), + moves: [], + duplicates: parsed.duplicates, + newFolders: [], + reasoning: parsed.reasoning, + }; + } + + async suggestBookmarkLocation( + bookmark: BookmarkInfo, + folders: { id: string; path: string }[], + onStatus?: StatusCallback, + ): Promise { + const input = { id: bookmark.id, title: bookmark.title }; + const prompt = buildBookmarkLocationPrompt(input, folders, { includeUrls: false }); + const parsed = await withRetry( + (errorContext) => this.complete(prompt, folders.length, errorContext), + parseBookmarkLocation, + onStatus, + ); + return parsed.suggestions; + } + + private async complete( + prompt: string, + itemCount: number, + errorContext?: string, + ): Promise { + const userContent = errorContext ? `${prompt}\n\n${errorContext}` : prompt; + const timeout = aiTimeoutMs(itemCount); + const maxTokens = aiMaxTokens(itemCount); + const url = `${this.baseUrl.replace(/\/+$/, "")}/v1/chat/completions`; + + console.log( + `[vxrtx] Ollama request: model=${this.model}, items=${itemCount}, prompt=${userContent.length} chars, timeout=${Math.round(timeout / 1000)}s`, + ); + const startTime = Date.now(); + + const response = await fetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: this.model, + messages: [ + { role: "system", content: SYSTEM_MESSAGE }, + { role: "user", content: userContent }, + ], + temperature: 0.0, + max_tokens: maxTokens, + stream: false, + }), + }, + timeout, + ); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + if (!response.ok) { + const err = await response.text(); + console.error(`[vxrtx] Ollama API error after ${elapsed}s: ${response.status}`, err); + throw new Error(`Ollama error (${response.status}): ${err}`); + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content; + if (!content) { + console.error( + `[vxrtx] Ollama empty response after ${elapsed}s:`, + JSON.stringify(data).slice(0, 500), + ); + throw new Error("No content in Ollama response"); + } + + console.log(`[vxrtx] Ollama response: ${elapsed}s, ${content.length} chars`); + return content; + } +} diff --git a/apps/extension/src/ai/providers/openrouter.ts b/apps/extension/src/ai/providers/openrouter.ts index d14ae66..74ac461 100644 --- a/apps/extension/src/ai/providers/openrouter.ts +++ b/apps/extension/src/ai/providers/openrouter.ts @@ -1,27 +1,37 @@ -import type { AIProvider, TabOrganizationAIResult } from "../types"; import type { - TabInfo, BookmarkInfo, BookmarkOrganizationResult, LocationSuggestion, - GroupingGranularity, + TabInfo, } from "@/shared/types"; import { - buildTabGroupingPrompt, - tabsToYoloInput, - tabsToRelaxedInput, -} from "../prompts/tab-grouping"; + parseBookmarkLocation, + parseBookmarkOrganization, + parseTabOrganization, + withRetry, +} from "../parser"; import { - buildBookmarkOrganizePrompt, - buildBookmarkLocationPrompt, - bookmarksToYoloInput, bookmarksToRelaxedInput, + bookmarksToYoloInput, + buildBookmarkLocationPrompt, + buildBookmarkOrganizePrompt, } from "../prompts/bookmark-grouping"; import { - parseTabOrganization, - parseBookmarkOrganization, - parseBookmarkLocation, -} from "../parser"; + buildTabGroupingPrompt, + tabsToRelaxedInput, + tabsToYoloInput, +} from "../prompts/tab-grouping"; +import { + type AIProvider, + aiMaxTokens, + aiTimeoutMs, + fetchWithTimeout, + type OrganizeBookmarksOptions, + type OrganizeTabsOptions, + type StatusCallback, + SYSTEM_MESSAGE, + type TabOrganizationAIResult, +} from "../types"; /** * OpenRouter provider — unified gateway to Claude, GPT, Llama, Mistral, etc. @@ -35,31 +45,43 @@ export class OpenRouterProvider implements AIProvider { private includeUrls: boolean, ) {} - async organizeTabs(tabs: TabInfo[], granularity?: GroupingGranularity): Promise { - const input = this.includeUrls - ? tabsToYoloInput(tabs) - : tabsToRelaxedInput(tabs); + async organizeTabs( + tabs: TabInfo[], + options?: OrganizeTabsOptions, + ): Promise { + const { granularity, corrections, guidance, onStatus } = options ?? {}; + const input = this.includeUrls ? tabsToYoloInput(tabs) : tabsToRelaxedInput(tabs); const prompt = buildTabGroupingPrompt(input, { includeUrls: this.includeUrls, granularity, + corrections, + guidance, }); - const response = await this.complete(prompt); - return parseTabOrganization(response); + return withRetry( + (errorContext) => this.complete(prompt, tabs.length, errorContext), + parseTabOrganization, + onStatus, + ); } async organizeBookmarks( bookmarks: BookmarkInfo[], - granularity?: GroupingGranularity, + options?: OrganizeBookmarksOptions, ): Promise { + const { granularity, guidance, onStatus } = options ?? {}; const input = this.includeUrls ? bookmarksToYoloInput(bookmarks) : bookmarksToRelaxedInput(bookmarks); const prompt = buildBookmarkOrganizePrompt(input, { includeUrls: this.includeUrls, granularity, + guidance, }); - const response = await this.complete(prompt); - const parsed = parseBookmarkOrganization(response); + const parsed = await withRetry( + (errorContext) => this.complete(prompt, bookmarks.length, errorContext), + parseBookmarkOrganization, + onStatus, + ); return { folders: parsed.folders.map((f) => ({ ...f, parentId: undefined })), moves: [], @@ -72,6 +94,7 @@ export class OpenRouterProvider implements AIProvider { async suggestBookmarkLocation( bookmark: BookmarkInfo, folders: { id: string; path: string }[], + onStatus?: StatusCallback, ): Promise { const input = this.includeUrls ? { id: bookmark.id, title: bookmark.title, url: bookmark.url } @@ -79,46 +102,80 @@ export class OpenRouterProvider implements AIProvider { const prompt = buildBookmarkLocationPrompt(input, folders, { includeUrls: this.includeUrls, }); - const response = await this.complete(prompt); - return parseBookmarkLocation(response).suggestions; + const parsed = await withRetry( + (errorContext) => this.complete(prompt, folders.length, errorContext), + parseBookmarkLocation, + onStatus, + ); + return parsed.suggestions; } - private async complete(prompt: string): Promise { + private async complete( + prompt: string, + itemCount: number, + errorContext?: string, + ): Promise { if (!this.apiKey) throw new Error("OpenRouter API key not configured"); - const response = await fetch( - "https://openrouter.ai/api/v1/chat/completions", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - "HTTP-Referer": "https://github.com/vxrtx", - "X-Title": "vxrtx", - }, - body: JSON.stringify({ - model: this.model, - messages: [ - { - role: "system", - content: - "You are a browser organization assistant. Always respond with valid JSON only.", - }, - { role: "user", content: prompt }, - ], - temperature: 0.3, - }), - }, + const userContent = errorContext ? `${prompt}\n\n${errorContext}` : prompt; + const timeout = aiTimeoutMs(itemCount); + const promptChars = userContent.length; + + const maxTokens = aiMaxTokens(itemCount); + console.log( + `[vxrtx] OpenRouter request: model=${this.model}, items=${itemCount}, prompt=${promptChars} chars, max_tokens=${maxTokens}, timeout=${Math.round(timeout / 1000)}s`, ); + const startTime = Date.now(); + + let response: Response; + try { + response = await fetchWithTimeout( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + "HTTP-Referer": "https://github.com/vxrtx", + "X-Title": "vxrtx", + }, + body: JSON.stringify({ + model: this.model, + messages: [ + { role: "system", content: SYSTEM_MESSAGE }, + { role: "user", content: userContent }, + ], + temperature: 0.0, + max_tokens: maxTokens, + }), + }, + timeout, + ); + } catch (err) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`[vxrtx] OpenRouter fetch failed after ${elapsed}s:`, err); + throw err; + } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); if (!response.ok) { const err = await response.text(); + console.error(`[vxrtx] OpenRouter API error after ${elapsed}s: ${response.status}`, err); throw new Error(`OpenRouter API error (${response.status}): ${err}`); } const data = await response.json(); const content = data.choices?.[0]?.message?.content; - if (!content) throw new Error("No content in OpenRouter response"); + if (!content) { + console.error( + `[vxrtx] OpenRouter empty response after ${elapsed}s:`, + JSON.stringify(data).slice(0, 500), + ); + throw new Error("No content in OpenRouter response"); + } + + console.log(`[vxrtx] OpenRouter response: ${elapsed}s, ${content.length} chars`); return content; } } diff --git a/apps/extension/src/ai/providers/relaxed.ts b/apps/extension/src/ai/providers/relaxed.ts index 907164c..aa042fd 100644 --- a/apps/extension/src/ai/providers/relaxed.ts +++ b/apps/extension/src/ai/providers/relaxed.ts @@ -1,22 +1,39 @@ -import type { AIProvider, TabOrganizationAIResult } from "../types"; import type { - TabInfo, + AIModelProvider, BookmarkInfo, BookmarkOrganizationResult, LocationSuggestion, - AIModelProvider, - GroupingGranularity, + TabInfo, } from "@/shared/types"; +import { + parseBookmarkLocation, + parseBookmarkOrganization, + parseTabOrganization, + withRetry, +} from "../parser"; +import { + bookmarksToRelaxedInput, + buildBookmarkLocationPrompt, + buildBookmarkOrganizePrompt, + buildBookmarkOrganizePromptParts, +} from "../prompts/bookmark-grouping"; import { buildTabGroupingPrompt, + buildTabGroupingPromptParts, tabsToRelaxedInput, } from "../prompts/tab-grouping"; import { - buildBookmarkOrganizePrompt, - buildBookmarkLocationPrompt, - bookmarksToRelaxedInput, -} from "../prompts/bookmark-grouping"; -import { parseTabOrganization, parseBookmarkOrganization, parseBookmarkLocation } from "../parser"; + type AIProvider, + aiMaxTokens, + aiTimeoutMs, + fetchWithTimeout, + type OrganizeBookmarksOptions, + type OrganizeTabsOptions, + type StatusCallback, + SYSTEM_MESSAGE, + selectClaudeModel, + type TabOrganizationAIResult, +} from "../types"; /** * Relaxed provider: same API calls as YOLO but sends minimal data. @@ -30,21 +47,54 @@ export class RelaxedProvider implements AIProvider { private openaiKey: string, ) {} - async organizeTabs(tabs: TabInfo[], granularity?: GroupingGranularity): Promise { + async organizeTabs( + tabs: TabInfo[], + options?: OrganizeTabsOptions, + ): Promise { + const { granularity, corrections, guidance, onStatus } = options ?? {}; const input = tabsToRelaxedInput(tabs); - const prompt = buildTabGroupingPrompt(input, { includeUrls: false, granularity }); - const response = await this.complete(prompt); - return parseTabOrganization(response); + const promptOpts = { includeUrls: false, granularity, corrections, guidance }; + if (this.modelProvider === "claude") { + const parts = buildTabGroupingPromptParts(input, promptOpts); + return withRetry( + (errorContext) => + this.completeClaude(parts.cached, parts.dynamic, tabs.length, errorContext), + parseTabOrganization, + onStatus, + ); + } + const prompt = buildTabGroupingPrompt(input, promptOpts); + return withRetry( + (errorContext) => this.completeOpenAI(prompt, tabs.length, errorContext), + parseTabOrganization, + onStatus, + ); } async organizeBookmarks( bookmarks: BookmarkInfo[], - granularity?: GroupingGranularity, + options?: OrganizeBookmarksOptions, ): Promise { + const { granularity, guidance, onStatus } = options ?? {}; const input = bookmarksToRelaxedInput(bookmarks); - const prompt = buildBookmarkOrganizePrompt(input, { includeUrls: false, granularity }); - const response = await this.complete(prompt); - const parsed = parseBookmarkOrganization(response); + const promptOpts = { includeUrls: false, granularity, guidance }; + let parsed: Awaited>; + if (this.modelProvider === "claude") { + const parts = buildBookmarkOrganizePromptParts(input, promptOpts); + parsed = await withRetry( + (errorContext) => + this.completeClaude(parts.cached, parts.dynamic, bookmarks.length, errorContext), + parseBookmarkOrganization, + onStatus, + ); + } else { + const prompt = buildBookmarkOrganizePrompt(input, promptOpts); + parsed = await withRetry( + (errorContext) => this.completeOpenAI(prompt, bookmarks.length, errorContext), + parseBookmarkOrganization, + onStatus, + ); + } return { folders: parsed.folders.map((f) => ({ ...f, parentId: undefined })), moves: [], @@ -57,39 +107,65 @@ export class RelaxedProvider implements AIProvider { async suggestBookmarkLocation( bookmark: BookmarkInfo, folders: { id: string; path: string }[], + onStatus?: StatusCallback, ): Promise { const input = { id: bookmark.id, title: bookmark.title }; const prompt = buildBookmarkLocationPrompt(input, folders, { includeUrls: false, }); - const response = await this.complete(prompt); - return parseBookmarkLocation(response).suggestions; - } - - private async complete(prompt: string): Promise { - if (this.modelProvider === "claude") { - return this.completeClaude(prompt); - } - return this.completeOpenAI(prompt); + const parsed = await withRetry( + (errorContext) => + this.modelProvider === "claude" + ? this.completeClaude(prompt, "", folders.length, errorContext) + : this.completeOpenAI(prompt, folders.length, errorContext), + parseBookmarkLocation, + onStatus, + ); + return parsed.suggestions; } - private async completeClaude(prompt: string): Promise { + private async completeClaude( + cachedContent: string, + dynamicContent: string, + itemCount: number, + errorContext?: string, + ): Promise { if (!this.claudeKey) throw new Error("Anthropic API key not configured"); - const response = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": this.claudeKey, - "anthropic-version": "2023-06-01", - "anthropic-dangerous-direct-browser-access": "true", + const userBlocks: { type: string; text: string; cache_control?: { type: string } }[] = [ + { type: "text", text: cachedContent, cache_control: { type: "ephemeral" } }, + ]; + const dynamicText = errorContext ? `${dynamicContent}\n\n${errorContext}` : dynamicContent; + if (dynamicText) { + userBlocks.push({ type: "text", text: dynamicText }); + } + + const model = selectClaudeModel(itemCount); + const timeout = aiTimeoutMs(itemCount); + const totalChars = cachedContent.length + (dynamicText?.length ?? 0); + console.log( + `[vxrtx] Claude request: model=${model}, items=${itemCount}, prompt=${totalChars} chars, timeout=${Math.round(timeout / 1000)}s`, + ); + const response = await fetchWithTimeout( + "https://api.anthropic.com/v1/messages", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": this.claudeKey, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }, + body: JSON.stringify({ + model, + max_tokens: aiMaxTokens(itemCount), + temperature: 0.0, + system: SYSTEM_MESSAGE, + messages: [{ role: "user", content: userBlocks }], + }), }, - body: JSON.stringify({ - model: "claude-sonnet-4-20250514", - max_tokens: 4096, - messages: [{ role: "user", content: prompt }], - }), - }); + timeout, + ); if (!response.ok) { const err = await response.text(); @@ -97,36 +173,45 @@ export class RelaxedProvider implements AIProvider { } const data = await response.json(); - const textBlock = data.content?.find( - (b: { type: string }) => b.type === "text", - ); + const textBlock = data.content?.find((b: { type: string }) => b.type === "text"); if (!textBlock?.text) throw new Error("No text in Claude response"); return textBlock.text; } - private async completeOpenAI(prompt: string): Promise { + private async completeOpenAI( + prompt: string, + itemCount: number, + errorContext?: string, + ): Promise { if (!this.openaiKey) throw new Error("OpenAI API key not configured"); - const response = await fetch("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.openaiKey}`, + const userContent = errorContext ? `${prompt}\n\n${errorContext}` : prompt; + const timeout = aiTimeoutMs(itemCount); + console.log( + `[vxrtx] OpenAI request: model=gpt-4o-mini, items=${itemCount}, prompt=${userContent.length} chars, timeout=${Math.round(timeout / 1000)}s`, + ); + + const response = await fetchWithTimeout( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.openaiKey}`, + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: SYSTEM_MESSAGE }, + { role: "user", content: userContent }, + ], + temperature: 0.0, + max_tokens: aiMaxTokens(itemCount), + response_format: { type: "json_object" }, + }), }, - body: JSON.stringify({ - model: "gpt-4o-mini", - messages: [ - { - role: "system", - content: - "You are a browser organization assistant. Always respond with valid JSON only.", - }, - { role: "user", content: prompt }, - ], - temperature: 0.3, - response_format: { type: "json_object" }, - }), - }); + timeout, + ); if (!response.ok) { const err = await response.text(); diff --git a/apps/extension/src/ai/providers/yolo.ts b/apps/extension/src/ai/providers/yolo.ts index a3c080e..041e595 100644 --- a/apps/extension/src/ai/providers/yolo.ts +++ b/apps/extension/src/ai/providers/yolo.ts @@ -1,22 +1,39 @@ -import type { AIProvider, TabOrganizationAIResult, AIRequestOptions } from "../types"; import type { - TabInfo, + AIModelProvider, BookmarkInfo, BookmarkOrganizationResult, LocationSuggestion, - AIModelProvider, - GroupingGranularity, + TabInfo, } from "@/shared/types"; +import { + parseBookmarkLocation, + parseBookmarkOrganization, + parseTabOrganization, + withRetry, +} from "../parser"; +import { + bookmarksToYoloInput, + buildBookmarkLocationPrompt, + buildBookmarkOrganizePrompt, + buildBookmarkOrganizePromptParts, +} from "../prompts/bookmark-grouping"; import { buildTabGroupingPrompt, + buildTabGroupingPromptParts, tabsToYoloInput, } from "../prompts/tab-grouping"; import { - buildBookmarkOrganizePrompt, - buildBookmarkLocationPrompt, - bookmarksToYoloInput, -} from "../prompts/bookmark-grouping"; -import { parseTabOrganization, parseBookmarkOrganization, parseBookmarkLocation } from "../parser"; + type AIProvider, + aiMaxTokens, + aiTimeoutMs, + fetchWithTimeout, + type OrganizeBookmarksOptions, + type OrganizeTabsOptions, + type StatusCallback, + SYSTEM_MESSAGE, + selectClaudeModel, + type TabOrganizationAIResult, +} from "../types"; export class YoloProvider implements AIProvider { constructor( @@ -25,21 +42,54 @@ export class YoloProvider implements AIProvider { private openaiKey: string, ) {} - async organizeTabs(tabs: TabInfo[], granularity?: GroupingGranularity): Promise { + async organizeTabs( + tabs: TabInfo[], + options?: OrganizeTabsOptions, + ): Promise { + const { granularity, corrections, guidance, onStatus } = options ?? {}; const input = tabsToYoloInput(tabs); - const prompt = buildTabGroupingPrompt(input, { includeUrls: true, granularity }); - const response = await this.complete(prompt); - return parseTabOrganization(response); + const promptOpts = { includeUrls: true, granularity, corrections, guidance }; + if (this.modelProvider === "claude") { + const parts = buildTabGroupingPromptParts(input, promptOpts); + return withRetry( + (errorContext) => + this.completeClaude(parts.cached, parts.dynamic, tabs.length, errorContext), + parseTabOrganization, + onStatus, + ); + } + const prompt = buildTabGroupingPrompt(input, promptOpts); + return withRetry( + (errorContext) => this.completeOpenAI(prompt, tabs.length, errorContext), + parseTabOrganization, + onStatus, + ); } async organizeBookmarks( bookmarks: BookmarkInfo[], - granularity?: GroupingGranularity, + options?: OrganizeBookmarksOptions, ): Promise { + const { granularity, guidance, onStatus } = options ?? {}; const input = bookmarksToYoloInput(bookmarks); - const prompt = buildBookmarkOrganizePrompt(input, { includeUrls: true, granularity }); - const response = await this.complete(prompt); - const parsed = parseBookmarkOrganization(response); + const promptOpts = { includeUrls: true, granularity, guidance }; + let parsed: Awaited>; + if (this.modelProvider === "claude") { + const parts = buildBookmarkOrganizePromptParts(input, promptOpts); + parsed = await withRetry( + (errorContext) => + this.completeClaude(parts.cached, parts.dynamic, bookmarks.length, errorContext), + parseBookmarkOrganization, + onStatus, + ); + } else { + const prompt = buildBookmarkOrganizePrompt(input, promptOpts); + parsed = await withRetry( + (errorContext) => this.completeOpenAI(prompt, bookmarks.length, errorContext), + parseBookmarkOrganization, + onStatus, + ); + } return { folders: parsed.folders.map((f) => ({ ...f, parentId: undefined })), moves: [], @@ -52,39 +102,64 @@ export class YoloProvider implements AIProvider { async suggestBookmarkLocation( bookmark: BookmarkInfo, folders: { id: string; path: string }[], + onStatus?: StatusCallback, ): Promise { const input = { id: bookmark.id, title: bookmark.title, url: bookmark.url }; const prompt = buildBookmarkLocationPrompt(input, folders, { includeUrls: true, }); - const response = await this.complete(prompt); - return parseBookmarkLocation(response).suggestions; - } - - private async complete(prompt: string): Promise { - if (this.modelProvider === "claude") { - return this.completeClaude(prompt); - } - return this.completeOpenAI(prompt); + const parsed = await withRetry( + (errorContext) => + this.modelProvider === "claude" + ? this.completeClaude(prompt, "", folders.length, errorContext) + : this.completeOpenAI(prompt, folders.length, errorContext), + parseBookmarkLocation, + onStatus, + ); + return parsed.suggestions; } - private async completeClaude(prompt: string): Promise { + private async completeClaude( + cachedContent: string, + dynamicContent: string, + itemCount: number, + errorContext?: string, + ): Promise { if (!this.claudeKey) throw new Error("Anthropic API key not configured"); - const response = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": this.claudeKey, - "anthropic-version": "2023-06-01", - "anthropic-dangerous-direct-browser-access": "true", + const userBlocks: { type: string; text: string; cache_control?: { type: string } }[] = [ + { type: "text", text: cachedContent, cache_control: { type: "ephemeral" } }, + ]; + const dynamicText = errorContext ? `${dynamicContent}\n\n${errorContext}` : dynamicContent; + if (dynamicText) { + userBlocks.push({ type: "text", text: dynamicText }); + } + + const model = selectClaudeModel(itemCount); + const timeout = aiTimeoutMs(itemCount); + console.log( + `[vxrtx] Claude request: model=${model}, items=${itemCount}, timeout=${Math.round(timeout / 1000)}s`, + ); + const response = await fetchWithTimeout( + "https://api.anthropic.com/v1/messages", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": this.claudeKey, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }, + body: JSON.stringify({ + model, + max_tokens: aiMaxTokens(itemCount), + temperature: 0.0, + system: SYSTEM_MESSAGE, + messages: [{ role: "user", content: userBlocks }], + }), }, - body: JSON.stringify({ - model: "claude-sonnet-4-20250514", - max_tokens: 4096, - messages: [{ role: "user", content: prompt }], - }), - }); + timeout, + ); if (!response.ok) { const err = await response.text(); @@ -92,36 +167,42 @@ export class YoloProvider implements AIProvider { } const data = await response.json(); - const textBlock = data.content?.find( - (b: { type: string }) => b.type === "text", - ); + const textBlock = data.content?.find((b: { type: string }) => b.type === "text"); if (!textBlock?.text) throw new Error("No text in Claude response"); return textBlock.text; } - private async completeOpenAI(prompt: string): Promise { + private async completeOpenAI( + prompt: string, + itemCount: number, + errorContext?: string, + ): Promise { if (!this.openaiKey) throw new Error("OpenAI API key not configured"); - const response = await fetch("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.openaiKey}`, + const userContent = errorContext ? `${prompt}\n\n${errorContext}` : prompt; + const timeout = aiTimeoutMs(itemCount); + + const response = await fetchWithTimeout( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.openaiKey}`, + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: SYSTEM_MESSAGE }, + { role: "user", content: userContent }, + ], + temperature: 0.0, + max_tokens: aiMaxTokens(itemCount), + response_format: { type: "json_object" }, + }), }, - body: JSON.stringify({ - model: "gpt-4o-mini", - messages: [ - { - role: "system", - content: - "You are a browser organization assistant. Always respond with valid JSON only.", - }, - { role: "user", content: prompt }, - ], - temperature: 0.3, - response_format: { type: "json_object" }, - }), - }); + timeout, + ); if (!response.ok) { const err = await response.text(); diff --git a/apps/extension/src/ai/types.ts b/apps/extension/src/ai/types.ts index 27e81c7..fe14f56 100644 --- a/apps/extension/src/ai/types.ts +++ b/apps/extension/src/ai/types.ts @@ -1,10 +1,11 @@ import type { - TabInfo, - TabGroupSuggestion, BookmarkInfo, BookmarkOrganizationResult, - LocationSuggestion, + CorrectionSignal, GroupingGranularity, + LocationSuggestion, + TabGroupSuggestion, + TabInfo, } from "@/shared/types"; export interface TabOrganizationAIResult { @@ -14,18 +15,31 @@ export interface TabOrganizationAIResult { reasoning: string; } +export type StatusCallback = (message: string) => void; + +export interface OrganizeTabsOptions { + granularity?: GroupingGranularity; + corrections?: CorrectionSignal[]; + guidance?: string; + onStatus?: StatusCallback; +} + +export interface OrganizeBookmarksOptions { + granularity?: GroupingGranularity; + guidance?: string; + onStatus?: StatusCallback; +} + export interface AIProvider { - organizeTabs( - tabs: TabInfo[], - granularity?: GroupingGranularity, - ): Promise; + organizeTabs(tabs: TabInfo[], options?: OrganizeTabsOptions): Promise; organizeBookmarks( bookmarks: BookmarkInfo[], - granularity?: GroupingGranularity, + options?: OrganizeBookmarksOptions, ): Promise; suggestBookmarkLocation( bookmark: BookmarkInfo, folders: { id: string; path: string }[], + onStatus?: StatusCallback, ): Promise; } @@ -33,3 +47,55 @@ export interface AIRequestOptions { apiKey: string; model?: string; } + +export const SYSTEM_MESSAGE = + "You are a browser tab and bookmark organizer. Always respond with ONLY valid JSON — no prose, no markdown, no code fences."; + +/** Route Claude model by item count: Haiku for small sets, Sonnet for large. */ +const CLAUDE_HAIKU = "claude-haiku-4-5-20251001"; +const CLAUDE_SONNET = "claude-sonnet-4-20250514"; +const CLAUDE_ROUTING_THRESHOLD = 30; + +export function selectClaudeModel(itemCount: number): string { + return itemCount <= CLAUDE_ROUTING_THRESHOLD ? CLAUDE_HAIKU : CLAUDE_SONNET; +} + +/** Scale max_tokens by item count. Each item needs ~20 tokens in the output JSON. */ +export function aiMaxTokens(itemCount: number): number { + // Base 2048 for schema overhead + reasoning, plus ~20 tokens per item for ID assignments + // Capped at 8192 for broad model compatibility (some models via OpenRouter cap lower) + return Math.min(2048 + itemCount * 20, 8192); +} + +/** Base timeout for LLM API calls. Scales with item count via aiTimeoutMs(). */ +const AI_FETCH_BASE_TIMEOUT_MS = 30_000; +const AI_FETCH_PER_ITEM_MS = 250; +const AI_FETCH_MAX_TIMEOUT_MS = 90_000; + +/** Calculate timeout based on item count. 30s base + 0.25s per item, capped at 90s. */ +export function aiTimeoutMs(itemCount: number): number { + return Math.min( + AI_FETCH_BASE_TIMEOUT_MS + itemCount * AI_FETCH_PER_ITEM_MS, + AI_FETCH_MAX_TIMEOUT_MS, + ); +} + +/** Fetch wrapper with AbortController timeout. Throws on timeout instead of hanging forever. */ +export async function fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit | undefined, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + throw new Error(`AI request timed out after ${Math.round(timeoutMs / 1000)}s`); + } + throw err; + } finally { + clearTimeout(timer); + } +} diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index e9b1f92..76bc707 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -1,69 +1,75 @@ -import type { Message, MessageResponse, ProgressUpdate } from "@/shared/messaging"; -import type { - Settings, - TabInfo, - TabOrganizationResult, - TabSnapshot, - LockedTabGroup, - LockedBookmarkFolder, - BookmarkInfo, - BookmarkSnapshot, - BookmarkOrganizationResult, - BookmarkDuplicateGroup, - LocationSuggestion, - FolderInfo, - Snapshot, - SnapshotType, -} from "@/shared/types"; +import { getAIProvider } from "@/ai/provider"; import { - getSettings, - saveSettings, - setSessionData, - getSessionData, + buildFolderPathMap, + createFolder, + extractFolders, + findDuplicateBookmarksDetailed, + flattenBookmarks, + getBookmarkTree, + getDeepLockedBookmarkIds, + moveBookmark, + removeBookmark, + removeEmptyFolders, + ruleBasedBookmarkOrganize, + snapshotBookmarks, +} from "@/core/bookmarks"; +import { extractCorrections, mergeCorrections } from "@/core/corrections"; +import { + addSnapshot, + appendExperimentLog, clearSessionData, - getLockedTabGroups, - saveLockedTabGroups, + deleteSnapshot, + getCorrections, + getExperimentLogs, getLockedBookmarkFolders, - saveLockedBookmarkFolders, - addSnapshot, + getLockedTabGroups, + getSessionData, + getSettings, getSnapshotHistory, - deleteSnapshot, - renameSnapshot, importSnapshots, + renameSnapshot, + saveCorrections, + saveLockedBookmarkFolders, + saveLockedTabGroups, + saveSettings, + setSessionData, + updateExperimentLog, } from "@/core/storage"; import { - queryAllTabs, - queryTabGroups, - createTabGroup, - ungroupTabs, closeTabs, + createTabGroup, + enhancedRuleBasedGroups, findDuplicatesByUrl, findStaleTabs, - groupByDomain, - domainToTabGroups, getLockedTabIds, + queryAllTabs, + queryTabGroups, resolveStaleLockedGroups, + ungroupTabs, } from "@/core/tabs"; -import { - getBookmarkTree, - flattenBookmarks, - extractFolders, - buildFolderPathMap, - findDuplicateBookmarksDetailed, - snapshotBookmarks, - moveBookmark, - createFolder, - removeBookmark, - removeEmptyFolders, - getDeepLockedBookmarkIds, -} from "@/core/bookmarks"; import { STORAGE_KEYS } from "@/shared/constants"; -import { getAIProvider } from "@/ai/provider"; +import type { Message, MessageResponse, ProgressUpdate } from "@/shared/messaging"; +import type { + BookmarkDuplicateGroup, + BookmarkInfo, + BookmarkOrganizationResult, + BookmarkSnapshot, + FolderInfo, + LocationSuggestion, + LockedBookmarkFolder, + LockedTabGroup, + Settings, + Snapshot, + SnapshotType, + TabGroupSnapshot, + TabGroupSuggestion, + TabInfo, + TabOrganizationResult, + TabSnapshot, +} from "@/shared/types"; // Open side panel when extension icon is clicked -chrome.sidePanel - .setPanelBehavior({ openPanelOnActionClick: true }) - .catch(console.error); +chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(console.error); // Message handler for quick operations chrome.runtime.onMessage.addListener( @@ -72,9 +78,12 @@ chrome.runtime.onMessage.addListener( _sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void, ) => { - handleMessage(message).then(sendResponse).catch((err) => { - sendResponse({ success: false, error: String(err) }); - }); + console.log(`[vxrtx] Message received: action="${message.action}"`); + handleMessage(message) + .then(sendResponse) + .catch((err) => { + sendResponse({ success: false, error: String(err) }); + }); return true; }, ); @@ -83,9 +92,11 @@ chrome.runtime.onMessage.addListener( type ProgressSender = (current: number, total: number, msg: string) => void; chrome.runtime.onConnect.addListener((port) => { + console.log(`[vxrtx] Port connected: "${port.name}"`); if (port.name !== "long-running") return; port.onMessage.addListener(async (message: Message) => { + console.log(`[vxrtx] Port message received: action="${message.action}"`); const sendProgress: ProgressSender = (current, total, msg) => { try { port.postMessage({ @@ -103,10 +114,11 @@ chrome.runtime.onConnect.addListener((port) => { let result: MessageResponse; const payload = message.payload as Record | undefined; const granularity = payload?.granularity as number | undefined; + const includePinned = payload?.includePinned as boolean | undefined; switch (message.action) { case "organize-tabs": - result = await handleOrganizeTabs(granularity, sendProgress); + result = await handleOrganizeTabs(granularity, includePinned, sendProgress); break; case "organize-bookmarks": result = await handleOrganizeBookmarks(granularity, sendProgress); @@ -114,9 +126,17 @@ chrome.runtime.onConnect.addListener((port) => { default: result = await handleMessage(message); } - try { port.postMessage(result); } catch { /* disconnected */ } + try { + port.postMessage(result); + } catch { + /* disconnected */ + } } catch (err) { - try { port.postMessage({ success: false, error: String(err) }); } catch { /* disconnected */ } + try { + port.postMessage({ success: false, error: String(err) }); + } catch { + /* disconnected */ + } } }); }); @@ -130,15 +150,13 @@ async function handleMessage(message: Message): Promise { await saveSettings(message.payload as Partial); return { success: true }; - case "organize-tabs": - return await handleOrganizeTabs( - (message.payload as { granularity?: number })?.granularity, - ); + case "organize-tabs": { + const p = message.payload as { granularity?: number; includePinned?: boolean } | undefined; + return await handleOrganizeTabs(p?.granularity, p?.includePinned); + } case "apply-tab-suggestions": - return await handleApplyTabSuggestions( - message.payload as TabOrganizationResult, - ); + return await handleApplyTabSuggestions(message.payload as TabOrganizationResult); case "undo-tab-changes": return await handleUndoTabChanges(); @@ -152,14 +170,10 @@ async function handleMessage(message: Message): Promise { return await handleFindDuplicateBookmarks(); case "suggest-bookmark-location": - return await handleSuggestBookmarkLocation( - message.payload as { bookmark: BookmarkInfo }, - ); + return await handleSuggestBookmarkLocation(message.payload as { bookmark: BookmarkInfo }); case "apply-bookmark-suggestions": - return await handleApplyBookmarkSuggestions( - message.payload as BookmarkApplyPayload, - ); + return await handleApplyBookmarkSuggestions(message.payload as BookmarkApplyPayload); case "undo-bookmark-changes": return await handleUndoBookmarkChanges(); @@ -176,30 +190,22 @@ async function handleMessage(message: Message): Promise { ); case "unlock-bookmark-folder": - return await handleUnlockBookmarkFolder( - message.payload as { folderId: string }, - ); + return await handleUnlockBookmarkFolder(message.payload as { folderId: string }); case "get-locked-tab-groups": return await handleGetLockedTabGroups(); case "lock-tab-group": - return await handleLockTabGroup( - message.payload as { chromeGroupId: number }, - ); + return await handleLockTabGroup(message.payload as { chromeGroupId: number }); case "unlock-tab-group": - return await handleUnlockTabGroup( - message.payload as { chromeGroupId: number }, - ); + return await handleUnlockTabGroup(message.payload as { chromeGroupId: number }); case "get-snapshots": return { success: true, data: await getSnapshotHistory() }; case "create-snapshot": - return await handleCreateSnapshot( - message.payload as { label: string; type: SnapshotType }, - ); + return await handleCreateSnapshot(message.payload as { label: string; type: SnapshotType }); case "restore-snapshot": return await handleRestoreSnapshot( @@ -207,9 +213,7 @@ async function handleMessage(message: Message): Promise { ); case "delete-snapshot": - await deleteSnapshot( - (message.payload as { id: string }).id, - ); + await deleteSnapshot((message.payload as { id: string }).id); return { success: true }; case "rename-snapshot": { @@ -234,13 +238,14 @@ async function handleMessage(message: Message): Promise { async function handleOrganizeTabs( granularity?: number, + includePinned?: boolean, sendProgress?: ProgressSender, ): Promise> { const settings = await getSettings(); const allTabs = await queryAllTabs(); const g = (granularity ?? 3) as import("@/shared/types").GroupingGranularity; - sendProgress?.(0, 1, `Analyzing ${allTabs.length} tabs...`); + sendProgress?.(1, 4, `Preparing ${allTabs.length} tabs...`); const snapshot: TabSnapshot[] = allTabs.map((t) => ({ id: t.id, @@ -249,21 +254,67 @@ async function handleOrganizeTabs( })); await setSessionData(STORAGE_KEYS.TAB_SNAPSHOT, snapshot); - // Filter out tabs in locked groups + // Filter out pinned tabs (unless included) and tabs in locked groups + const shouldExcludePinned = !includePinned; + const pinnedTabs = shouldExcludePinned ? allTabs.filter((t) => t.pinned) : []; + const candidateTabs = shouldExcludePinned ? allTabs.filter((t) => !t.pinned) : allTabs; + const windowId = allTabs[0]?.windowId; - const lockedGroups = - windowId !== undefined ? await resolveLockedGroups(windowId) : []; - const lockedTabIds = getLockedTabIds(lockedGroups, allTabs); - const tabs = allTabs.filter((t) => !lockedTabIds.has(t.id)); + const lockedGroups = windowId !== undefined ? await resolveLockedGroups(windowId) : []; + const lockedTabIds = getLockedTabIds(lockedGroups, candidateTabs); + const tabs = candidateTabs.filter((t) => !lockedTabIds.has(t.id)); let result: TabOrganizationResult; - if (settings.aiTier === "secure") { + if (tabs.length === 0) { + result = { + tabs: [], + groups: [], + stale: [], + duplicates: [], + reasoning: "No tabs to organize — all tabs are pinned or in locked groups.", + }; + } else if (settings.aiTier === "secure" && settings.localAIProvider === "rule-based") { result = ruleBasedOrganize(tabs, settings.staleDaysThreshold, g); } else { try { + const modelLabel = + settings.aiTier === "secure" + ? settings.localAIProvider === "ollama" + ? `Ollama (${settings.ollamaModel})` + : "Chrome AI" + : settings.aiModelProvider === "openrouter" + ? `OpenRouter (${settings.openrouterModel})` + : settings.aiModelProvider; + sendProgress?.(2, 4, `Sending ${tabs.length} tabs to ${modelLabel}...`); const provider = await getAIProvider(); - const aiResult = await provider.organizeTabs(tabs, g); + const corrections = await getCorrections(); + const onStatus = (msg: string) => sendProgress?.(2, 4, msg); + const startTime = Date.now(); + const aiResult = await provider.organizeTabs(tabs, { + granularity: g, + corrections, + guidance: settings.tabGuidance, + onStatus, + }); + const latencyMs = Date.now() - startTime; + sendProgress?.(3, 4, "Processing results..."); + // Store original AI groups for correction diff at apply time + await setSessionData("vxrtx_ai_tab_groups", aiResult.groups); + + // Log experiment for A/B analysis + const experimentId = crypto.randomUUID(); + await setSessionData("vxrtx_experiment_id", experimentId); + await appendExperimentLog({ + id: experimentId, + timestamp: Date.now(), + variant: "default", + model: modelLabel, + itemCount: tabs.length, + latencyMs, + groupCount: aiResult.groups.length, + }); + result = { tabs, groups: aiResult.groups, @@ -278,25 +329,29 @@ async function handleOrganizeTabs( } } - if (lockedGroups.length > 0) { - result.reasoning = `${lockedGroups.length} locked group(s) excluded. ${result.reasoning ?? ""}`; + const exclusions: string[] = []; + if (pinnedTabs.length > 0) exclusions.push(`${pinnedTabs.length} pinned tab(s) excluded`); + if (lockedGroups.length > 0) exclusions.push(`${lockedGroups.length} locked group(s) excluded`); + if (exclusions.length > 0) { + result.reasoning = `${exclusions.join(". ")}. ${result.reasoning ?? ""}`; } + sendProgress?.(4, 4, "Done"); return { success: true, data: result }; } -async function handleApplyTabSuggestions( - result: TabOrganizationResult, -): Promise { +async function handleApplyTabSuggestions(result: TabOrganizationResult): Promise { const tabs = await queryAllTabs(); const windowId = tabs[0]?.windowId; if (windowId === undefined) { return { success: false, error: "No window found" }; } - // Resolve locked groups and build protected tab ID set + // Resolve locked groups and build protected tab ID set (includes pinned) + const pinnedTabIds = new Set(tabs.filter((t) => t.pinned).map((t) => t.id)); const lockedGroups = await resolveLockedGroups(windowId); const lockedTabIds = getLockedTabIds(lockedGroups, tabs); + const protectedTabIds = new Set([...pinnedTabIds, ...lockedTabIds]); const snapshot: TabSnapshot[] = tabs.map((t) => ({ id: t.id, @@ -305,6 +360,15 @@ async function handleApplyTabSuggestions( })); await setSessionData(STORAGE_KEYS.TAB_SNAPSHOT, snapshot); + // Capture group metadata (names + colors) for restore + const liveGroups = windowId !== undefined ? await queryTabGroups(windowId) : []; + const tabGroupSnapshots: TabGroupSnapshot[] = liveGroups.map((g) => ({ + groupId: g.id, + title: g.title ?? "", + color: g.color as import("@/shared/types").TabGroupColor, + })); + await setSessionData("vxrtx_tab_group_snapshot", tabGroupSnapshots); + // Persistent auto-snapshot for history await addSnapshot({ id: crypto.randomUUID(), @@ -315,20 +379,32 @@ async function handleApplyTabSuggestions( tabCount: snapshot.length, bookmarkCount: 0, tabs: snapshot, + tabGroups: tabGroupSnapshots, bookmarks: [], }); + const createdGroupIds: number[] = []; for (const group of result.groups) { const validTabIds = group.tabIds.filter( - (id) => !lockedTabIds.has(id) && tabs.some((t) => t.id === id), + (id) => !protectedTabIds.has(id) && tabs.some((t) => t.id === id), ); if (validTabIds.length === 0) continue; - await createTabGroup({ ...group, tabIds: validTabIds }, windowId); + const groupId = await createTabGroup({ ...group, tabIds: validTabIds }, windowId); + createdGroupIds.push(groupId); + } + + // Collapse all newly created groups to reduce tab bar clutter + for (const groupId of createdGroupIds) { + try { + await chrome.tabGroups.update(groupId, { collapsed: true }); + } catch { + // Group may have been removed between creation and collapse + } } if (result.stale.length > 0) { const validStale = result.stale.filter( - (id) => !lockedTabIds.has(id) && tabs.some((t) => t.id === id), + (id) => !protectedTabIds.has(id) && tabs.some((t) => t.id === id), ); if (validStale.length > 0) await closeTabs(validStale); } @@ -337,20 +413,46 @@ async function handleApplyTabSuggestions( if (dupSet.length > 1) { const validDups = dupSet .slice(1) - .filter( - (id) => !lockedTabIds.has(id) && tabs.some((t) => t.id === id), - ); + .filter((id) => !protectedTabIds.has(id) && tabs.some((t) => t.id === id)); if (validDups.length > 0) await closeTabs(validDups); } } + // Extract and store correction signals from AI suggestion vs. user-applied diff + try { + const aiGroups = await getSessionData("vxrtx_ai_tab_groups"); + if (aiGroups && aiGroups.length > 0) { + const newCorrections = extractCorrections(aiGroups, result.groups, tabs); + const editCount = newCorrections.filter((c) => c.source === "correction").length; + + if (newCorrections.length > 0) { + const existing = await getCorrections(); + const merged = mergeCorrections(existing, newCorrections); + await saveCorrections(merged); + console.log( + `[vxrtx] Saved ${newCorrections.length} correction signal(s), ${merged.length} total stored`, + ); + } + + // Update experiment log with edit count + const experimentId = await getSessionData("vxrtx_experiment_id"); + if (experimentId) { + await updateExperimentLog(experimentId, { editCount }); + console.log(`[vxrtx] Experiment ${experimentId.slice(0, 8)}: ${editCount} edit(s)`); + await clearSessionData("vxrtx_experiment_id"); + } + + await clearSessionData("vxrtx_ai_tab_groups"); + } + } catch (err) { + console.warn("[vxrtx] Failed to save corrections:", err); + } + return { success: true }; } async function handleUndoTabChanges(): Promise { - const snapshot = await getSessionData( - STORAGE_KEYS.TAB_SNAPSHOT, - ); + const snapshot = await getSessionData(STORAGE_KEYS.TAB_SNAPSHOT); if (!snapshot || snapshot.length === 0) { return { success: false, error: "No undo snapshot available" }; } @@ -359,8 +461,7 @@ async function handleUndoTabChanges(): Promise { const windowId = currentTabs[0]?.windowId; // Resolve locked groups — their tabs must not be ungrouped or moved - const lockedGroups = - windowId !== undefined ? await resolveLockedGroups(windowId) : []; + const lockedGroups = windowId !== undefined ? await resolveLockedGroups(windowId) : []; const lockedTabIds = getLockedTabIds(lockedGroups, currentTabs); // Ungroup all non-locked grouped tabs @@ -384,21 +485,51 @@ async function handleUndoTabChanges(): Promise { } } + // Load group metadata saved at apply time + const savedGroupMeta = await getSessionData("vxrtx_tab_group_snapshot"); + const groupMeta = new Map(); + if (savedGroupMeta) { + for (const g of savedGroupMeta) { + groupMeta.set(g.groupId, g); + } + } + if (windowId !== undefined) { - for (const tabIds of groupMap.values()) { - const validIds = tabIds.filter((id) => - currentTabs.some((t) => t.id === id), - ); + for (const [oldGroupId, tabIds] of groupMap) { + const validIds = tabIds.filter((id) => currentTabs.some((t) => t.id === id)); if (validIds.length > 0) { - await chrome.tabs.group({ + const newGroupId = await chrome.tabs.group({ tabIds: validIds as [number, ...number[]], createProperties: { windowId }, }); + const meta = groupMeta.get(oldGroupId); + if (meta && (meta.title || meta.color)) { + try { + await chrome.tabGroups.update(newGroupId, { + title: meta.title, + color: meta.color as chrome.tabGroups.Color, + }); + } catch { + /* group may have been removed */ + } + } } } } await clearSessionData(STORAGE_KEYS.TAB_SNAPSHOT); + + // Mark the most recent experiment as undone + try { + const logs = await getExperimentLogs(); + if (logs.length > 0) { + const latest = logs[logs.length - 1]; + await updateExperimentLog(latest.id, { undone: true }); + } + } catch { + /* non-critical */ + } + return { success: true }; } @@ -407,11 +538,10 @@ function ruleBasedOrganize( staleDays: number, granularity: import("@/shared/types").GroupingGranularity = 3, ): TabOrganizationResult { - const domainMap = groupByDomain(tabs); // Lower granularity = larger minGroupSize (fewer groups) // 1=Broad → min 4, 2→min 3, 3=Balanced → min 2, 4→min 2, 5=Fine → min 1 const minGroupSize = granularity <= 1 ? 4 : granularity <= 2 ? 3 : granularity <= 3 ? 2 : 1; - const groups = domainToTabGroups(domainMap, minGroupSize); + const groups = enhancedRuleBasedGroups(tabs, minGroupSize); const duplicates = findDuplicatesByUrl(tabs); const stale = findStaleTabs(tabs, staleDays); @@ -420,15 +550,13 @@ function ruleBasedOrganize( groups, stale, duplicates, - reasoning: "Grouped by domain (rule-based)", + reasoning: "Grouped by domain + title keywords (rule-based)", }; } // ─── Tab Group Locking ────────────────────────────────────────────── -async function resolveLockedGroups( - windowId: number, -): Promise { +async function resolveLockedGroups(windowId: number): Promise { const stored = await getLockedTabGroups(); if (stored.length === 0) return []; @@ -463,9 +591,7 @@ async function handleGetLockedTabGroups(): Promise< return { success: true, data: { locked, dormant } }; } -async function handleLockTabGroup( - payload: { chromeGroupId: number }, -): Promise { +async function handleLockTabGroup(payload: { chromeGroupId: number }): Promise { try { const group = await chrome.tabGroups.get(payload.chromeGroupId); const stored = await getLockedTabGroups(); @@ -491,21 +617,19 @@ async function handleLockTabGroup( } } -async function handleUnlockTabGroup( - payload: { chromeGroupId: number }, -): Promise { +async function handleUnlockTabGroup(payload: { chromeGroupId: number }): Promise { const stored = await getLockedTabGroups(); - await saveLockedTabGroups( - stored.filter((g) => g.chromeGroupId !== payload.chromeGroupId), - ); + await saveLockedTabGroups(stored.filter((g) => g.chromeGroupId !== payload.chromeGroupId)); return { success: true }; } // ─── Bookmark Folder Locking ──────────────────────────────────────── -async function handleLockBookmarkFolder( - payload: { folderId: string; title: string; path: string }, -): Promise { +async function handleLockBookmarkFolder(payload: { + folderId: string; + title: string; + path: string; +}): Promise { const stored = await getLockedBookmarkFolders(); if (stored.some((f) => f.folderId === payload.folderId)) { return { success: true }; @@ -520,13 +644,9 @@ async function handleLockBookmarkFolder( return { success: true }; } -async function handleUnlockBookmarkFolder( - payload: { folderId: string }, -): Promise { +async function handleUnlockBookmarkFolder(payload: { folderId: string }): Promise { const stored = await getLockedBookmarkFolders(); - await saveLockedBookmarkFolders( - stored.filter((f) => f.folderId !== payload.folderId), - ); + await saveLockedBookmarkFolders(stored.filter((f) => f.folderId !== payload.folderId)); return { success: true }; } @@ -565,46 +685,139 @@ async function handleOrganizeBookmarks( const folders = extractFolders(tree); // Snapshot for undo - await setSessionData( - STORAGE_KEYS.BOOKMARK_SNAPSHOT, - snapshotBookmarks(allBookmarks), - ); + await setSessionData(STORAGE_KEYS.BOOKMARK_SNAPSHOT, snapshotBookmarks(allBookmarks)); // Filter out bookmarks in locked folders const lockedFolders = await getLockedBookmarkFolders(); const lockedBookmarkIds = getDeepLockedBookmarkIds(lockedFolders, tree); const bookmarks = allBookmarks.filter((b) => !lockedBookmarkIds.has(b.id)); - sendProgress?.(0, 1, `Analyzing ${bookmarks.length} bookmarks...`); + sendProgress?.(1, 4, `Preparing ${bookmarks.length} bookmarks...`); + + if (settings.aiTier === "secure" && settings.localAIProvider === "rule-based") { + const ruleResult = ruleBasedBookmarkOrganize(bookmarks, g); + return { + success: true, + data: { bookmarks, folders, result: ruleResult }, + }; + } + + const modelLabel = + settings.aiModelProvider === "openrouter" + ? `OpenRouter (${settings.openrouterModel})` + : settings.aiModelProvider; + + try { + const provider = await getAIProvider(); + const BATCH_SIZE = 100; + const startTime = Date.now(); + + if (bookmarks.length <= BATCH_SIZE) { + // Small enough for a single call + console.log( + `[vxrtx] Bookmark organize: ${bookmarks.length} bookmarks (single batch), model=${modelLabel}, granularity=${g}`, + ); + sendProgress?.(2, 4, `Sending ${bookmarks.length} bookmarks to ${modelLabel}...`); + const onStatus = (msg: string) => sendProgress?.(2, 4, msg); + const aiResult = await provider.organizeBookmarks(bookmarks, { + granularity: g, + guidance: settings.bookmarkGuidance, + onStatus, + }); + console.log( + `[vxrtx] Bookmark organize complete: ${((Date.now() - startTime) / 1000).toFixed(1)}s, ${aiResult.folders.length} folders`, + ); + sendProgress?.(3, 4, "Processing results..."); + return { success: true, data: { bookmarks, folders, result: aiResult } }; + } + + // Batch processing for large collections + const batches: BookmarkInfo[][] = []; + for (let i = 0; i < bookmarks.length; i += BATCH_SIZE) { + batches.push(bookmarks.slice(i, i + BATCH_SIZE)); + } + console.log( + `[vxrtx] Bookmark organize: ${bookmarks.length} bookmarks in ${batches.length} batches, model=${modelLabel}, granularity=${g}`, + ); + + const allFolders: Map = new Map(); // lowercased path → bookmarkIds + const nameMap = new Map(); // lowercased path → first-seen original casing + const allDuplicates: string[][] = []; + const reasonings: string[] = []; + + for (let bi = 0; bi < batches.length; bi++) { + const batch = batches[bi]; + sendProgress?.( + bi + 1, + batches.length + 1, + `Batch ${bi + 1}/${batches.length}: analyzing ${batch.length} bookmarks...`, + ); + console.log(`[vxrtx] Batch ${bi + 1}/${batches.length}: ${batch.length} bookmarks`); + + try { + const onStatus = (msg: string) => sendProgress?.(bi + 1, batches.length + 1, msg); + const batchResult = await provider.organizeBookmarks(batch, { + granularity: g, + guidance: settings.bookmarkGuidance, + onStatus, + }); + + // Merge folders: consolidate by path (case-insensitive) + for (const folder of batchResult.folders) { + const key = folder.name.toLowerCase().trim(); + if (!nameMap.has(key)) { + nameMap.set(key, folder.name); // preserve first-seen casing + } + const existing = allFolders.get(key); + if (existing) { + existing.push(...folder.bookmarkIds); + } else { + allFolders.set(key, [...folder.bookmarkIds]); + } + } + if (batchResult.duplicates.length > 0) { + allDuplicates.push(...batchResult.duplicates); + } + if (batchResult.reasoning) { + reasonings.push(batchResult.reasoning); + } + } catch (batchErr) { + console.warn(`[vxrtx] Batch ${bi + 1} failed:`, batchErr); + reasonings.push( + `Batch ${bi + 1} failed: ${batchErr instanceof Error ? batchErr.message : String(batchErr)}`, + ); + } + } + + // Build merged result using first-seen casing for folder names + const mergedFolders = Array.from(allFolders.entries()).map(([key, bookmarkIds]) => ({ + name: nameMap.get(key) ?? key, + bookmarkIds, + parentId: undefined as string | undefined, + })); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log( + `[vxrtx] Bookmark organize complete: ${elapsed}s, ${mergedFolders.length} folders from ${batches.length} batches`, + ); + sendProgress?.(batches.length + 1, batches.length + 1, "Done"); - if (settings.aiTier === "secure") { - // Rule-based: no restructuring, just return current state return { success: true, data: { bookmarks, folders, result: { - folders: [], + folders: mergedFolders, moves: [], - duplicates: [], + duplicates: allDuplicates, newFolders: [], - reasoning: - "Local AI not yet available. Showing current bookmarks. Switch to Relaxed/YOLO tier for AI-powered organization.", + reasoning: `Processed ${bookmarks.length} bookmarks in ${batches.length} batches (${elapsed}s). ${reasonings[0] ?? ""}`, }, }, }; - } - - try { - const provider = await getAIProvider(); - const aiResult = await provider.organizeBookmarks(bookmarks, g); - return { - success: true, - data: { bookmarks, folders, result: aiResult }, - }; } catch (err) { - console.warn("AI bookmark organization failed:", err); + console.error("[vxrtx] Bookmark organize FAILED:", err); return { success: true, data: { @@ -622,9 +835,7 @@ async function handleOrganizeBookmarks( } } -async function handleFindDuplicateBookmarks(): Promise< - MessageResponse -> { +async function handleFindDuplicateBookmarks(): Promise> { const tree = await getBookmarkTree(); const allBookmarks = flattenBookmarks(tree); const folderPathMap = buildFolderPathMap(tree); @@ -642,37 +853,29 @@ async function handleFindDuplicateBookmarks(): Promise< } // Snapshot for undo - await setSessionData( - STORAGE_KEYS.BOOKMARK_SNAPSHOT, - snapshotBookmarks(bookmarks), - ); + await setSessionData(STORAGE_KEYS.BOOKMARK_SNAPSHOT, snapshotBookmarks(bookmarks)); return { success: true, data: { duplicates, folderPaths } }; } -async function handleSuggestBookmarkLocation( - payload: { bookmark: BookmarkInfo }, -): Promise> { +async function handleSuggestBookmarkLocation(payload: { + bookmark: BookmarkInfo; +}): Promise> { const settings = await getSettings(); - if (settings.aiTier === "secure") { - return { - success: false, - error: - "Local AI not yet available for bookmark suggestions. Switch to Relaxed/YOLO tier.", - }; - } - const tree = await getBookmarkTree(); const folders = extractFolders(tree); + + if (settings.aiTier === "secure") { + // Rule-based: suggest folders whose name matches the bookmark's domain + const suggestions = suggestFolderByDomain(payload.bookmark, folders); + return { success: true, data: { suggestions } }; + } const folderInput = folders.map((f) => ({ id: f.id, path: f.path })); try { const provider = await getAIProvider(); - const suggestions = await provider.suggestBookmarkLocation( - payload.bookmark, - folderInput, - ); + const suggestions = await provider.suggestBookmarkLocation(payload.bookmark, folderInput); return { success: true, data: { suggestions } }; } catch (err) { return { @@ -682,44 +885,144 @@ async function handleSuggestBookmarkLocation( } } +/** + * Rule-based folder suggestion: match bookmark's domain against existing folder names/paths. + */ +function suggestFolderByDomain( + bookmark: BookmarkInfo, + folders: FolderInfo[], +): LocationSuggestion[] { + if (!bookmark.url) return []; + + let domain: string; + try { + domain = new URL(bookmark.url).hostname.replace(/^www\./, "").toLowerCase(); + } catch { + return []; + } + + const domainWord = domain.split(".")[0]; // "github" from "github.com" + const suggestions: LocationSuggestion[] = []; + + for (const folder of folders) { + const folderLower = folder.title.toLowerCase(); + const pathLower = folder.path.toLowerCase(); + + // Exact domain word match in folder name + if (folderLower.includes(domainWord)) { + suggestions.push({ + folderId: folder.id, + folderPath: folder.path, + confidence: 0.8, + reason: `Folder name matches domain "${domain}"`, + }); + } + // Partial match in folder path + else if (pathLower.includes(domainWord)) { + suggestions.push({ + folderId: folder.id, + folderPath: folder.path, + confidence: 0.5, + reason: `Folder path contains "${domainWord}"`, + }); + } + } + + // Sort by confidence, take top 3 + suggestions.sort((a, b) => b.confidence - a.confidence); + return suggestions.slice(0, 3); +} + +/** + * Create a nested folder path like "Dev/Frontend". Splits on "/", creates + * intermediate folders, and returns the leaf folder's ID. Uses a cache to + * avoid creating duplicate parents within the same apply operation. + */ +async function createFolderPath( + path: string, + rootParentId: string, + cache: Map, +): Promise { + const segments = path + .split("/") + .map((s) => s.trim()) + .filter(Boolean); + if (segments.length === 0) return rootParentId; + + let currentParentId = rootParentId; + let currentPath = ""; + + for (const segment of segments) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const cacheKey = `${currentPath.toLowerCase()}:${rootParentId}`; + + const cached = cache.get(cacheKey); + if (cached) { + currentParentId = cached; + continue; + } + + const created = await createFolder(segment, currentParentId); + cache.set(cacheKey, created.id); + currentParentId = created.id; + } + + return currentParentId; +} + async function handleApplyBookmarkSuggestions( payload: BookmarkApplyPayload, ): Promise { + console.log( + `[vxrtx] Applying bookmark suggestions: ${payload.moves.length} moves, ${payload.newFolders.length} new folders, ${payload.removals.length} removals`, + ); + // Re-snapshot before applying const tree = await getBookmarkTree(); const bookmarks = flattenBookmarks(tree); const bmSnapshot = snapshotBookmarks(bookmarks); - await setSessionData(STORAGE_KEYS.BOOKMARK_SNAPSHOT, bmSnapshot); + console.log(`[vxrtx] Bookmark snapshot: ${bmSnapshot.length} entries`); + + try { + await setSessionData(STORAGE_KEYS.BOOKMARK_SNAPSHOT, bmSnapshot); + } catch (err) { + console.error("[vxrtx] Failed to save session snapshot:", err); + } // Persistent auto-snapshot for history - await addSnapshot({ - id: crypto.randomUUID(), - timestamp: Date.now(), - type: "bookmarks", - label: "Before bookmark organization", - source: "auto", - tabCount: 0, - bookmarkCount: bmSnapshot.length, - tabs: [], - bookmarks: bmSnapshot, - }); + try { + await addSnapshot({ + id: crypto.randomUUID(), + timestamp: Date.now(), + type: "bookmarks", + label: "Before bookmark organization", + source: "auto", + tabCount: 0, + bookmarkCount: bmSnapshot.length, + tabs: [], + bookmarks: bmSnapshot, + }); + console.log(`[vxrtx] Auto-snapshot saved to history`); + } catch (err) { + console.error("[vxrtx] Failed to save auto-snapshot:", err); + } // Build locked set so we never move/remove locked bookmarks const lockedFolders = await getLockedBookmarkFolders(); const lockedBookmarkIds = getDeepLockedBookmarkIds(lockedFolders, tree); - // Create new folders first and build a map of placeholder → real ID + // Create new folders (with hierarchical path support) and build a map of placeholder → real ID const folderIdMap = new Map(); + const folderPathCache = new Map(); // cache to avoid duplicate intermediate folders for (const folder of payload.newFolders) { - const created = await createFolder(folder.name, folder.parentId); - folderIdMap.set(`${folder.name}:${folder.parentId}`, created.id); + const leafId = await createFolderPath(folder.name, folder.parentId, folderPathCache); + folderIdMap.set(`${folder.name}:${folder.parentId}`, leafId); } // Apply moves (skip locked bookmarks) for (const move of payload.moves) { if (lockedBookmarkIds.has(move.bookmarkId)) continue; - const resolvedFolderId = - folderIdMap.get(move.targetFolderId) ?? move.targetFolderId; + const resolvedFolderId = folderIdMap.get(move.targetFolderId) ?? move.targetFolderId; try { await moveBookmark(move.bookmarkId, { parentId: resolvedFolderId }); } catch (err) { @@ -755,9 +1058,7 @@ async function handleCleanupEmptyFolders(): Promise { } async function handleUndoBookmarkChanges(): Promise { - const snapshot = await getSessionData( - STORAGE_KEYS.BOOKMARK_SNAPSHOT, - ); + const snapshot = await getSessionData(STORAGE_KEYS.BOOKMARK_SNAPSHOT); if (!snapshot || snapshot.length === 0) { return { success: false, error: "No undo snapshot available" }; } @@ -780,10 +1081,12 @@ async function handleUndoBookmarkChanges(): Promise { // ─── Snapshot Management ────────────────────────────────────────────── -async function handleCreateSnapshot( - payload: { label: string; type: SnapshotType }, -): Promise { +async function handleCreateSnapshot(payload: { + label: string; + type: SnapshotType; +}): Promise { let tabs: TabSnapshot[] = []; + let tabGroups: TabGroupSnapshot[] = []; let bmSnapshots: BookmarkSnapshot[] = []; if (payload.type === "tabs" || payload.type === "both") { @@ -793,6 +1096,15 @@ async function handleCreateSnapshot( groupId: t.groupId, windowId: t.windowId, })); + const windowId = allTabs[0]?.windowId; + if (windowId !== undefined) { + const liveGroups = await queryTabGroups(windowId); + tabGroups = liveGroups.map((g) => ({ + groupId: g.id, + title: g.title ?? "", + color: g.color as import("@/shared/types").TabGroupColor, + })); + } } if (payload.type === "bookmarks" || payload.type === "both") { @@ -810,15 +1122,21 @@ async function handleCreateSnapshot( tabCount: tabs.length, bookmarkCount: bmSnapshots.length, tabs, + tabGroups: tabGroups.length > 0 ? tabGroups : undefined, bookmarks: bmSnapshots, }); return { success: true }; } -async function handleRestoreSnapshot( - payload: { id: string; restoreType: SnapshotType }, -): Promise> { +async function handleRestoreSnapshot(payload: { id: string; restoreType: SnapshotType }): Promise< + MessageResponse<{ + tabsRestored: number; + tabsSkipped: number; + bookmarksRestored: number; + bookmarksSkipped: number; + }> +> { const history = await getSnapshotHistory(); const snapshot = history.find((s) => s.id === payload.id); if (!snapshot) { @@ -838,8 +1156,7 @@ async function handleRestoreSnapshot( const currentTabs = await queryAllTabs(); const windowId = currentTabs[0]?.windowId; - const lockedGroups = - windowId !== undefined ? await resolveLockedGroups(windowId) : []; + const lockedGroups = windowId !== undefined ? await resolveLockedGroups(windowId) : []; const lockedTabIds = getLockedTabIds(lockedGroups, currentTabs); // Ungroup all non-locked grouped tabs @@ -863,18 +1180,36 @@ async function handleRestoreSnapshot( } } + // Build lookup for group metadata (name + color) + const groupMeta = new Map(); + if (snapshot.tabGroups) { + for (const g of snapshot.tabGroups) { + groupMeta.set(g.groupId, g); + } + } + if (windowId !== undefined) { - for (const tabIds of groupMap.values()) { - const validIds = tabIds.filter((id) => - currentTabs.some((t) => t.id === id), - ); + for (const [oldGroupId, tabIds] of groupMap) { + const validIds = tabIds.filter((id) => currentTabs.some((t) => t.id === id)); tabsRestored += validIds.length; tabsSkipped += tabIds.length - validIds.length; if (validIds.length > 0) { - await chrome.tabs.group({ + const newGroupId = await chrome.tabs.group({ tabIds: validIds as [number, ...number[]], createProperties: { windowId }, }); + // Restore group name and color if we have metadata + const meta = groupMeta.get(oldGroupId); + if (meta && (meta.title || meta.color)) { + try { + await chrome.tabGroups.update(newGroupId, { + title: meta.title, + color: meta.color as chrome.tabGroups.Color, + }); + } catch { + // Group may have been removed between creation and update + } + } } } } diff --git a/apps/extension/src/core/bookmarks.ts b/apps/extension/src/core/bookmarks.ts index a15f84e..4103b1f 100644 --- a/apps/extension/src/core/bookmarks.ts +++ b/apps/extension/src/core/bookmarks.ts @@ -1,14 +1,15 @@ import type { + BookmarkDuplicateGroup, + BookmarkFolderSuggestion, BookmarkInfo, + BookmarkOrganizationResult, BookmarkSnapshot, FolderInfo, - BookmarkDuplicateGroup, + GroupingGranularity, LockedBookmarkFolder, } from "@/shared/types"; -export async function getBookmarkTree(): Promise< - chrome.bookmarks.BookmarkTreeNode[] -> { +export async function getBookmarkTree(): Promise { return chrome.bookmarks.getTree(); } @@ -30,9 +31,7 @@ export async function removeBookmark(id: string): Promise { await chrome.bookmarks.remove(id); } -export function flattenBookmarks( - nodes: chrome.bookmarks.BookmarkTreeNode[], -): BookmarkInfo[] { +export function flattenBookmarks(nodes: chrome.bookmarks.BookmarkTreeNode[]): BookmarkInfo[] { const result: BookmarkInfo[] = []; function walk(node: chrome.bookmarks.BookmarkTreeNode) { if (node.url) { @@ -58,14 +57,9 @@ export function extractFolders( parentPath: string = "", ): FolderInfo[] { const folders: FolderInfo[] = []; - function walk( - node: chrome.bookmarks.BookmarkTreeNode, - currentPath: string, - ) { + function walk(node: chrome.bookmarks.BookmarkTreeNode, currentPath: string) { if (!node.url && node.children) { - const path = currentPath - ? `${currentPath}/${node.title}` - : node.title || "Root"; + const path = currentPath ? `${currentPath}/${node.title}` : node.title || "Root"; if (node.id !== "0") { folders.push({ id: node.id, @@ -87,10 +81,7 @@ export function buildFolderPathMap( nodes: chrome.bookmarks.BookmarkTreeNode[], ): Map { const map = new Map(); - function walk( - node: chrome.bookmarks.BookmarkTreeNode, - path: string, - ) { + function walk(node: chrome.bookmarks.BookmarkTreeNode, path: string) { const currentPath = path ? `${path}/${node.title}` : node.title || ""; if (!node.url) { map.set(node.id, currentPath || "Root"); @@ -105,9 +96,7 @@ export function buildFolderPathMap( return map; } -export function findDuplicateBookmarks( - bookmarks: BookmarkInfo[], -): string[][] { +export function findDuplicateBookmarks(bookmarks: BookmarkInfo[]): string[][] { const urlMap = new Map(); for (const bm of bookmarks) { if (!bm.url) continue; @@ -159,11 +148,7 @@ export async function findEmptyFolders( (child) => child.url || (child.children && child.children.length > 0), ); - if ( - !hasContent && - node.children.length === 0 && - !PROTECTED_FOLDER_IDS.has(node.id) - ) { + if (!hasContent && node.children.length === 0 && !PROTECTED_FOLDER_IDS.has(node.id)) { empties.push({ id: node.id, title: node.title, path: currentPath }); } @@ -196,9 +181,83 @@ export async function removeEmptyFolders(): Promise { return totalRemoved; } -export function snapshotBookmarks( +// ─── Rule-Based Bookmark Organization ─────────────────────────────── + +/** + * Group bookmarks by domain, similar to tab's groupByDomain. + */ +export function groupBookmarksByDomain(bookmarks: BookmarkInfo[]): Map { + const domainMap = new Map(); + for (const bm of bookmarks) { + if (!bm.url) continue; + try { + const hostname = new URL(bm.url).hostname.replace(/^www\./, ""); + // Use the main domain (e.g., "github" from "github.com") + const existing = domainMap.get(hostname); + if (existing) { + existing.push(bm); + } else { + domainMap.set(hostname, [bm]); + } + } catch { + // Skip bookmarks with invalid URLs + } + } + return domainMap; +} + +/** + * Rule-based bookmark organization. Groups by domain with granularity-aware + * minimum group size. No AI needed. + */ +export function ruleBasedBookmarkOrganize( bookmarks: BookmarkInfo[], -): BookmarkSnapshot[] { + granularity: GroupingGranularity = 3, +): BookmarkOrganizationResult { + const domainMap = groupBookmarksByDomain(bookmarks); + + // Granularity affects minimum group size: 1→6, 2→4, 3→3, 4→2, 5→1 + const minGroupSize = + granularity <= 1 ? 6 : granularity <= 2 ? 4 : granularity <= 3 ? 3 : granularity <= 4 ? 2 : 1; + + const folders: BookmarkFolderSuggestion[] = []; + const ungrouped: BookmarkInfo[] = []; + + for (const [hostname, bms] of domainMap) { + if (bms.length >= minGroupSize) { + const name = hostname.split(".")[0]; + folders.push({ + name: name.charAt(0).toUpperCase() + name.slice(1), + bookmarkIds: bms.map((b) => b.id), + }); + } else { + ungrouped.push(...bms); + } + } + + // Group remaining ungrouped bookmarks into a "Misc" folder if there are enough + if (ungrouped.length > 0) { + folders.push({ + name: "Misc", + bookmarkIds: ungrouped.map((b) => b.id), + }); + } + + // Sort folders by bookmark count (largest first) + folders.sort((a, b) => b.bookmarkIds.length - a.bookmarkIds.length); + + const duplicates = findDuplicateBookmarks(bookmarks); + + return { + folders, + moves: [], + duplicates, + newFolders: [], + reasoning: `Grouped by domain (rule-based). ${folders.length} folders, ${duplicates.length} duplicate set(s) found.`, + }; +} + +export function snapshotBookmarks(bookmarks: BookmarkInfo[]): BookmarkSnapshot[] { return bookmarks .filter((b) => b.parentId !== undefined && b.index !== undefined) .map((b) => ({ diff --git a/apps/extension/src/core/corrections.ts b/apps/extension/src/core/corrections.ts new file mode 100644 index 0000000..a0dd157 --- /dev/null +++ b/apps/extension/src/core/corrections.ts @@ -0,0 +1,193 @@ +import { MAX_CORRECTIONS } from "@/shared/constants"; +import type { CorrectionSignal, TabGroupSuggestion, TabInfo } from "@/shared/types"; + +/** Half-life for recency decay in days. */ +const DECAY_HALF_LIFE_DAYS = 14; + +/** + * Extract domain from a URL. Returns empty string for invalid/chrome URLs. + */ +function extractDomain(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return ""; + return parsed.hostname.replace(/^www\./, ""); + } catch { + return ""; + } +} + +/** + * Compare AI-suggested groups with what the user actually applied. + * Returns new correction signals for domains that were moved or rejected. + */ +export function extractCorrections( + aiGroups: TabGroupSuggestion[], + appliedGroups: TabGroupSuggestion[], + tabs: TabInfo[], +): CorrectionSignal[] { + const tabMap = new Map(tabs.map((t) => [t.id, t])); + const now = Date.now(); + + // Build AI assignment: tabId → groupName + const aiAssignment = new Map(); + for (const group of aiGroups) { + for (const tabId of group.tabIds) { + aiAssignment.set(tabId, group.name); + } + } + + // Build applied assignment: tabId → groupName + const appliedAssignment = new Map(); + for (const group of appliedGroups) { + for (const tabId of group.tabIds) { + appliedAssignment.set(tabId, group.name); + } + } + + // Find corrections: tabs that ended up in a different group than AI suggested + const corrections: CorrectionSignal[] = []; + const seen = new Set(); // dedup by domain+preferredGroup + + for (const [tabId, aiGroup] of aiAssignment) { + const appliedGroup = appliedAssignment.get(tabId); + const tab = tabMap.get(tabId); + if (!tab) continue; + + const domain = extractDomain(tab.url); + if (!domain) continue; + + if (appliedGroup && appliedGroup !== aiGroup) { + // Tab moved to a different group — explicit correction + const key = `${domain}:prefer:${appliedGroup}`; + if (!seen.has(key)) { + seen.add(key); + corrections.push({ + domain, + preferredGroup: appliedGroup, + count: 1, + lastSeen: now, + source: "correction", + }); + } + } else if (!appliedGroup) { + // Tab was in AI suggestion but not in applied (group was disabled/tab removed) + const key = `${domain}:reject:${aiGroup}`; + if (!seen.has(key)) { + seen.add(key); + corrections.push({ + domain, + rejectedGroup: aiGroup, + count: 1, + lastSeen: now, + source: "correction", + }); + } + } else if (appliedGroup === aiGroup) { + // Tab accepted as-is — implicit affinity signal + const key = `${domain}:accept:${aiGroup}`; + if (!seen.has(key)) { + seen.add(key); + corrections.push({ + domain, + preferredGroup: aiGroup, + count: 1, + lastSeen: now, + source: "acceptance", + }); + } + } + } + + return corrections; +} + +/** + * Merge new corrections into existing stored corrections. + * Upserts by domain + preferredGroup/rejectedGroup, increments count, updates lastSeen. + * Prunes to MAX_CORRECTIONS by removing lowest-weight entries. + */ +export function mergeCorrections( + existing: CorrectionSignal[], + incoming: CorrectionSignal[], +): CorrectionSignal[] { + const merged = [...existing]; + + for (const signal of incoming) { + const matchIdx = merged.findIndex( + (c) => + c.domain === signal.domain && + c.preferredGroup === signal.preferredGroup && + c.rejectedGroup === signal.rejectedGroup, + ); + + if (matchIdx >= 0) { + merged[matchIdx] = { + ...merged[matchIdx], + count: merged[matchIdx].count + 1, + lastSeen: Math.max(merged[matchIdx].lastSeen, signal.lastSeen), + // Upgrade to "correction" if either signal is an explicit correction + source: + merged[matchIdx].source === "correction" || signal.source === "correction" + ? "correction" + : "acceptance", + }; + } else { + merged.push(signal); + } + } + + // Prune to MAX_CORRECTIONS by removing lowest-weight entries + if (merged.length > MAX_CORRECTIONS) { + const ranked = rankCorrections(merged); + return ranked.slice(0, MAX_CORRECTIONS); + } + + return merged; +} + +/** Weight multiplier: explicit corrections are 2x stronger than implicit acceptances. */ +const ACCEPTANCE_WEIGHT = 0.5; + +/** + * Calculate decay weight for a correction signal. + * weight = count * sourceMultiplier * exp(-daysSinceLastSeen / halfLife) + */ +function decayWeight(signal: CorrectionSignal, now = Date.now()): number { + const daysSince = (now - signal.lastSeen) / (1000 * 60 * 60 * 24); + const sourceMultiplier = signal.source === "acceptance" ? ACCEPTANCE_WEIGHT : 1; + return signal.count * sourceMultiplier * Math.exp(-daysSince / DECAY_HALF_LIFE_DAYS); +} + +/** + * Rank corrections by decayed weight (highest first). + */ +export function rankCorrections( + corrections: CorrectionSignal[], + now = Date.now(), +): CorrectionSignal[] { + return [...corrections].sort((a, b) => decayWeight(b, now) - decayWeight(a, now)); +} + +/** + * Format top corrections as a prompt context block. + * Returns empty string if no corrections. + */ +export function correctionsBlock(corrections: CorrectionSignal[], maxItems = 10): string { + if (corrections.length === 0) return ""; + + const ranked = rankCorrections(corrections); + const top = ranked.slice(0, maxItems); + + const lines = top.map((c) => { + if (c.rejectedGroup) { + return `- ${c.domain} tabs → avoid "${c.rejectedGroup}" (rejected ${c.count}x)`; + } + if (c.source === "acceptance") { + return `- ${c.domain} tabs → "${c.preferredGroup}" works well (confirmed ${c.count}x)`; + } + return `- ${c.domain} tabs → prefer "${c.preferredGroup}" (corrected ${c.count}x)`; + }); + + return `USER PREFERENCES (learned from your past corrections):\n${lines.join("\n")}\nRespect these preferences when grouping the tabs below.`; +} diff --git a/apps/extension/src/core/storage.ts b/apps/extension/src/core/storage.ts index f7be4c2..70a14fd 100644 --- a/apps/extension/src/core/storage.ts +++ b/apps/extension/src/core/storage.ts @@ -1,5 +1,13 @@ -import { STORAGE_KEYS, MAX_SNAPSHOTS } from "@/shared/constants"; -import { DEFAULT_SETTINGS, type Settings, type LockedTabGroup, type LockedBookmarkFolder, type Snapshot } from "@/shared/types"; +import { MAX_EXPERIMENT_LOGS, MAX_SNAPSHOTS, STORAGE_KEYS } from "@/shared/constants"; +import { + type CorrectionSignal, + DEFAULT_SETTINGS, + type ExperimentLog, + type LockedBookmarkFolder, + type LockedTabGroup, + type Settings, + type Snapshot, +} from "@/shared/types"; export async function getSettings(): Promise { const result = await chrome.storage.local.get(STORAGE_KEYS.SETTINGS); @@ -7,9 +15,7 @@ export async function getSettings(): Promise { return { ...DEFAULT_SETTINGS, ...stored }; } -export async function saveSettings( - settings: Partial, -): Promise { +export async function saveSettings(settings: Partial): Promise { const current = await getSettings(); await chrome.storage.local.set({ [STORAGE_KEYS.SETTINGS]: { ...current, ...settings }, @@ -21,10 +27,7 @@ export async function getSessionData(key: string): Promise { return (result[key] as T) ?? null; } -export async function setSessionData( - key: string, - data: T, -): Promise { +export async function setSessionData(key: string, data: T): Promise { await chrome.storage.session.set({ [key]: data }); } @@ -42,22 +45,57 @@ export async function getLockedBookmarkFolders(): Promise { +export async function saveLockedBookmarkFolders(folders: LockedBookmarkFolder[]): Promise { await chrome.storage.local.set({ [STORAGE_KEYS.LOCKED_BOOKMARK_FOLDERS]: folders, }); } -export async function saveLockedTabGroups( - groups: LockedTabGroup[], -): Promise { +export async function saveLockedTabGroups(groups: LockedTabGroup[]): Promise { await chrome.storage.local.set({ [STORAGE_KEYS.LOCKED_TAB_GROUPS]: groups, }); } +// ─── Correction Signals ────────────────────────────────────────────── + +export async function getCorrections(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEYS.CORRECTIONS); + return (result[STORAGE_KEYS.CORRECTIONS] as CorrectionSignal[]) ?? []; +} + +export async function saveCorrections(corrections: CorrectionSignal[]): Promise { + await chrome.storage.local.set({ [STORAGE_KEYS.CORRECTIONS]: corrections }); +} + +// ─── Experiment Logs ───────────────────────────────────────────────── + +export async function getExperimentLogs(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEYS.EXPERIMENT_LOGS); + return (result[STORAGE_KEYS.EXPERIMENT_LOGS] as ExperimentLog[]) ?? []; +} + +export async function appendExperimentLog(log: ExperimentLog): Promise { + const logs = await getExperimentLogs(); + logs.push(log); + // Keep only the most recent entries + const trimmed = + logs.length > MAX_EXPERIMENT_LOGS ? logs.slice(logs.length - MAX_EXPERIMENT_LOGS) : logs; + await chrome.storage.local.set({ [STORAGE_KEYS.EXPERIMENT_LOGS]: trimmed }); +} + +export async function updateExperimentLog( + id: string, + update: Partial>, +): Promise { + const logs = await getExperimentLogs(); + const log = logs.find((l) => l.id === id); + if (log) { + Object.assign(log, update); + await chrome.storage.local.set({ [STORAGE_KEYS.EXPERIMENT_LOGS]: logs }); + } +} + // ─── Snapshot History ───────────────────────────────────────────────── export async function getSnapshotHistory(): Promise { @@ -80,10 +118,7 @@ export async function deleteSnapshot(id: string): Promise { }); } -export async function renameSnapshot( - id: string, - label: string, -): Promise { +export async function renameSnapshot(id: string, label: string): Promise { const history = await getSnapshotHistory(); const snap = history.find((s) => s.id === id); if (snap) { @@ -94,9 +129,7 @@ export async function renameSnapshot( } } -export async function importSnapshots( - incoming: Snapshot[], -): Promise { +export async function importSnapshots(incoming: Snapshot[]): Promise { const history = await getSnapshotHistory(); const existingIds = new Set(history.map((s) => s.id)); const newSnapshots = incoming.filter((s) => !existingIds.has(s.id)); diff --git a/apps/extension/src/core/tabs.ts b/apps/extension/src/core/tabs.ts index 2243600..cb81af6 100644 --- a/apps/extension/src/core/tabs.ts +++ b/apps/extension/src/core/tabs.ts @@ -1,9 +1,4 @@ -import type { - TabInfo, - TabGroupColor, - TabGroupSuggestion, - LockedTabGroup, -} from "@/shared/types"; +import type { LockedTabGroup, TabGroupColor, TabGroupSuggestion, TabInfo } from "@/shared/types"; export async function queryAllTabs(): Promise { const tabs = await chrome.tabs.query({ currentWindow: true }); @@ -15,25 +10,19 @@ export async function queryAllTabs(): Promise { url: tab.url ?? "", favIconUrl: tab.favIconUrl, lastAccessed: tab.lastAccessed, + pinned: tab.pinned ?? false, groupId: tab.groupId ?? chrome.tabGroups?.TAB_GROUP_ID_NONE ?? -1, windowId: tab.windowId, })); } -export async function queryTabGroups( - windowId: number, -): Promise { +export async function queryTabGroups(windowId: number): Promise { return chrome.tabGroups.query({ windowId }); } -export function getLockedTabIds( - lockedGroups: LockedTabGroup[], - tabs: TabInfo[], -): Set { +export function getLockedTabIds(lockedGroups: LockedTabGroup[], tabs: TabInfo[]): Set { const lockedGroupIds = new Set(lockedGroups.map((g) => g.chromeGroupId)); - return new Set( - tabs.filter((t) => lockedGroupIds.has(t.groupId)).map((t) => t.id), - ); + return new Set(tabs.filter((t) => lockedGroupIds.has(t.groupId)).map((t) => t.id)); } export function resolveStaleLockedGroups( @@ -45,9 +34,7 @@ export function resolveStaleLockedGroups( const resolved = lockedGroups.map((locked) => { if (liveIds.has(locked.chromeGroupId)) return locked; // Try to match by name + color - const match = liveGroups.find( - (g) => g.title === locked.name && g.color === locked.color, - ); + const match = liveGroups.find((g) => g.title === locked.name && g.color === locked.color); if (match) { changed = true; return { ...locked, chromeGroupId: match.id }; @@ -66,8 +53,9 @@ export async function createTabGroup( tabIds, createProperties: { windowId }, }); + const title = suggestion.name?.trim() || "Untitled Group"; await chrome.tabGroups.update(groupId, { - title: suggestion.name, + title, color: suggestion.color as chrome.tabGroups.Color, }); return groupId; @@ -96,22 +84,14 @@ export function findDuplicatesByUrl(tabs: TabInfo[]): number[][] { return Array.from(urlMap.values()).filter((ids) => ids.length > 1); } -export function findStaleTabs( - tabs: TabInfo[], - staleDays: number, -): number[] { +export function findStaleTabs(tabs: TabInfo[], staleDays: number): number[] { const threshold = Date.now() - staleDays * 24 * 60 * 60 * 1000; return tabs - .filter( - (tab) => - tab.lastAccessed !== undefined && tab.lastAccessed < threshold, - ) + .filter((tab) => tab.lastAccessed !== undefined && tab.lastAccessed < threshold) .map((tab) => tab.id); } -export function groupByDomain( - tabs: TabInfo[], -): Map { +export function groupByDomain(tabs: TabInfo[]): Map { const domainMap = new Map(); for (const tab of tabs) { try { @@ -161,8 +141,7 @@ export function domainToTabGroups( for (const [domain, tabs] of domainMap) { if (tabs.length < minGroupSize) continue; - const color = - DOMAIN_COLOR_MAP[domain] ?? COLOR_CYCLE[colorIndex % COLOR_CYCLE.length]; + const color = DOMAIN_COLOR_MAP[domain] ?? COLOR_CYCLE[colorIndex % COLOR_CYCLE.length]; if (!DOMAIN_COLOR_MAP[domain]) colorIndex++; const name = domain.split(".")[0]; @@ -175,3 +154,172 @@ export function domainToTabGroups( return groups; } + +// ─── Keyword-Enhanced Rule-Based Grouping ─────────────────────────── + +/** Common stop words to filter out of title keywords. */ +const STOP_WORDS = new Set([ + "the", + "a", + "an", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "of", + "with", + "by", + "from", + "is", + "are", + "was", + "were", + "be", + "been", + "has", + "have", + "had", + "do", + "does", + "did", + "will", + "would", + "could", + "should", + "may", + "might", + "can", + "this", + "that", + "these", + "those", + "it", + "its", + "my", + "your", + "our", + "their", + "his", + "her", + "not", + "no", + "how", + "what", + "when", + "where", + "who", + "which", + "why", + "all", + "each", + "new", + "old", + "get", + "set", + "use", + "using", + "home", + "page", + "about", + "http", + "https", + "www", + "com", + "org", + "net", + "io", +]); + +/** Extract meaningful keywords from a tab title. */ +function extractKeywords(title: string): string[] { + return title + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((w) => w.length >= 3 && !STOP_WORDS.has(w)); +} + +/** + * Theme-based grouping: find keyword clusters across tabs that share + * common title keywords, regardless of domain. Runs AFTER domain grouping + * to merge ungrouped/small-domain tabs into keyword-based themes. + */ +export function groupByKeywordThemes( + tabs: TabInfo[], + minGroupSize: number = 2, +): TabGroupSuggestion[] { + // Build keyword → tab mapping + const keywordTabs = new Map>(); + for (const tab of tabs) { + const keywords = extractKeywords(tab.title); + for (const kw of keywords) { + const existing = keywordTabs.get(kw); + if (existing) { + existing.add(tab.id); + } else { + keywordTabs.set(kw, new Set([tab.id])); + } + } + } + + // Find keywords that appear in enough tabs to form a group + const candidates: { keyword: string; tabIds: number[] }[] = []; + for (const [keyword, tabIdSet] of keywordTabs) { + if (tabIdSet.size >= minGroupSize) { + candidates.push({ keyword, tabIds: Array.from(tabIdSet) }); + } + } + + // Sort by group size (largest first) and greedily assign tabs + candidates.sort((a, b) => b.tabIds.length - a.tabIds.length); + const assigned = new Set(); + const groups: TabGroupSuggestion[] = []; + let colorIndex = 0; + + for (const { keyword, tabIds } of candidates) { + const unassigned = tabIds.filter((id) => !assigned.has(id)); + if (unassigned.length < minGroupSize) continue; + + for (const id of unassigned) assigned.add(id); + + groups.push({ + name: keyword.charAt(0).toUpperCase() + keyword.slice(1), + color: COLOR_CYCLE[colorIndex % COLOR_CYCLE.length], + tabIds: unassigned, + }); + colorIndex++; + } + + return groups; +} + +/** + * Enhanced rule-based organization: domain grouping first, then keyword + * themes for ungrouped tabs. Combines both strategies for better results. + */ +export function enhancedRuleBasedGroups( + tabs: TabInfo[], + minGroupSize: number = 2, +): TabGroupSuggestion[] { + // Step 1: Domain-based groups + const domainMap = groupByDomain(tabs); + const domainGroups = domainToTabGroups(domainMap, minGroupSize); + + // Collect tab IDs already assigned to domain groups + const assignedIds = new Set(); + for (const g of domainGroups) { + for (const id of g.tabIds) assignedIds.add(id); + } + + // Step 2: Keyword-based groups for unassigned tabs + const unassignedTabs = tabs.filter((t) => !assignedIds.has(t.id)); + if (unassignedTabs.length < minGroupSize) return domainGroups; + + const keywordGroups = groupByKeywordThemes(unassignedTabs, minGroupSize); + + return [...domainGroups, ...keywordGroups]; +} diff --git a/apps/extension/src/shared/constants.ts b/apps/extension/src/shared/constants.ts index 9ac3e32..4f3e2fd 100644 --- a/apps/extension/src/shared/constants.ts +++ b/apps/extension/src/shared/constants.ts @@ -5,9 +5,13 @@ export const STORAGE_KEYS = { LOCKED_TAB_GROUPS: "vxrtx_locked_tab_groups", LOCKED_BOOKMARK_FOLDERS: "vxrtx_locked_bookmark_folders", SNAPSHOT_HISTORY: "vxrtx_snapshot_history", + CORRECTIONS: "vxrtx_corrections", + EXPERIMENT_LOGS: "vxrtx_experiment_logs", } as const; export const MAX_SNAPSHOTS = 20; +export const MAX_CORRECTIONS = 50; +export const MAX_EXPERIMENT_LOGS = 200; export const TAB_GROUP_COLORS: readonly string[] = [ "grey", diff --git a/apps/extension/src/shared/messaging.ts b/apps/extension/src/shared/messaging.ts index b9279d2..5a0b43c 100644 --- a/apps/extension/src/shared/messaging.ts +++ b/apps/extension/src/shared/messaging.ts @@ -51,9 +51,13 @@ export function sendMessage( }); } +/** Timeout for silence between progress updates (60 seconds). Resets on each progress message. */ +const SILENCE_TIMEOUT_MS = 60_000; + /** * Send a message over a port for long-running operations. * Avoids the MV3 message channel timeout and enables progress updates. + * Includes a silence timeout — if no progress or result arrives within 45s, assumes hung. */ export function sendLongRunningMessage( action: MessageAction, @@ -62,25 +66,45 @@ export function sendLongRunningMessage( ): Promise> { return new Promise((resolve) => { const port = chrome.runtime.connect({ name: "long-running" }); + let settled = false; + let timer: ReturnType; - port.onMessage.addListener( - (msg: MessageResponse | ProgressUpdate) => { - if ("type" in msg && msg.type === "progress") { - onProgress?.(msg as ProgressUpdate); - } else { - resolve(msg as MessageResponse); + function resetTimer() { + clearTimeout(timer); + timer = setTimeout(() => { + if (!settled) { + settled = true; port.disconnect(); + resolve({ + success: false, + error: + "Operation timed out — no response from AI provider. Check your API key and network connection.", + } as MessageResponse); } - }, - ); + }, SILENCE_TIMEOUT_MS); + } + + resetTimer(); + + port.onMessage.addListener((msg: MessageResponse | ProgressUpdate) => { + if ("type" in msg && msg.type === "progress") { + resetTimer(); // Activity — keep waiting + onProgress?.(msg as ProgressUpdate); + } else if (!settled) { + settled = true; + clearTimeout(timer); + resolve(msg as MessageResponse); + port.disconnect(); + } + }); port.onDisconnect.addListener(() => { - if (chrome.runtime.lastError) { + if (!settled && chrome.runtime.lastError) { + settled = true; + clearTimeout(timer); resolve({ success: false, - error: - chrome.runtime.lastError.message ?? - "Connection lost to background worker", + error: chrome.runtime.lastError.message ?? "Connection lost to background worker", } as MessageResponse); } }); diff --git a/apps/extension/src/shared/types.ts b/apps/extension/src/shared/types.ts index b9ddec2..abeab68 100644 --- a/apps/extension/src/shared/types.ts +++ b/apps/extension/src/shared/types.ts @@ -2,6 +2,8 @@ export type AITier = "secure" | "relaxed" | "yolo"; export type AIModelProvider = "claude" | "openai" | "openrouter"; +export type LocalAIProvider = "rule-based" | "ollama" | "chrome-ai"; + export interface Settings { aiTier: AITier; aiModelProvider: AIModelProvider; @@ -10,6 +12,18 @@ export interface Settings { openrouterApiKey: string; openrouterModel: string; staleDaysThreshold: number; + /** Local AI provider for secure tier */ + localAIProvider: LocalAIProvider; + /** Ollama server URL (default: http://localhost:11434) */ + ollamaUrl: string; + /** Ollama model name */ + ollamaModel: string; + /** Whether to include pinned tabs in AI organization */ + includePinnedTabs: boolean; + /** Custom guidance for tab organization (injected into prompt) */ + tabGuidance: string; + /** Custom guidance for bookmark organization (injected into prompt) */ + bookmarkGuidance: string; } export const DEFAULT_SETTINGS: Settings = { @@ -20,6 +34,12 @@ export const DEFAULT_SETTINGS: Settings = { openrouterApiKey: "", openrouterModel: "anthropic/claude-sonnet-4", staleDaysThreshold: 7, + localAIProvider: "rule-based", + ollamaUrl: "http://localhost:11434", + ollamaModel: "llama3.2", + includePinnedTabs: false, + tabGuidance: "", + bookmarkGuidance: "", }; export type GroupingGranularity = 1 | 2 | 3 | 4 | 5; @@ -49,6 +69,7 @@ export interface TabInfo { url: string; favIconUrl?: string; lastAccessed?: number; + pinned: boolean; groupId: number; windowId: number; } @@ -73,6 +94,12 @@ export interface TabSnapshot { windowId: number; } +export interface TabGroupSnapshot { + groupId: number; + title: string; + color: TabGroupColor; +} + export interface LockedTabGroup { chromeGroupId: number; name: string; @@ -135,6 +162,49 @@ export interface BookmarkDuplicateGroup { bookmarks: BookmarkInfo[]; } +// ─── Correction Signals ───────────────────────────────────────────── + +export type SignalSource = "correction" | "acceptance"; + +export interface CorrectionSignal { + /** Domain the correction applies to (e.g., "github.com") */ + domain: string; + /** Group name the user prefers for this domain */ + preferredGroup?: string; + /** Group name the user rejected for this domain */ + rejectedGroup?: string; + /** Number of times this signal has been recorded */ + count: number; + /** Timestamp of the most recent occurrence */ + lastSeen: number; + /** Whether this came from an explicit edit or implicit acceptance. Defaults to "correction" for backward compat. */ + source?: SignalSource; +} + +// ─── Experiment Logs ──────────────────────────────────────────────── + +export interface ExperimentLog { + /** Unique ID for correlating organize→apply pairs */ + id: string; + timestamp: number; + /** Prompt variant identifier (e.g., "default", "v2-fewshot") */ + variant: string; + /** LLM model used */ + model: string; + /** Number of items sent to the AI */ + itemCount: number; + /** AI response latency in milliseconds */ + latencyMs: number; + /** Number of groups the AI suggested */ + groupCount: number; + /** Number of user edits before applying (0 = accepted as-is). Set at apply time. */ + editCount?: number; + /** Whether the user undid the changes after applying */ + undone?: boolean; +} + +// ─── Snapshots ────────────────────────────────────────────────────── + export type SnapshotSource = "auto" | "manual"; export type SnapshotType = "tabs" | "bookmarks" | "both"; @@ -147,5 +217,7 @@ export interface Snapshot { tabCount: number; bookmarkCount: number; tabs: TabSnapshot[]; + /** Group metadata for restoring names/colors. Optional for backward compat with old snapshots. */ + tabGroups?: TabGroupSnapshot[]; bookmarks: BookmarkSnapshot[]; } diff --git a/apps/extension/src/sidepanel/App.tsx b/apps/extension/src/sidepanel/App.tsx index aae9f8b..8614896 100644 --- a/apps/extension/src/sidepanel/App.tsx +++ b/apps/extension/src/sidepanel/App.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; -import { TabOrganizer } from "./pages/TabOrganizer"; import { BookmarkOrganizer } from "./pages/BookmarkOrganizer"; -import { Snapshots } from "./pages/Snapshots"; import { Settings } from "./pages/Settings"; +import { Snapshots } from "./pages/Snapshots"; +import { TabOrganizer } from "./pages/TabOrganizer"; type Page = "tabs" | "bookmarks" | "snapshots" | "settings"; @@ -11,7 +11,16 @@ const NAV_ITEMS: { id: Page; label: string; icon: React.ReactNode }[] = [ id: "tabs", label: "Tabs", icon: ( - + @@ -22,7 +31,16 @@ const NAV_ITEMS: { id: Page; label: string; icon: React.ReactNode }[] = [ id: "bookmarks", label: "Bookmarks", icon: ( - + ), @@ -31,7 +49,16 @@ const NAV_ITEMS: { id: Page; label: string; icon: React.ReactNode }[] = [ id: "snapshots", label: "Snapshots", icon: ( - + @@ -42,7 +69,16 @@ const NAV_ITEMS: { id: Page; label: string; icon: React.ReactNode }[] = [ id: "settings", label: "Settings", icon: ( - + @@ -57,8 +93,18 @@ export function App() {
{/* Branded header */}
- - + +
@@ -71,12 +117,12 @@ export function App() { key={item.id} onClick={() => setPage(item.id)} className={`group relative flex flex-1 flex-col items-center gap-0.5 py-2.5 text-[10px] font-medium tracking-wide transition-colors ${ - isActive - ? "text-brand-400" - : "text-zinc-500 hover:text-zinc-300" + isActive ? "text-brand-400" : "text-zinc-500 hover:text-zinc-300" }`} > - + {item.icon} {item.label} @@ -88,13 +134,19 @@ export function App() { })} - {/* Content */} + {/* Content — all pages stay mounted to preserve state across tab switches */}
-
- {page === "tabs" && } - {page === "bookmarks" && } - {page === "snapshots" && } - {page === "settings" && } +
+ +
+
+ +
+
+ +
+
+
diff --git a/apps/extension/src/sidepanel/components/GranularitySlider.tsx b/apps/extension/src/sidepanel/components/GranularitySlider.tsx index d14e8ce..ef2d705 100644 --- a/apps/extension/src/sidepanel/components/GranularitySlider.tsx +++ b/apps/extension/src/sidepanel/components/GranularitySlider.tsx @@ -28,9 +28,7 @@ export function GranularitySlider({ max={5} step={1} value={value} - onChange={(e) => - onChange(Number(e.target.value) as GroupingGranularity) - } + onChange={(e) => onChange(Number(e.target.value) as GroupingGranularity)} disabled={disabled} className="h-1 flex-1" /> diff --git a/apps/extension/src/sidepanel/components/GuidanceInput.tsx b/apps/extension/src/sidepanel/components/GuidanceInput.tsx new file mode 100644 index 0000000..17eb043 --- /dev/null +++ b/apps/extension/src/sidepanel/components/GuidanceInput.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from "react"; +import { sendMessage } from "@/shared/messaging"; + +const TAB_PRESETS = [ + { label: "By project", value: "Group tabs by project or codebase, not by domain" }, + { label: "By domain", value: "Group tabs primarily by website domain" }, + { + label: "By activity", + value: "Group tabs by what I'm actively working on vs. reference material", + }, + { label: "Fewer groups", value: "Create as few groups as possible, merge aggressively" }, +]; + +const BOOKMARK_PRESETS = [ + { label: "By topic", value: "Organize bookmarks by topic and subject matter" }, + { + label: "By purpose", + value: "Organize bookmarks by purpose: tools, reference, reading, entertainment", + }, + { + label: "Nested", + value: + "Organize into a hierarchical folder tree using '/' paths like 'Dev/Frontend'. Group related categories under shared parents.", + }, + { + label: "Flat", + value: + "Create a completely flat folder structure. Do NOT use '/' nesting. Use only simple top-level folder names.", + }, +]; + +export function GuidanceInput({ + type, + value, + onChange, +}: { + type: "tabs" | "bookmarks"; + value: string; + onChange: (value: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const textareaRef = useRef(null); + const presets = type === "tabs" ? TAB_PRESETS : BOOKMARK_PRESETS; + const hasValue = value.trim().length > 0; + + useEffect(() => { + if (expanded && textareaRef.current) { + textareaRef.current.focus(); + } + }, [expanded]); + + // Save guidance to settings when it changes + useEffect(() => { + const key = type === "tabs" ? "tabGuidance" : "bookmarkGuidance"; + const timer = setTimeout(() => { + sendMessage("save-settings", { [key]: value }); + }, 500); + return () => clearTimeout(timer); + }, [value, type]); + + return ( +
+ + + {expanded && ( +
+