Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
57f2fc3
Wrapping phase 3
heyjawrsh Mar 26, 2026
1a8162d
a little phase 4 pregame; adding in openrouter options
heyjawrsh Mar 26, 2026
013477c
Saving initial pass at bookmarks org
heyjawrsh Mar 26, 2026
64e39d4
Added folder cleanup to Bookmarks section
heyjawrsh Mar 26, 2026
d87184c
Monorepo (#2)
heyjawrsh Mar 27, 2026
602a2f1
feat: add tab group locking and brand color theme (#4)
heyjawrsh Mar 27, 2026
7f90050
Bookmark locking
heyjawrsh Mar 27, 2026
483b28d
feat: add granularity slider and long-running message support
heyjawrsh Mar 30, 2026
64b74e3
Updated to new direction branding
heyjawrsh Mar 30, 2026
6d123f1
feat: add snapshot manager with persistent history, import/export, an…
heyjawrsh Mar 30, 2026
c916d0c
Ignoring .screens dir
heyjawrsh Mar 30, 2026
e4241dc
Adding screenshot directive so that we can always obtain good screens…
heyjawrsh Mar 30, 2026
d97d4ca
Big UI hugs
heyjawrsh Mar 30, 2026
78b8384
Adding in the current state of case study
heyjawrsh Mar 30, 2026
13e84b1
Ignoring a couple of additional generated dirs
heyjawrsh Apr 2, 2026
29ff342
Spiffing up the place
heyjawrsh Apr 2, 2026
6ee22f5
feat(ai): improve grouping consistency with system prompts, temperatu…
heyjawrsh Apr 2, 2026
5e1f16b
Updating gitignore to ignore root docs folder
heyjawrsh Apr 2, 2026
58bdd4c
Update apps/extension/src/ai/parser.ts
heyjawrsh Apr 2, 2026
83ac082
refactor(ai): consolidate SYSTEM_MESSAGE into shared types module
heyjawrsh Apr 2, 2026
667b21b
refactor(ai): use dedicated JsonExtractionError for stable retry dete…
heyjawrsh Apr 3, 2026
524b571
fix(ai): add 60s timeout to all LLM API calls and 90s safety timeout …
heyjawrsh Apr 3, 2026
2920cbf
fix(ai): add phase-based progress reporting and tighten timeouts
heyjawrsh Apr 3, 2026
20af219
fix(ai): scale timeout by item count and fix misleading "Batch" label
heyjawrsh Apr 3, 2026
0b001e9
fix(ai): add diagnostic logging to OpenRouter and show model in status
heyjawrsh Apr 3, 2026
317e2c7
fix(ai): add diagnostic logging to service worker message handlers
heyjawrsh Apr 3, 2026
81a859f
feat(ai): modular prompt composition with few-shot examples
heyjawrsh Apr 3, 2026
28cde71
fix(ai): reject empty group/folder names at parse time and fallback o…
heyjawrsh Apr 3, 2026
fb41dd4
feat(tabs): exclude pinned tabs from AI organization
heyjawrsh Apr 3, 2026
2f3706d
feat(tabs): auto-collapse tab groups after applying organization
heyjawrsh Apr 3, 2026
96bed46
feat(ui): add select all/deselect all for stale tabs and duplicates
heyjawrsh Apr 3, 2026
0c0ee5a
chore(test): add evaluation pipeline with unit tests, fixtures, and s…
heyjawrsh Apr 3, 2026
e98cd98
feat(ai): add Anthropic prompt caching via cache_control on static co…
heyjawrsh Apr 3, 2026
a3b801b
feat(ai): route Claude model by item count — Haiku for small, Sonnet …
heyjawrsh Apr 3, 2026
8494d84
feat(ai): capture user correction signals and inject into prompts
heyjawrsh Apr 3, 2026
e9bccc5
feat(ai): add domain affinity tracking from implicit acceptance signals
heyjawrsh Apr 3, 2026
5d6edb5
feat(ai): add A/B experiment logging infrastructure
heyjawrsh Apr 3, 2026
7b02621
Updating turbothangs
heyjawrsh Apr 3, 2026
c930130
feat(ui): add guidance prompts, folder rename, and improved group ren…
heyjawrsh Apr 3, 2026
61a3d4c
fix(ai): scale max_tokens by item count and add bookmark diagnostics
heyjawrsh Apr 3, 2026
0fd1cdf
fix(ai): cap max_tokens at 8192 for OpenRouter model compatibility
heyjawrsh Apr 3, 2026
7c0d6f9
feat(ai): batch bookmark organization for large collections
heyjawrsh Apr 3, 2026
c80898d
fix(ui): keep all pages mounted across tab switches to preserve state
heyjawrsh Apr 3, 2026
248f969
fix(snapshots): add diagnostic log to bookmark auto-snapshot
heyjawrsh Apr 4, 2026
4af4d4a
fix(snapshots): add diagnostic logging to bookmark apply + snapshot save
heyjawrsh Apr 4, 2026
2642dac
fix(snapshots): restore tab group names and colors on undo/restore
heyjawrsh Apr 4, 2026
c1b6c31
feat(bookmarks): hierarchical folder organization with nested paths
heyjawrsh Apr 4, 2026
499a0bb
fix(bookmarks): enforce 3-level max nesting depth in Zod schema
heyjawrsh Apr 4, 2026
ceb9811
feat(tabs): add visible toggle for including/excluding pinned tabs
heyjawrsh Apr 4, 2026
8945708
feat(secure): rule-based bookmark organization and folder suggestions
heyjawrsh Apr 4, 2026
d689e5e
feat(secure): enhanced tab grouping with title keyword themes
heyjawrsh Apr 4, 2026
07e8ec0
feat(secure): add Ollama and Chrome Built-in AI providers
heyjawrsh Apr 4, 2026
e2b20df
feat(ci): add release infrastructure with CI gates, versioning, and p…
heyjawrsh Apr 4, 2026
b6d9946
merge: resolve conflicts with main branch
heyjawrsh Apr 4, 2026
d8aee34
feat(lint): replace ESLint with Biome and add pre-commit hook
heyjawrsh Apr 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -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 }}
126 changes: 126 additions & 0 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
@@ -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 }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/
.turbo/
*.local
.DS_Store
docs/

# Environment variables
.env
Expand Down
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"apps/extension": "0.1.0"
}
1 change: 1 addition & 0 deletions apps/extension/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.zip
5 changes: 3 additions & 2 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
87 changes: 77 additions & 10 deletions apps/extension/src/ai/parser.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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({
Expand All @@ -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<T>(
completeFn: (errorContext?: string) => Promise<string>,
parseFn: (raw: string) => T,
onStatus?: (message: string) => void,
): Promise<T> {
// 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);
Expand Down Expand Up @@ -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");
}
}
Loading
Loading