diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1dfd3ef..af5f07d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,9 +24,47 @@ jobs:
# terminates an attribute) that node --check + vitest + eslint can't see.
- name: HTML parse check
run: npm run check:html
- - name: Format check (advisory)
+ - name: Format check
run: npm run format:check
- continue-on-error: true
- - name: Dependency audit (advisory)
+ - name: Dependency audit
run: npm audit --audit-level=high
+
+ mcp:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: mcp/package-lock.json
+ - run: npm ci
+ working-directory: mcp
+ - name: MCP syntax check
+ run: find . -maxdepth 1 -name '*.js' -print0 | xargs -0 -n1 node --check
+ working-directory: mcp
+ - name: MCP dependency audit
+ run: npm audit --audit-level=moderate
+ working-directory: mcp
+
+ website:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: website/package-lock.json
+ - run: npm ci
+ working-directory: website
+ - name: Website lint
+ run: npm run lint
+ working-directory: website
+ - name: Website build
+ run: npm run build
+ working-directory: website
+ - name: Website dependency audit (advisory)
+ run: npm audit --audit-level=moderate
+ working-directory: website
continue-on-error: true
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 73d9fb8..a57eb61 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -34,7 +34,7 @@ jobs:
cache-dependency-path: website/package-lock.json
- name: Install deps
working-directory: website
- run: npm install
+ run: npm ci
- name: Build static export
working-directory: website
env:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..4b961ad
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,77 @@
+name: Release extension zip
+
+on:
+ push:
+ tags:
+ - 'v*.*.*'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ package:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: '20'
+ cache: 'npm'
+ - run: npm ci
+ - name: Verify version matches tag
+ if: startsWith(github.ref, 'refs/tags/')
+ run: |
+ VERSION="${GITHUB_REF_NAME#v}"
+ VERSION="$VERSION" node - <<'NODE'
+ const fs = require('fs');
+ const version = process.env.VERSION;
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')).version;
+ const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')).version;
+ if (pkg !== version || manifest !== version) {
+ console.error(`Version mismatch: tag=${version} package=${pkg} manifest=${manifest}`);
+ process.exit(1);
+ }
+ NODE
+ - name: Verify
+ run: |
+ npm test
+ npm run lint
+ npm run check:html
+ npm audit --audit-level=high
+ - name: Package Chrome extension
+ run: |
+ VERSION=$(node -p "require('./manifest.json').version")
+ ZIP="dist/repolens-v${VERSION}.zip"
+ rm -rf dist repolens-release
+ mkdir -p dist repolens-release
+
+ cp manifest.json README.md CHANGELOG.md repolens-release/
+ cp batch.html library.html options.html output-tab.html share.html stack-tab.html whats-new.html repolens-release/
+ cp ./*.css repolens-release/
+ for file in ./*.js; do
+ case "$(basename "$file")" in
+ eslint.config.js|vitest.config.js) continue ;;
+ esac
+ cp "$file" repolens-release/
+ done
+ cp -R icons assets store migrate vendor repolens-release/
+ (cd repolens-release && zip -r "../${ZIP}" . -x '*.DS_Store')
+
+ unzip -Z1 "$ZIP" | sort > /tmp/repolens-zip-files.txt
+ cat /tmp/repolens-zip-files.txt
+ forbidden='(^|/)(node_modules|tests|coverage|website|mcp|docs|tools|\.github|\.git|\.verify|\.playwright-mcp)/|(^|/)(eslint\.config\.js|vitest\.config\.js|package-lock\.json|package\.json)$'
+ if grep -E "$forbidden" /tmp/repolens-zip-files.txt; then
+ echo "Release zip contains forbidden dev/test files" >&2
+ exit 1
+ fi
+ for required in manifest.json background.js output-tab.html library.html whats-new.html store/idb.js icons/icon128.png; do
+ if ! grep -Fxq "$required" /tmp/repolens-zip-files.txt; then
+ echo "Release zip missing required file: $required" >&2
+ exit 1
+ fi
+ done
+ - uses: actions/upload-artifact@v4
+ with:
+ name: repolens-extension-zip
+ path: dist/*.zip
diff --git a/.prettierignore b/.prettierignore
index 5ef7a31..f821ef3 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,6 +1,8 @@
node_modules
coverage
website
+docs/audits
+docs/superpowers
package-lock.json
*.min.js
icons
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b993939..ad03a90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,21 +1,42 @@
# Changelog
Every release of RepoLens, newest first. Want the friendly highlights instead of
-the full detail? See **[What's new](README.md)** in the README.
+the full detail? Open **What's New** in the extension, or read `whats-new.html`.
This project follows [Semantic Versioning](https://semver.org/) and groups changes
by theme. Dates are when the release landed on `main`. 1.1.0 through 1.6.0 shipped
the same day, as a rapid burst of improvements, so they share a date.
-## [Unreleased]
+## [Unreleased] — 2026-06-19 · _Actionable Scans · Smooth Loading · Provider Refresh · Stability_
### Added
-- **Mono Ink identity.** RepoLens ships a new dark-tile lens icon, a "Mono Ink" default theme (cool near-black, white, and cobalt), and a wordmark lockup. The toolbar icon now animates only while a scan runs: the aperture grows and spins and the ring breathes grey to blue, then it resets to static. Turn the animation off in **Options**, and it honors your OS reduced-motion setting. The other 13 themes stay one click away.
+- **More consistent scan start.** Clicking the extension icon now opens the loading tab immediately, then checks cache/provider setup in parallel, so first-press feedback is instant and cached scans swap in cleanly.
+- **More tolerant long scans.** The output tab now watches the scan heartbeat instead of failing at a fixed 90 seconds, so slow provider calls can finish instead of showing a false timeout.
+- **More playful scan loading.** The loading screen now has scan stages, live phase labels, rotating signal copy, and a livelier reduced-motion-safe lens animation.
+- **Smoother navigation.** Scan tabs now use reduced-motion-safe view transitions, restore per-tab scroll position, avoid quick-verdict re-renders while loading, and make large Library grids cheaper to scroll.
+- **Structured scan schema.** Core scans now ask for a mental model, risk register, adoption simulation, and learning path — with parser fallbacks so older scans still render useful structured sections.
+- **Decision-grade Verdict tab.** The Verdict view now leads with a best next action, confidence, evidence, and a 30-minute trial plan so every scan tells you what to do next — not just what the repo is.
+- **DESIGN.md-inspired theme pack.** Added six more CSS-only themes researched from DESIGN.md catalogs: **Command Blue**, **Aubergine Trace**, **Emerald DB**, **Paperline**, **Toybox Red**, and **Gradient Aurora**.
+- **Liquid Glass theme.** Inspired by `liquid-dom`'s liquid-glass direction without adding its WebGPU/React runtime, RepoLens now has a CSS-only **Liquid Glass** theme: translucent surfaces, icy blue highlights, glassy panels, and no new dependency or browser flag.
+- **Live first-class model catalogs.** Google Gemini, OpenRouter, and Nous now load their live model lists in **Options** instead of relying on stale hard-coded dropdowns. RepoLens filters those catalogs to text-capable models, preserves a **Custom…** escape hatch, and reuses the live list in **Models per scan part**.
+- **Claude sign-in is back, using the Claude Code / Pi OAuth flow.** The Anthropic card now supports **Sign in with Claude** for Claude Pro/Max-style accounts and still supports a Console API key (`sk-ant-api…`). OAuth tokens are stored only in this browser, refreshed automatically, and excluded from settings export.
+- **Gemini Ultra-ready picker.** The Google card loads newer Gemini entries when your API key exposes them, while the scan fallback stays on a stable Gemini 2.5 default so a fresh install does not hang on a preview model.
+- **Mono Ink identity.** RepoLens ships a new dark-tile lens icon, a "Mono Ink" default theme (cool near-black, white, and cobalt), and a wordmark lockup. The toolbar icon now animates only while a scan runs: the aperture grows and spins and the ring breathes grey to blue, then it resets to static. Turn the animation off in **Options**, and it honors your OS reduced-motion setting. The other themes stay one click away.
- **A warmer Vee.** Vee's onboarding copy reads like a person now. The repo also vendors the stop-slop writing standard under `docs/style/` so the voice stays consistent.
- **Vee-guided first-run walkthrough.** New users are met by Vee on their first Library open; the coachmark steps through a seeded demo repo (Library card → Verdict tab → Blueprint canvas) with plain narration and a spotlight on each target element. Implemented in `onboarding.js` / `coachmark.js`; copy lives in `onboarding-copy.js`.
- **Milestone "power tour"** offered after approximately five real scans: a second coachmark sequence introducing the cross-library tools: Ask, Corkboard (Alternatives / Synergies), multi-select Compare, Radar / auto-organize, and Discover.
+### Changed
+
+- **Model IDs are canonicalized before calls.** Legacy saved values such as `Hermes-4-405B`, `anthropic/claude-opus-4-8`, or Google `models/...` IDs are normalized to the IDs the provider APIs expect.
+- **Provider docs and settings copy now match reality.** Claude can use OAuth or a key, Gemini model options come from your key, and OpenRouter/Nous names reflect their `/models` catalogs.
+
+### Fixed
+
+- **Stuck scan recovery.** Output tabs now keep the MV3 service worker warm while a scan is loading, repository metadata fetches have a 20s timeout, and output pages render a timeout/retry state instead of spinning forever if the background scan stops responding.
+- **Library load/save hangs after updates.** IndexedDB connections now close on version upgrades and blocked opens reject instead of hanging forever, so stale RepoLens tabs can no longer leave the Library blank or a scan stuck on “Saving…”.
+
---
## [3.1.0] — 2026-06-16 · _Interactive Canvas (Blueprint · Guided Tour · Corkboard · Stack Studio)_
@@ -28,7 +49,7 @@ the same day, as a rapid burst of improvements, so they share a date.
- **Persistent arrangements.** Node positions and canvas state are stored in a new `scenes` IndexedDB store and round-trip through the library backup/export envelope, so layouts travel with your library.
- **Corkboard (Library-wide canvas).** A toggle in the Library page switches your whole collection into a red-string board: every scanned repo is a draggable manila card, and related repos are joined by colored string keyed to relationship type (alternatives, synergies, head-to-heads, combined ideas) and shaded by fit score. Filter by Collection to focus a board, and the arrangement is saved so it's exactly where you left it next session. Reuses the same canvas engine as Blueprint — zero new dependencies, theme-aware, reduced-motion safe.
- **Stack Studio (canvas view of a tech-stack).** The Tech-Stack Builder result gains a **View on canvas** toggle: the repos you wired together render as layer-coloured cards in adoption order, joined by their integrations, with any gaps shown as dashed cards — the same engine, turning "how these fit together" into a living diagram.
-- **Zero-build, zero dependencies.** Plain ES modules only — no bundler, no new npm packages. Theme-aware across all 13 themes and reduced-motion safe throughout.
+- **Zero-build, zero dependencies.** Plain ES modules only — no bundler, no new npm packages. Theme-aware across all themes and reduced-motion safe throughout.
## [3.0.1] — 2026-06-15 · _Audit hardening_
@@ -80,7 +101,7 @@ behavioural changes to features, just fixes and guardrails.
- **"Vee", a lens mascot** _(optional)_. A small telescope/aperture character that reacts
to your scans — scanning as it reads, wide open on a **strong** fit, eyes narrowed on a
**risky** one, thinking during a Deep Dive, resting on an empty Library. One token-aware
- inline SVG that re-skins across all 13 themes; purely decorative (`aria-hidden`) and
+ inline SVG that re-skins across every theme; purely decorative (`aria-hidden`) and
reduced-motion-safe. Turn it off in **Options → Interface** (`mascotEnabled`, on by
default; it travels with your settings backup).
- **A shared motion vocabulary** (`--dur-*` / `--ease-*` tokens in `themes.css`) and a
@@ -102,24 +123,18 @@ behavioural changes to features, just fixes and guardrails.
- Still 100% client-side: Collections live in your in-browser IndexedDB and round-trip
through the library backup; no server, no accounts, no telemetry.
-## [1.6.0] — 2026-06-13 · _Claude is API-key only_
+## [1.6.0] — 2026-06-13 · _Claude API-key fallback_
+
+> Superseded by the current Unreleased provider refresh: Claude OAuth is available again via the Claude Code / Pi flow, with Console API keys still supported.
### Removed
-- **The Claude *subscription* sign-in ("Sign in with Claude").** It never reliably
- worked, and it can't: Anthropic binds Claude Pro/Max OAuth tokens to their own
- **Claude Code** client (validated server-side via an identity system prompt + beta
- flags) and, as of 2026, its terms **prohibit using subscription authentication in
- third-party products**. The only way to make it "work" is to impersonate Claude
- Code — which is a terms violation that risks getting **the user's own Anthropic
- account banned** and breaks whenever Anthropic rotates a flag. So we took it out
- rather than ship spoofing.
+- **The earlier Claude _subscription_ sign-in ("Sign in with Claude").** That implementation was removed because it did not match the Claude Code flow closely enough to be reliable. Current builds restore Claude sign-in using the same shape Pi uses, while keeping the Console API key path as a fallback.
### Changed
-- **Claude now connects with a Console API key only** (`sk-ant-api…` from
- console.anthropic.com). The Anthropic card's *Connect* opens the key field directly;
- `callAnthropic` is a clean `x-api-key` request with no OAuth/exchange branches.
+- **Claude gained an explicit Console API key path** (`sk-ant-api…` from
+ console.anthropic.com). At the time, the Anthropic card's _Connect_ opened the key field directly and `callAnthropic` used a clean `x-api-key` request with no OAuth/exchange branches.
- Dropped the now-unused `claude.ai`, `platform.claude.com`, and
`console.anthropic.com` host permissions (kept `api.anthropic.com` for inference).
- Deleted the dead `oauth-anthropic.js` module and its callback interception.
@@ -128,7 +143,7 @@ behavioural changes to features, just fixes and guardrails.
- **This does not affect the working sign-ins.** **Grok** (Grok CLI device flow),
**OpenRouter**, and **OpenAI** (Sign in with ChatGPT, added in 1.5.0) still use
- one-click OAuth — those vendors *support* third-party OAuth. Anthropic is the one
+ one-click OAuth — those vendors _support_ third-party OAuth. Anthropic is the one
that doesn't.
- **Free is still easy:** local **Ollama** (no key) or **Gemini's** free tier.
@@ -137,8 +152,8 @@ behavioural changes to features, just fixes and guardrails.
### Added
- **Sign in with ChatGPT for OpenAI.** Connect OpenAI without pasting a key —
- RepoLens performs the **same OAuth login the Codex CLI uses**. Click *Sign in
- with ChatGPT*, approve it on OpenAI's page, and RepoLens captures the redirect
+ RepoLens performs the **same OAuth login the Codex CLI uses**. Click _Sign in
+ with ChatGPT_, approve it on OpenAI's page, and RepoLens captures the redirect
and turns it into a working OpenAI key for you, behind the scenes. This rounds
out the one-click sign-ins: **Claude** already uses the Claude Code login and
**Grok** the Grok CLI login, so the three big coding-CLI logins are now all here.
@@ -152,7 +167,7 @@ behavioural changes to features, just fixes and guardrails.
access. If it doesn't, RepoLens tells you plainly and you can paste an OpenAI
API key — or use any other provider — instead.
- Still **no spawning of a local `claude` / `codex` binary** — a browser extension
- can't launch a process. What's new is the *OAuth* those CLIs use, not the CLI.
+ can't launch a process. What's new is the _OAuth_ those CLIs use, not the CLI.
Your ChatGPT credentials never touch RepoLens; the login happens on OpenAI's site
and only tokens come back, stored in this browser and never exported.
@@ -173,17 +188,17 @@ behavioural changes to features, just fixes and guardrails.
exported with your settings.
- **Per-vendor model pickers** (with a recommended ★) plus a free-form Custom
model, and an **Advanced endpoint override** for proxies/regional gateways.
-- **Provider self-tests** — *Test connection* checks the endpoint answers;
- *Test function* asks the model to follow a tiny instruction.
+- **Provider self-tests** — _Test connection_ checks the endpoint answers;
+ _Test function_ asks the model to follow a tiny instruction.
- Compatible providers also appear in the **per-scan-part router**, and any one
- you connect becomes a valid fallback in the smart chain — so connecting *only*
+ you connect becomes a valid fallback in the smart chain — so connecting _only_
(say) DeepSeek or a local Ollama just works.
### Notes
- When you connect a custom AI address, Chrome asks you to approve that site once —
that's expected. Only secure `http(s)` addresses are accepted.
-- Local *CLI* providers (a `claude` / `codex` binary) aren't offered: a browser
+- Local _CLI_ providers (a `claude` / `codex` binary) aren't offered: a browser
extension is sandboxed and cannot launch a local process. Local **Ollama**
(an HTTP server) is supported instead.
diff --git a/README.md b/README.md
index 1520b2a..fd75b70 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,8 @@



-
-
+
+

@@ -27,17 +27,17 @@ RepoLens is a **Manifest V3 Chrome extension**. Open a GitHub, GitLab, npm, or P
A scan opens to a **verdict landing** and fans out into focused tabs:
-| | Tab | What it does |
-|---|---|---|
-| ⚖️ | **Verdict** | Fit call (strong / solid / care / risky), a one-line bottom line, measured facts, and the top things worth noting — first thing you see. |
-| 🧠 | **Deep Dive** | The core concepts → how they build on each other → a plain-English ("explain it like I'm five") walkthrough. Optionally grounded by **measured facts** from the local runner. |
-| 📚 | **Library** | Every repo you've analyzed, as a sortable / filterable triage grid with fit chips, a stats bar, **bulk multi-select delete**, and one-click **Export / Import / Backup**. |
-| 🗂️ | **Triage & decide** | Keyboard-first **Adopt / Trial / Hold / Reject**, a Tech Radar, Boards, fit-delta tracking, notes, and daily **drift alerts** when repos go stale. |
-| ★ | **Evaluate & compare** | Score repos **1–5** against your own rubric, grade docs **A–F**, and put any **2–10** side-by-side in a decision matrix (CSV / Markdown export). |
-| 🔍 | **Discover** | Search GitHub from inside the extension, or get **recommendations** from the repos you've already adopted. |
-| 🕸️ | **Connections** | A walkable map centred on the current repo, showing how it relates to the others you've scanned. |
-| 🤝 | **Synergies** · **Versus** · **Combinator** | Complements, head-to-heads, and fused project ideas — grounded in *your* library. |
-| 🗺️ | **Canvas** | Turn a repo's Deep Dive into an interactive, draggable **Blueprint** — pan/zoom the architecture map, take a narrated **Guided Tour** in dependency order (keyboard-navigable, reduced-motion safe), and export to **.excalidraw** or SVG. Switch the Library into a **Corkboard** to map your whole collection at once: every scanned repo a draggable card, related repos joined by colored string (alternatives, synergies, head-to-heads, combined ideas), colored by fit, filterable by Collection, arrangement saved. And the Tech-Stack Builder renders its wiring on the same canvas as a **Stack Studio**. |
+| | Tab | What it does |
+| --- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| ⚖️ | **Verdict** | Fit call (strong / solid / care / risky), a one-line bottom line, measured facts, and the top things worth noting — first thing you see. |
+| 🧠 | **Deep Dive** | The core concepts → how they build on each other → a plain-English ("explain it like I'm five") walkthrough. Optionally grounded by **measured facts** from the local runner. |
+| 📚 | **Library** | Every repo you've analyzed, as a sortable / filterable triage grid with fit chips, a stats bar, **bulk multi-select delete**, and one-click **Export / Import / Backup**. |
+| 🗂️ | **Triage & decide** | Keyboard-first **Adopt / Trial / Hold / Reject**, a Tech Radar, Boards, fit-delta tracking, notes, and daily **drift alerts** when repos go stale. |
+| ★ | **Evaluate & compare** | Score repos **1–5** against your own rubric, grade docs **A–F**, and put any **2–10** side-by-side in a decision matrix (CSV / Markdown export). |
+| 🔍 | **Discover** | Search GitHub from inside the extension, or get **recommendations** from the repos you've already adopted. |
+| 🕸️ | **Connections** | A walkable map centred on the current repo, showing how it relates to the others you've scanned. |
+| 🤝 | **Synergies** · **Versus** · **Combinator** | Complements, head-to-heads, and fused project ideas — grounded in _your_ library. |
+| 🗺️ | **Canvas** | Turn a repo's Deep Dive into an interactive, draggable **Blueprint** — pan/zoom the architecture map, take a narrated **Guided Tour** in dependency order (keyboard-navigable, reduced-motion safe), and export to **.excalidraw** or SVG. Switch the Library into a **Corkboard** to map your whole collection at once: every scanned repo a draggable card, related repos joined by colored string (alternatives, synergies, head-to-heads, combined ideas), colored by fit, filterable by Collection, arrangement saved. And the Tech-Stack Builder renders its wiring on the same canvas as a **Stack Studio**. |
Plus **SKTPG** (a one-tap State / Known-pitfalls / Trajectory / Proof / Growth read), framework lenses, and capability re-tagging.
@@ -49,6 +49,27 @@ Plus **SKTPG** (a one-tap State / Known-pitfalls / Trajectory / Proof / Growth r
Newest first — the highlights. Full, detailed notes live in the **[changelog](CHANGELOG.md)**.
+### Unreleased — Actionable scans, smoother loading, provider refresh
+
+- 🚀 **Instant scan feedback.** Clicking RepoLens opens the loading tab immediately, checks cache/provider setup in parallel, and shows a more playful staged scan loader.
+- 🫀 **Long-scan heartbeat.** Slow-but-alive provider calls no longer fail at a fixed 90 seconds; RepoLens waits while the background scan is still reporting progress.
+- ✨ **Smoother app feel.** Scan tabs preserve scroll, transition more softly, avoid loading-card flicker, and large Library grids scroll more cheaply.
+- 🧭 **Structured scan schema.** Scans now include a mental model, risk register, adoption simulation, and learning path, with fallbacks for old results.
+- ✅ **Decision-grade Verdict.** Scans now produce a best next action, confidence, evidence, and a 30-minute trial plan.
+- 🎨 **DESIGN.md theme pack.** Six new CSS-only looks: Command Blue, Aubergine Trace, Emerald DB, Paperline, Toybox Red, and Gradient Aurora.
+- 🫧 **Liquid Glass theme.** A CSS-only theme inspired by `liquid-dom`'s glassy WebGPU look, without adding its runtime, flags, or dependencies.
+- 🔄 **Live model lists.** Gemini, OpenRouter, and Nous load their current provider catalogs in Settings, so new models and renamed slugs show up without another hand edit.
+- 🧠 **Gemini Ultra-ready, stable by default.** The Google picker uses your API key to show the Gemini models your account can actually call, while fresh scans fall back to stable Gemini 2.5 unless you opt into newer IDs.
+- 🔓 **Sign in with Claude.** Anthropic now supports the same Claude Code / Pi OAuth flow, while still accepting a Console API key.
+- 🧭 **Safer routing and scan recovery.** Legacy saved model IDs are normalized before calls, OAuth-only providers count as connected, scans keep the MV3 worker warm, and IndexedDB blocked-upgrade hangs now surface cleanly.
+
+### v3.1.0 — Interactive Canvas
+
+- 🗺️ **Blueprint Canvas.** Turn a Deep Dive into a draggable, pannable architecture map with dependency-order Guided Tour.
+- 🧵 **Corkboard.** Switch the Library into a saved red-string board of scanned repos and their relationships.
+- 🧱 **Stack Studio.** Render Tech-Stack Builder outputs as a living wiring diagram with gaps and integrations.
+- 📤 **Exports.** Save canvas views as `.excalidraw` or SVG, with arrangements preserved in backups.
+
### v3.0.1 — Audit hardening
A correctness, security, and tooling pass from a full code audit — fixes only, no feature changes.
@@ -65,14 +86,14 @@ A correctness, security, and tooling pass from a full code audit — fixes only,
- ✨ **Subtle motion, everywhere it helps** — tactile press states, a staged tab reveal, a verdict health-bar fill, a smoother toast and modal — all respecting reduced-motion.
- 🧭 **Errors that tell you what to do** — a failed scan now offers **Open Settings** (bad key / wrong model) or **Retry** (transient), and the loading copy names the provider it's actually using.
-### v1.6.0 — Claude is API-key only
+### v1.6.0 — Claude key fallback
-- 🔑 **Removed the Claude *subscription* sign-in.** Anthropic locks Claude Pro/Max tokens to their own Claude Code app and, as of 2026, prohibits subscription sign-in in third-party tools — so that login could never work here without impersonating Claude Code (which risks getting **your** account banned). Connect Claude with a **Console API key** instead.
+- 🔑 **Console API key support for Claude.** This release made the reliable `sk-ant-api…` path explicit. Current builds also support the Claude Code / Pi OAuth flow again, so you can choose sign-in or a key.
- 🆓 **Want $0?** Use **local Ollama** (no key) or **Gemini's free tier** — both already supported. See the [How models & sign-in work](website/content/docs/how-it-works.mdx) guide.
### v1.5.0 — Sign in with ChatGPT
-- 🔓 **Connect OpenAI without a key** — *Sign in with ChatGPT* uses the **same login the Codex CLI does**: approve it on OpenAI's page and RepoLens handles the rest. Joins Claude (Claude Code login) and Grok (Grok CLI login) — the three big CLI sign-ins are now all here.
+- 🔓 **Connect OpenAI without a key** — _Sign in with ChatGPT_ uses the **same login the Codex CLI does**: approve it on OpenAI's page and RepoLens handles the rest. Joins Claude (Claude Code login) and Grok (Grok CLI login) — the three big CLI sign-ins are now all here.
- ℹ️ **Needs API access on your ChatGPT plan** to mint the key. If it's not included, RepoLens says so and you can paste an API key instead. Your ChatGPT login stays on OpenAI's site — only tokens come back, and they never leave this browser.
### v1.4.0 — Bring any model
@@ -82,16 +103,16 @@ Use almost any AI provider, not just the built-in five.
- ➕ **20+ providers built in** — OpenAI, DeepSeek, Groq, NVIDIA NIM, Kimi, Zhipu GLM, Qwen, MiniMax, Azure OpenAI, and more.
- 🖥️ **Run the AI locally** — use **Ollama** on your own machine, with **no key at all** (only the AI step is local; RepoLens still reads the repo page online).
- 🔌 **Any service** — a **Custom** option connects almost any other AI provider: paste the address it gives you, pick the format, done.
-- ✅ **One-click tests** — *Test connection* and *Test function* tell you a provider really works before you rely on it.
+- ✅ **One-click tests** — _Test connection_ and _Test function_ tell you a provider really works before you rely on it.
- 🔑 Each provider keeps its **own key**, stored only in your browser — switching never loses your other setups.
### v1.3.0 — Bulk cleanup
-- 🗂️ **Select multiple repos** in the Library and delete them in one confirmed action (or *Select all*). **Esc** to back out.
+- 🗂️ **Select multiple repos** in the Library and delete them in one confirmed action (or _Select all_). **Esc** to back out.
-### v1.2.0 — 13 themes, done right
+### v1.2.0 — Themes, done right
-- 🎨 **5 new themes** — Nord, Gruvbox, Rosé Pine, Catppuccin Latte, Solarized Light (13 in all).
+- 🎨 **5 new themes** — Nord, Gruvbox, Rosé Pine, Catppuccin Latte, Solarized Light. Current builds include even more, including Mono Ink and Liquid Glass.
- 💡 **Light themes fixed** — no more dark patches bleeding through; every theme now reads crisp and clear, light or dark.
### v1.1.0 — Trust & polish
@@ -108,30 +129,30 @@ Use almost any AI provider, not just the built-in five.
toolbar click
→ fetch repo metadata + README (GitHub / GitLab / npm / PyPI)
→ AI provider of your choice (with smart fallback)
- → structured analysis (verdict, pros/cons, health, eli5, …)
+ → structured analysis (verdict, evidence, risk register, learning path, …)
→ saved to your in-browser library
→ rendered as a verdict-first tab
```
-No accounts. No backend. Your keys, your machine.
+No RepoLens account. No backend. Your keys, your machine.
---
## Models — your keys, your call
-Bring your own provider. Five are **first-class** (one-click sign-in where the vendor allows it: **Grok**, **OpenRouter**, and **OpenAI/ChatGPT**; otherwise an API key; **Claude** is API-key only) and fan out across a **smart fallback chain**: RepoLens tries them in order and drops to the next if one errors, so a single key is enough to start.
+Bring your own provider. Five are **first-class**: **Claude** (Claude Code / Pi OAuth or Console API key), **Grok** (OAuth or key), **OpenRouter** (OAuth), **Gemini** (API key with live model list), and **Nous** (API key with live model list). They fan out across a **smart fallback chain**: RepoLens tries them in order and drops to the next if one errors, so a single key is enough to start.
**Nous** (Nous Research) **→ Gemini → OpenRouter → Grok → Anthropic**
-On top of those, RepoLens works with **almost any other AI service** through one registry: **OpenAI, DeepSeek, Groq, NVIDIA NIM, Kimi (Moonshot), Zhipu GLM, Qwen (Aliyun), Xiaomi MiMo, Volcengine Ark, Ollama Cloud, MiniMax, Azure OpenAI**, local **Ollama** (no key needed), and a universal **Custom** endpoint. Each keeps its **own key** (switching never loses data), has a model picker, an optional **endpoint override**, and built-in **connection / function self-tests**. Connect just one and it works. It joins the fallback chain automatically.
+On top of those, RepoLens works with **almost any other AI service** through one registry: **OpenAI, DeepSeek, Groq, NVIDIA NIM, Kimi (Moonshot), Zhipu GLM, Qwen (Aliyun), Xiaomi MiMo, Volcengine Ark, Ollama Cloud, MiniMax, Azure OpenAI**, local **Ollama** (no key needed), and a universal **Custom** endpoint. Each keeps its **own key** (switching never loses data), has a model picker, an optional **endpoint override**, and built-in **connection / function self-tests**. Gemini, OpenRouter, and Nous refresh their model pickers from the provider APIs, and every picker still has **Custom…** for a brand-new model ID. Connect just one and it works. It joins the fallback chain automatically.
-> **Sign in with ChatGPT.** The OpenAI card also offers a one-click **ChatGPT login**, the same OAuth the **Codex CLI** uses, so you can connect without pasting a key (it needs API access on your ChatGPT plan; otherwise paste a key).
+> **CLI-style sign-ins.** Anthropic uses the same Claude Code / Pi OAuth flow, OpenAI offers the Codex-style **Sign in with ChatGPT**, and xAI uses the Grok device flow. If a plan does not expose API access through OAuth, paste that provider's API key instead.
-> Local-only? Point at **Ollama** on `localhost`. No key, no cloud. (Spawning a local *CLI* binary like `claude`/`codex` still isn't possible: a browser extension is sandboxed and can't launch a program. But it can do those CLIs' **OAuth logins**, and talk to a local HTTP model server like Ollama.)
+> Local-only? Point at **Ollama** on `localhost`. No key, no cloud. (Spawning a local _CLI_ binary like `claude`/`codex` still isn't possible: a browser extension is sandboxed and can't launch a program. But it can do those CLIs' **OAuth logins**, and talk to a local HTTP model server like Ollama.)
Each provider has a model dropdown (★ marks the recommended pick), and you can **route each part of a scan to a different model**:
-> Core scan → *Claude Opus 4.8* for the deep judgment. Re-tag → a cheap, fast model. Deep Dive → whatever you like.
+> Core scan → _Claude Opus 4.8_ for the deep judgment. Re-tag → a cheap, fast model. Deep Dive → whatever you like.
Any per-part pick still falls back to the full chain if that provider errors or isn't connected, so nothing can dead-end. Set it all in **Options → More model providers** and **Models per scan part**.
@@ -141,7 +162,7 @@ Any per-part pick still falls back to the full chain if that provider errors or
Your whole library lives **in the browser** (IndexedDB). No database, no daemon, no setup. It works the moment you load the extension, and it's Web-Store-ready.
-Because it's *your* data, you can take it with you: **Library → Export** writes your whole library (analyzed repos, the semantic graph, and the local scan cache) to one portable JSON file, and **Import** restores it (merge or replace) on any machine. Backups are validated and bounded on import, so a bad file fails safe. Your settings travel too: **Options → Back up your settings** exports your theme, voice, model picks and per-part routing, but never your API keys.
+Because it's _your_ data, you can take it with you: **Library → Export** writes your whole library (analyzed repos, the semantic graph, and the local scan cache) to one portable JSON file, and **Import** restores it (merge or replace) on any machine. Backups are validated and bounded on import, so a bad file fails safe. Your settings travel too: **Options → Back up your settings** exports your theme, voice, model picks and per-part routing, but never your API keys.
Migrating from an old VelesDB server? **Options → Import from VelesDB** pulls your library across in one click.
@@ -159,7 +180,7 @@ Then click the RepoLens icon on any repo page.
## Develop
-> For contributors — if you just want to *use* RepoLens, you're done after **Install** above.
+> For contributors — if you just want to _use_ RepoLens, you're done after **Install** above.
```bash
npm install # installs vitest + lint/format tooling
@@ -176,7 +197,7 @@ CI (`.github/workflows/ci.yml`) runs the suite on every push and PR. Pure ES mod
## Optional: the deeper-scan runner
-For Deep Dive grounded in *measured* facts (real file counts, languages, dependency graph, license, architecture, tests/CI, secret scan), run the companion **Rust** daemon — it downloads a repo's source and analyzes it statically (it never executes repo code). Requires [Rust](https://rustup.rs); from the runner directory:
+For Deep Dive grounded in _measured_ facts (real file counts, languages, dependency graph, license, architecture, tests/CI, secret scan), run the companion **Rust** daemon — it downloads a repo's source and analyzes it statically (it never executes repo code). Requires [Rust](https://rustup.rs); from the runner directory:
```bash
cargo run --release -- serve # listens on localhost:9191
@@ -188,25 +209,25 @@ The extension auto-detects it and the Deep Dive pill turns green. Without it, De
## Layout
-| Path | Responsibility |
-|------|----------------|
-| `background.js` | Service worker: scan orchestration, AI provider calls + per-part routing, store writes |
-| `output-tab.{js,html}` | The result surface — verdict landing + every tab |
-| `library.{js,html}` · `library-data.js` | The Library home + its pure row/sort/filter helpers |
-| `store.js` · `store/` | In-browser persistence (IndexedDB doc store, client-side search ranker, ego-graph builder) |
-| `routing.js` · `models.js` | Per-part model routing + the provider × model catalog |
+| Path | Responsibility |
+| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `background.js` | Service worker: scan orchestration, AI provider calls + per-part routing, store writes |
+| `output-tab.{js,html}` | The result surface — verdict landing + every tab |
+| `library.{js,html}` · `library-data.js` | The Library home + its pure row/sort/filter helpers |
+| `store.js` · `store/` | In-browser persistence (IndexedDB doc store, client-side search ranker, ego-graph builder) |
+| `routing.js` · `models.js` | Per-part model routing + the provider × model catalog |
| `providers.js` · `options-providers.js` | OpenAI/Anthropic-compatible provider registry + the data-driven Settings cards (keys, models, endpoint override, self-tests) |
-| `migrate/velesdb-import.js` | One-time import from a legacy VelesDB server |
-| `runner.js` | Client for the optional Rust deeper-scan runner |
-| `backup.js` · `store.js` · `cache.js` | Library Export / Import / Backup — versioned envelope, validated + bounded on restore |
-| `safe-html.js` | One canonical HTML escaper + an injection-safe `html\`\`` template (replaces the old per-file `esc()` copies) |
-| `errors.js` · `retry.js` | Provider-error ranking (surface the one fixable failure) + exponential-backoff retries |
-| `tests/` | Vitest unit tests for the pure helpers |
+| `migrate/velesdb-import.js` | One-time import from a legacy VelesDB server |
+| `runner.js` | Client for the optional Rust deeper-scan runner |
+| `backup.js` · `store.js` · `cache.js` | Library Export / Import / Backup — versioned envelope, validated + bounded on restore |
+| `safe-html.js` | One canonical HTML escaper + an injection-safe `html\`\``template (replaces the old per-file`esc()` copies) |
+| `errors.js` · `retry.js` | Provider-error ranking (surface the one fixable failure) + exponential-backoff retries |
+| `tests/` | Vitest unit tests for the pure helpers |
---
-*Built for people who read code before they trust it.*
+_Built for people who read code before they trust it._
diff --git a/ask-library.js b/ask-library.js
index 9cca820..25e3062 100644
--- a/ask-library.js
+++ b/ask-library.js
@@ -16,17 +16,19 @@ function truncate(s, max) {
export function buildAskPrompt(question, docs) {
if (!question || !Array.isArray(docs) || !docs.length) return '';
- const corpus = docs.map((d) => {
- const lines = [`--- ${d.repoId || 'unknown'} ---`];
- if (d.description) lines.push(`Description: ${truncate(d.description, 120)}`);
- if (d.category) lines.push(`Category: ${d.category}`);
- const caps = Array.isArray(d.capabilities) && d.capabilities.length ? d.capabilities.join(', ') : null;
- if (caps) lines.push(`Capabilities: ${caps}`);
- if (d.health) lines.push(`Health: ${d.health}/100`);
- if (d.decision) lines.push(`Decision: ${d.decision}`);
- if (d.eli5) lines.push(`Summary: ${truncate(d.eli5, MAX_ELI5)}`);
- return lines.join('\n');
- }).join('\n\n');
+ const corpus = docs
+ .map((d) => {
+ const lines = [`--- ${d.repoId || 'unknown'} ---`];
+ if (d.description) lines.push(`Description: ${truncate(d.description, 120)}`);
+ if (d.category) lines.push(`Category: ${d.category}`);
+ const caps = Array.isArray(d.capabilities) && d.capabilities.length ? d.capabilities.join(', ') : null;
+ if (caps) lines.push(`Capabilities: ${caps}`);
+ if (d.health) lines.push(`Health: ${d.health}/100`);
+ if (d.decision) lines.push(`Decision: ${d.decision}`);
+ if (d.eli5) lines.push(`Summary: ${truncate(d.eli5, MAX_ELI5)}`);
+ return lines.join('\n');
+ })
+ .join('\n\n');
return [
"You are RepoLens, a developer assistant. Answer the question below using ONLY the repositories listed here — these are from the user's own analyzed library. Cite repo names in your answer. Keep it to 2–4 sentences unless the question clearly needs more. If none of these repos address the question, say so briefly.",
@@ -49,20 +51,22 @@ export function parseAskAnswer(text) {
export function buildFilterPrompt(question, docs) {
if (!question || !Array.isArray(docs) || !docs.length) return '';
- const corpus = docs.map((d) => {
- const lines = [`--- ${d.repoId || 'unknown'} ---`];
- if (d.description) lines.push(`Description: ${truncate(d.description, 120)}`);
- if (d.category) lines.push(`Category: ${d.category}`);
- const caps = Array.isArray(d.capabilities) && d.capabilities.length ? d.capabilities.join(', ') : null;
- if (caps) lines.push(`Capabilities: ${caps}`);
- if (d.health) lines.push(`Health: ${d.health}/100`);
- if (d.decision) lines.push(`Decision: ${d.decision}`);
- if (d.eli5) lines.push(`Summary: ${truncate(d.eli5, MAX_ELI5)}`);
- return lines.join('\n');
- }).join('\n\n');
+ const corpus = docs
+ .map((d) => {
+ const lines = [`--- ${d.repoId || 'unknown'} ---`];
+ if (d.description) lines.push(`Description: ${truncate(d.description, 120)}`);
+ if (d.category) lines.push(`Category: ${d.category}`);
+ const caps = Array.isArray(d.capabilities) && d.capabilities.length ? d.capabilities.join(', ') : null;
+ if (caps) lines.push(`Capabilities: ${caps}`);
+ if (d.health) lines.push(`Health: ${d.health}/100`);
+ if (d.decision) lines.push(`Decision: ${d.decision}`);
+ if (d.eli5) lines.push(`Summary: ${truncate(d.eli5, MAX_ELI5)}`);
+ return lines.join('\n');
+ })
+ .join('\n\n');
return [
- 'You are RepoLens filtering a user\'s repository library.',
+ "You are RepoLens filtering a user's repository library.",
`The user wants to find: "${question}"`,
'',
'Return a JSON array of repoId strings (from the list below) that best match the request, sorted by relevance (most relevant first). Include only repos that clearly match. Return [] if none match. Return ONLY valid JSON — no prose, no markdown, no explanation.',
@@ -76,7 +80,10 @@ export function buildFilterPrompt(question, docs) {
* Returns [] on any parsing failure.
*/
export function parseFilterResult(text) {
- const s = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
+ const s = String(text || '')
+ .trim()
+ .replace(/^```(?:json)?\s*/i, '')
+ .replace(/\s*```$/, '');
try {
const arr = JSON.parse(s);
if (!Array.isArray(arr)) return [];
diff --git a/ask-repo.js b/ask-repo.js
index 9afdc87..a1699cc 100644
--- a/ask-repo.js
+++ b/ask-repo.js
@@ -19,7 +19,9 @@ export function buildAskRepoPrompt(question, analysis, history = []) {
if (!question || !analysis?.repoId) return '';
const a = analysis;
- const flagTexts = (a.red_flags || []).map((f) => (typeof f === 'string' ? f : f?.text || '')).filter(Boolean);
+ const flagTexts = (a.red_flags || [])
+ .map((f) => (typeof f === 'string' ? f : f?.text || ''))
+ .filter(Boolean);
const lines = [
`You are RepoLens. Answer the question about **${a.repoId}** using ONLY the analysis data below. Be specific and cite details. 2–4 sentences unless the question clearly needs more. If the data does not contain enough information to answer, say so.`,
@@ -32,14 +34,25 @@ export function buildAskRepoPrompt(question, analysis, history = []) {
a.category ? `Category: ${a.category}` : '',
a.eli5 ? `\nSummary: ${trunc(a.eli5, MAX_SECTION)}` : '',
a.technical ? `\nTechnical: ${trunc(a.technical, MAX_SECTION)}` : '',
- Array.isArray(a.use_cases) && a.use_cases.length ? `\nUse cases: ${a.use_cases.slice(0, 5).join('; ')}` : '',
+ Array.isArray(a.use_cases) && a.use_cases.length
+ ? `\nUse cases: ${a.use_cases.slice(0, 5).join('; ')}`
+ : '',
Array.isArray(a.pros) && a.pros.length ? `\nPros: ${a.pros.join('; ')}` : '',
Array.isArray(a.cons) && a.cons.length ? `\nCons: ${a.cons.join('; ')}` : '',
flagTexts.length ? `\nRed flags: ${flagTexts.slice(0, 5).join('; ')}` : '',
- Array.isArray(a.capabilities) && a.capabilities.length ? `\nCapabilities: ${a.capabilities.join(', ')}` : '',
+ Array.isArray(a.capabilities) && a.capabilities.length
+ ? `\nCapabilities: ${a.capabilities.join(', ')}`
+ : '',
a.health?.score ? `\nHealth score: ${a.health.score}/100` : '',
- a.alternatives?.length ? `\nAlternatives: ${a.alternatives.slice(0, 4).map((x) => x.name || x).join(', ')}` : '',
- ].filter(Boolean).join('\n');
+ a.alternatives?.length
+ ? `\nAlternatives: ${a.alternatives
+ .slice(0, 4)
+ .map((x) => x.name || x)
+ .join(', ')}`
+ : '',
+ ]
+ .filter(Boolean)
+ .join('\n');
const historySection = history.length
? `\n\n## Prior conversation\n${history.map((h) => `Q: ${h.question}\nA: ${trunc(h.answer, 200)}`).join('\n\n')}`
diff --git a/background.js b/background.js
index 8251008..85a2c22 100644
--- a/background.js
+++ b/background.js
@@ -2,7 +2,16 @@ import { detectPlatform } from './url-detector.js';
import { fetchRepoData } from './fetcher.js';
import { buildPrompt } from './prompt.js';
import { parseClaudeResponse } from './parser.js';
-import { saveAnalysis, searchLibrary, upsertNode, addEdge, scrollLibrary, scrollPoints, saveRepo } from './store.js';
+import {
+ saveAnalysis,
+ searchLibrary,
+ upsertNode,
+ addEdge,
+ scrollLibrary,
+ scrollPoints,
+ saveRepo,
+ setConcepts,
+} from './store.js';
import { buildAttemptPlan } from './routing.js';
import {
COMPAT_PROVIDERS,
@@ -17,6 +26,9 @@ import {
parseOpenAiText,
parseAnthropicText,
compatStorageKeys,
+ embeddingsBody,
+ parseEmbeddings,
+ pickEmbeddingsProvider,
} from './providers.js';
import { withRetry } from './retry.js';
import { categorizeError, rankErrors } from './errors.js';
@@ -28,6 +40,13 @@ import { deriveFit } from './verdict.js';
import { combineCandidates } from './combinator.js';
import { buildCombinatorPrompt, parseCombinator } from './combinator-prompt.js';
import { refreshXaiToken, XAI_CHAT_PROXY } from './oauth-xai.js';
+import {
+ ANTHROPIC_ACCESS_KEY,
+ ANTHROPIC_EXPIRY_KEY,
+ ANTHROPIC_REFRESH_KEY,
+ clearAnthropicOAuthTokens,
+ refreshAnthropicAccessToken,
+} from './oauth-anthropic.js';
import {
OPENAI_OAUTH_ERROR_KEY,
OPENAI_OAUTH_STATE_KEY,
@@ -41,9 +60,12 @@ import {
} from './oauth-openai.js';
import {
fetchSource,
- buildAtomsPrompt, parseAtoms,
- buildLineagePrompt, parseLineage,
- buildFeynmanPrompt, parseFeynman,
+ buildAtomsPrompt,
+ parseAtoms,
+ buildLineagePrompt,
+ parseLineage,
+ buildFeynmanPrompt,
+ parseFeynman,
} from './deepdive.js';
import { scanRepo } from './runner.js';
import { buildSystemsPrompt, parseSystems, isFramework } from './systems.js';
@@ -99,8 +121,16 @@ async function linkRepos({ source, sourcePayload, targetKey, targetPayload, labe
const tgt = nodeIdFor(targetKey);
await upsertNode(src, sourcePayload);
await upsertNode(tgt, targetPayload);
- await addEdge({ id: edgeIdFor(src, label, tgt), source: src, target: tgt, label, properties: properties || {} });
- } catch { /* best-effort: additive graph, write error = skip */ }
+ await addEdge({
+ id: edgeIdFor(src, label, tgt),
+ source: src,
+ target: tgt,
+ label,
+ properties: properties || {},
+ });
+ } catch {
+ /* best-effort: additive graph, write error = skip */
+ }
}
// Pin a generated combo as a first-class IDEA node + COMBINES edges (best-effort, non-fatal).
@@ -108,14 +138,28 @@ async function pinIdea({ title, pitch, sources = [], novelty = 0, feasibility =
try {
const ideaId = ideaIdFor(sources);
await upsertNode(ideaId, {
- kind: 'idea', title: title || '', pitch: pitch || '', sources,
- novelty: Number(novelty) || 0, feasibility: Number(feasibility) || 0, analyzed: false, created: createdIso || '',
+ kind: 'idea',
+ title: title || '',
+ pitch: pitch || '',
+ sources,
+ novelty: Number(novelty) || 0,
+ feasibility: Number(feasibility) || 0,
+ analyzed: false,
+ created: createdIso || '',
});
for (const src of sources) {
const srcId = nodeIdFor(src);
- await addEdge({ id: edgeIdFor(srcId, 'COMBINES', ideaId), source: srcId, target: ideaId, label: 'COMBINES', properties: { title: title || '' } });
+ await addEdge({
+ id: edgeIdFor(srcId, 'COMBINES', ideaId),
+ source: srcId,
+ target: ideaId,
+ label: 'COMBINES',
+ properties: { title: title || '' },
+ });
}
- } catch { /* best-effort: ontology is additive, write error = skip */ }
+ } catch {
+ /* best-effort: ontology is additive, write error = skip */
+ }
}
// Build the analyzed-repo node payload from a parsed scan (shared by every write site).
@@ -153,11 +197,46 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
try {
const points = await scrollPoints({ limit: 2000 });
const STALE_MS = 14 * 24 * 60 * 60 * 1000;
- const staleCount = points.filter(p => p.payload?.saved_at && (Date.now() - Date.parse(p.payload.saved_at)) > STALE_MS).length;
+ const staleCount = points.filter(
+ (p) => p.payload?.saved_at && Date.now() - Date.parse(p.payload.saved_at) > STALE_MS
+ ).length;
await chrome.storage.local.set({ repolens_drift: { staleCount, checkedAt: new Date().toISOString() } });
- } catch { /* offline or IDB unavailable */ }
+ } catch {
+ /* offline or IDB unavailable */
+ }
});
+const PROVIDER_GATE_KEYS = [
+ 'anthropicKey',
+ ANTHROPIC_ACCESS_KEY,
+ ANTHROPIC_REFRESH_KEY,
+ 'googleKey',
+ 'openrouterKey',
+ 'xaiKey',
+ 'xaiRefresh',
+ 'nousKey',
+ OPENAI_CREDENTIALS_KEY,
+ ...compatStorageKeys(),
+];
+
+function hasConnectedScanProvider(keys) {
+ const firstClass =
+ keys.anthropicKey ||
+ keys[ANTHROPIC_ACCESS_KEY] ||
+ keys[ANTHROPIC_REFRESH_KEY] ||
+ keys.googleKey ||
+ keys.openrouterKey ||
+ keys.xaiKey ||
+ keys.xaiRefresh ||
+ keys.nousKey ||
+ keys[OPENAI_CREDENTIALS_KEY]?.refresh_token;
+ return Boolean(firstClass || COMPAT_PROVIDERS.some((p) => isCompatConnected(p.id, keys)));
+}
+
+function initialScanState(detected, statusMsg = 'Opening the lens…') {
+ return { loading: true, status: 'preflight', statusMsg, startedAt: Date.now(), ...detected };
+}
+
// Scan a link right-clicked anywhere — detect platform from the href, open output tab.
chrome.contextMenus.onClicked.addListener(async (info) => {
if (info.menuItemId !== 'repolens-scan-link') return;
@@ -165,30 +244,48 @@ chrome.contextMenus.onClicked.addListener(async (info) => {
const detected = detectPlatform(url);
if (!detected) {
const sessionKey = SESSION_KEY_PREFIX + crypto.randomUUID();
- await chrome.storage.session.set({ [sessionKey]: { loading: false, error: `Not a supported repo URL: ${url}` } });
+ await chrome.storage.session.set({
+ [sessionKey]: { loading: false, error: `Not a supported repo URL: ${url}` },
+ });
chrome.tabs.create({ url: chrome.runtime.getURL(`output-tab.html?key=${sessionKey}`) });
return;
}
- const gateKeys = await chrome.storage.local.get(['anthropicKey', 'googleKey', 'openrouterKey', 'xaiKey', 'nousKey', ...compatStorageKeys()]);
- const hasKey = gateKeys.anthropicKey || gateKeys.googleKey || gateKeys.openrouterKey || gateKeys.xaiKey || gateKeys.nousKey || compatStorageKeys().some(k => gateKeys[k]);
const sessionKey = SESSION_KEY_PREFIX + crypto.randomUUID();
- if (!hasKey) {
- await chrome.storage.session.set({ [sessionKey]: { loading: false, error: 'No AI provider configured — open Settings to add a key.', errorKind: 'none' } });
- chrome.tabs.create({ url: chrome.runtime.getURL(`output-tab.html?key=${sessionKey}`) });
+ await chrome.storage.session.set({ [sessionKey]: initialScanState(detected, 'Checking scan setup…') });
+ chrome.tabs.create({ url: chrome.runtime.getURL(`output-tab.html?key=${sessionKey}`) });
+
+ const gateKeys = await chrome.storage.local.get(PROVIDER_GATE_KEYS);
+ if (!hasConnectedScanProvider(gateKeys)) {
+ await chrome.storage.session.set({
+ [sessionKey]: {
+ loading: false,
+ error: 'No AI provider configured — open Settings to add a key.',
+ errorKind: 'none',
+ },
+ });
return;
}
- await chrome.storage.session.set({ [sessionKey]: { loading: true, status: 'fetching', ...detected } });
- chrome.tabs.create({ url: chrome.runtime.getURL(`output-tab.html?key=${sessionKey}`) });
+ await chrome.storage.session.set({ [sessionKey]: initialScanState(detected, 'Starting fresh scan…') });
runAnalysis(sessionKey, detected);
});
// ─── Listen for content script + output-tab signals ──────────────────────────
+// Output tabs keep this port warm while a scan is loading. MV3 service workers can
+// otherwise be suspended during a long provider call, leaving the session entry in
+// `{ loading: true }` forever. A tiny ping every few seconds resets the idle timer.
+chrome.runtime.onConnect.addListener((port) => {
+ if (port.name !== 'repolens-scan-keepalive') return;
+ port.onMessage.addListener(() => {
+ /* ping only */
+ });
+});
+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'REPO_PAGE' && sender.tab?.id) {
chrome.action.setIcon({
tabId: sender.tab.id,
- path: { 16: 'icons/icon16.png', 48: 'icons/icon48.png', 128: 'icons/icon128.png' }
+ path: { 16: 'icons/icon16.png', 48: 'icons/icon48.png', 128: 'icons/icon128.png' },
});
return;
}
@@ -197,7 +294,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'RERUN' && msg.sessionKey && msg.platform && msg.repoId) {
const detected = { platform: msg.platform, repoId: msg.repoId };
chrome.storage.session
- .set({ [msg.sessionKey]: { loading: true, status: 'fetching', ...detected } })
+ .set({ [msg.sessionKey]: initialScanState(detected, 'Restarting fresh scan…') })
.then(() => {
sendResponse({ ok: true });
runAnalysis(msg.sessionKey, detected, sender.tab?.id); // fire and forget; tab polls the session
@@ -214,17 +311,47 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
// Framework lenses from the output tab — accept one or many frameworks; run sequentially.
- if (msg.type === 'SYSTEMS' && msg.sessionKey && msg.platform && msg.repoId && Array.isArray(msg.frameworks)) {
+ if (
+ msg.type === 'SYSTEMS' &&
+ msg.sessionKey &&
+ msg.platform &&
+ msg.repoId &&
+ Array.isArray(msg.frameworks)
+ ) {
const fws = msg.frameworks.filter(isFramework);
- if (fws.length) { sendResponse({ ok: true }); runFrameworkLens(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, fws, SYSTEMS_LENS); return true; }
+ if (fws.length) {
+ sendResponse({ ok: true });
+ runFrameworkLens(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, fws, SYSTEMS_LENS);
+ return true;
+ }
}
- if (msg.type === 'IDEATE' && msg.sessionKey && msg.platform && msg.repoId && Array.isArray(msg.frameworks)) {
+ if (
+ msg.type === 'IDEATE' &&
+ msg.sessionKey &&
+ msg.platform &&
+ msg.repoId &&
+ Array.isArray(msg.frameworks)
+ ) {
const fws = msg.frameworks.filter(isIdeateFramework);
- if (fws.length) { sendResponse({ ok: true }); runFrameworkLens(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, fws, IDEATE_LENS); return true; }
+ if (fws.length) {
+ sendResponse({ ok: true });
+ runFrameworkLens(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, fws, IDEATE_LENS);
+ return true;
+ }
}
- if (msg.type === 'PRIORITIZE' && msg.sessionKey && msg.platform && msg.repoId && Array.isArray(msg.frameworks)) {
+ if (
+ msg.type === 'PRIORITIZE' &&
+ msg.sessionKey &&
+ msg.platform &&
+ msg.repoId &&
+ Array.isArray(msg.frameworks)
+ ) {
const fws = msg.frameworks.filter(isHeuristicFramework);
- if (fws.length) { sendResponse({ ok: true }); runFrameworkLens(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, fws, PRIORITIZE_LENS); return true; }
+ if (fws.length) {
+ sendResponse({ ok: true });
+ runFrameworkLens(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, fws, PRIORITIZE_LENS);
+ return true;
+ }
}
// SKTPG directional-intelligence skill from the output tab — one-tap, one run.
@@ -256,13 +383,16 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
if (msg.type === 'COMBINATOR' && msg.sessionKey && msg.platform && msg.repoId) {
sendResponse({ ok: true });
- runCombinator(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, { mode: msg.mode || 'repo', wildness: Number(msg.wildness) || 0 });
+ runCombinator(
+ msg.sessionKey,
+ { platform: msg.platform, repoId: msg.repoId },
+ { mode: msg.mode || 'repo', wildness: Number(msg.wildness) || 0 }
+ );
return true;
}
if (msg.type === 'PIN_IDEA' && msg.sessionKey && msg.idea && Array.isArray(msg.idea.sources)) {
sendResponse({ ok: true });
(async () => {
- const cur = (await chrome.storage.session.get(msg.sessionKey))[msg.sessionKey] || {};
await pinIdea({ ...msg.idea, createdIso: new Date().toISOString() });
})();
return true;
@@ -309,7 +439,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
const getSession = async () => (await chrome.storage.session.get(msg.sessionKey))[msg.sessionKey] || {};
const setAsk = async (patch) => {
const cur = await getSession();
- await chrome.storage.session.set({ [msg.sessionKey]: { ...cur, askRepo: { ...(cur.askRepo || {}), ...patch } } });
+ await chrome.storage.session.set({
+ [msg.sessionKey]: { ...cur, askRepo: { ...(cur.askRepo || {}), ...patch } },
+ });
};
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
@@ -320,12 +452,22 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
try {
const persisted = await chrome.storage.local.get(`repolens_ask_${cur.repoId}`);
sessionHistory = persisted[`repolens_ask_${cur.repoId}`] || [];
- } catch (_) {}
+ } catch {}
}
const history = sessionHistory.slice(-4); // keep last 4 completed pairs for AI context
await setAsk({ pending: { status: 'thinking', question: msg.question }, history });
const prompt = buildAskRepoPrompt(msg.question, cur, history);
- if (!prompt) { await setAsk({ pending: { status: 'error', question: msg.question, error: 'Not enough context — try re-scanning first.' }, history }); return; }
+ if (!prompt) {
+ await setAsk({
+ pending: {
+ status: 'error',
+ question: msg.question,
+ error: 'Not enough context — try re-scanning first.',
+ },
+ history,
+ });
+ return;
+ }
const text = await callAI(keys, prompt, 'ask');
const answer = parseAskRepoAnswer(text);
const updated = [...history, { question: msg.question, answer }].slice(-5);
@@ -336,7 +478,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
} catch (e) {
const cur = await getSession();
const history = cur.askRepo?.history || [];
- await setAsk({ pending: { status: 'error', question: msg.question, error: e?.message || 'Ask failed' }, history });
+ await setAsk({
+ pending: { status: 'error', question: msg.question, error: e?.message || 'Ask failed' },
+ history,
+ });
}
})();
return true;
@@ -348,7 +493,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const prompt = buildAskRepoPrompt(msg.question, msg.analysis);
- if (!prompt) { sendResponse({ ok: false, error: 'Not enough context.' }); return; }
+ if (!prompt) {
+ sendResponse({ ok: false, error: 'Not enough context.' });
+ return;
+ }
const text = await callAI(keys, prompt, 'ask');
sendResponse({ ok: true, answer: parseAskRepoAnswer(text) });
} catch (e) {
@@ -364,10 +512,16 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const prompt = buildComparePrompt(msg.a, msg.b);
- if (!prompt) { sendResponse({ ok: false, error: 'Not enough context to compare.' }); return; }
+ if (!prompt) {
+ sendResponse({ ok: false, error: 'Not enough context to compare.' });
+ return;
+ }
const text = await callAI(keys, prompt, 'ask');
const result = parseCompareResult(text);
- if (!result) { sendResponse({ ok: false, error: 'Could not parse comparison result.' }); return; }
+ if (!result) {
+ sendResponse({ ok: false, error: 'Could not parse comparison result.' });
+ return;
+ }
sendResponse({ ok: true, result });
} catch (e) {
sendResponse({ ok: false, error: e?.message || 'Comparison failed' });
@@ -382,7 +536,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const prompt = buildFilterPrompt(msg.question, msg.docs);
- if (!prompt) { sendResponse({ ok: false, error: 'No question or context provided.' }); return; }
+ if (!prompt) {
+ sendResponse({ ok: false, error: 'No question or context provided.' });
+ return;
+ }
const text = await callAI(keys, prompt, 'ask');
const ids = parseFilterResult(text);
sendResponse({ ok: true, ids });
@@ -399,7 +556,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const prompt = buildAskPrompt(msg.question, msg.docs);
- if (!prompt) { sendResponse({ ok: false, error: 'No question or context provided.' }); return; }
+ if (!prompt) {
+ sendResponse({ ok: false, error: 'No question or context provided.' });
+ return;
+ }
const text = await callAI(keys, prompt, 'ask');
sendResponse({ ok: true, answer: parseAskAnswer(text) });
} catch (e) {
@@ -428,7 +588,10 @@ function resolveCompetitor(input) {
const s = (input || '').trim();
const detected = detectPlatform(s); // handles full GitHub/GitLab/npm/PyPI URLs
if (detected) return detected;
- const repoId = s.replace(/^https?:\/\/(www\.)?github\.com\//i, '').replace(/\.git$/, '').replace(/^\/+|\/+$/g, '');
+ const repoId = s
+ .replace(/^https?:\/\/(www\.)?github\.com\//i, '')
+ .replace(/\.git$/, '')
+ .replace(/^\/+|\/+$/g, '');
return { platform: 'github', repoId };
}
@@ -459,10 +622,7 @@ async function handleOpenAIOAuthCallback(rawUrl, tabId) {
const errorDesc = url.searchParams.get('error_description');
const cleanupFlowMarkers = async () => {
- await chrome.storage.local.remove([
- OPENAI_OAUTH_VERIFIER_KEY,
- OPENAI_OAUTH_STATE_KEY,
- ]).catch(() => {});
+ await chrome.storage.local.remove([OPENAI_OAUTH_VERIFIER_KEY, OPENAI_OAUTH_STATE_KEY]).catch(() => {});
};
if (error) {
@@ -525,37 +685,51 @@ chrome.action.onClicked.addListener(async (tab) => {
const detected = detectPlatform(tab.url);
if (!detected) {
const sessionKey = SESSION_KEY_PREFIX + crypto.randomUUID();
- await chrome.storage.session.set({ [sessionKey]: { loading: false, error: 'Not a supported page. Navigate to a GitHub, GitLab, npm, or PyPI repo page and click the icon there.' } });
+ await chrome.storage.session.set({
+ [sessionKey]: {
+ loading: false,
+ error:
+ 'Not a supported page. Navigate to a GitHub, GitLab, npm, or PyPI repo page and click the icon there.',
+ },
+ });
chrome.tabs.create({ url: `output-tab.html?key=${sessionKey}` });
return;
}
const sessionKey = SESSION_KEY_PREFIX + crypto.randomUUID();
- // Cache hit → show the saved analysis instantly (no AI call, works offline).
+ // Always open feedback first. Cache/provider checks then race in the background,
+ // so the first icon press feels consistent even when storage is warming up.
+ await chrome.storage.session.set({ [sessionKey]: initialScanState(detected) });
+ await chrome.tabs.create({ url: `output-tab.html?key=${sessionKey}` });
+
+ const [cached, gateKeys] = await Promise.all([
+ getCached(detected.platform, detected.repoId).catch(() => null),
+ chrome.storage.local.get(PROVIDER_GATE_KEYS),
+ ]);
+
+ // Cache hit → swap the loading tab to the saved analysis instantly (no AI call, works offline).
// The output tab offers a "Re-run fresh" affordance.
- const cached = await getCached(detected.platform, detected.repoId);
if (cached) {
await chrome.storage.session.set({ [sessionKey]: { ...cached, cached: true, loading: false } });
- await chrome.tabs.create({ url: `output-tab.html?key=${sessionKey}` });
return;
}
// Gate: at least one provider must be configured (runAnalysis reads the rest).
- const gateKeys = await chrome.storage.local.get(
- ['anthropicKey', 'googleKey', 'openrouterKey', 'xaiKey', 'xaiRefresh', 'nousKey', ...compatStorageKeys()]
- );
- const firstClass = gateKeys.anthropicKey || gateKeys.googleKey || gateKeys.openrouterKey ||
- gateKeys.xaiKey || gateKeys.xaiRefresh || gateKeys.nousKey;
- const anyCompat = COMPAT_PROVIDERS.some((p) => isCompatConnected(p.id, gateKeys));
- if (!firstClass && !anyCompat) {
+ if (!hasConnectedScanProvider(gateKeys)) {
+ await chrome.storage.session.set({
+ [sessionKey]: {
+ ...detected,
+ loading: false,
+ error: 'No AI provider configured — open Settings to add a key.',
+ errorKind: 'none',
+ },
+ });
chrome.runtime.openOptionsPage();
return;
}
- // Open the output tab immediately with a loading state, then run the analysis.
- await chrome.storage.session.set({ [sessionKey]: { loading: true, status: 'fetching', ...detected } });
- await chrome.tabs.create({ url: `output-tab.html?key=${sessionKey}` });
+ await chrome.storage.session.set({ [sessionKey]: initialScanState(detected, 'Starting fresh scan…') });
runAnalysis(sessionKey, detected, tab.id);
});
@@ -563,11 +737,13 @@ chrome.action.onClicked.addListener(async (tab) => {
// call is made. Single source of truth — add a provider here and every scan path
// picks it up.
const PROVIDER_KEYS = [
- 'anthropicKey', 'anthropicModel', 'googleKey', 'googleModel',
- 'openrouterKey', 'openrouterModel', 'xaiKey', 'xaiRefresh', 'xaiModel',
- 'nousKey', 'nousModel',
- ...compatStorageKeys(), // registry providers' key / model / endpoint / enabled / proto slots
- OPENAI_CREDENTIALS_KEY, // ChatGPT-login OAuth record (drives re-mint on 401)
+ ...PROVIDER_GATE_KEYS, // credentials + compat registry slots + ChatGPT-login OAuth record
+ ANTHROPIC_EXPIRY_KEY,
+ 'anthropicModel',
+ 'googleModel',
+ 'openrouterModel',
+ 'xaiModel',
+ 'nousModel',
'partRouting', // per-part model routing map (loaded alongside provider keys)
];
@@ -582,7 +758,9 @@ async function runBatchScan(batchKey, urls) {
});
const writeBatch = (done = false) =>
- chrome.storage.session.set({ [batchKey]: { type: 'batch', total: items.length, items: items.map((i) => ({ ...i })), done } });
+ chrome.storage.session.set({
+ [batchKey]: { type: 'batch', total: items.length, items: items.map((i) => ({ ...i })), done },
+ });
await writeBatch(false);
@@ -594,7 +772,12 @@ async function runBatchScan(batchKey, urls) {
const subKey = SESSION_KEY_PREFIX + crypto.randomUUID();
try {
- await chrome.storage.session.set({ [subKey]: { loading: true, status: 'fetching', platform: items[i].platform, repoId: items[i].repoId } });
+ await chrome.storage.session.set({
+ [subKey]: initialScanState(
+ { platform: items[i].platform, repoId: items[i].repoId },
+ `Queued from batch (${i + 1}/${items.length})…`
+ ),
+ });
runAnalysis(subKey, { platform: items[i].platform, repoId: items[i].repoId });
// Poll until the sub-analysis finishes (max 90 s per repo)
@@ -632,9 +815,7 @@ async function runBatchScan(batchKey, urls) {
try {
const done = items.filter((i) => i.status === 'done').length;
const errors = items.filter((i) => i.status === 'error').length;
- const msg = errors
- ? `${done} saved, ${errors} failed`
- : `${done} repo${done === 1 ? '' : 's'} saved`;
+ const msg = errors ? `${done} saved, ${errors} failed` : `${done} repo${done === 1 ? '' : 's'} saved`;
chrome.notifications.create(`rl_batch_${batchKey}`, {
type: 'basic',
iconUrl: chrome.runtime.getURL('icons/icon128.png'),
@@ -642,24 +823,43 @@ async function runBatchScan(batchKey, urls) {
message: msg,
silent: true,
});
- } catch { /* notifications are best-effort */ }
+ } catch {
+ /* notifications are best-effort */
+ }
}
// Fetch → AI → parse → store. Used by the initial click and by RERUN (retry).
async function runAnalysis(sessionKey, detected, tabId) {
- // Load every provider credential + model + routing in one read; pass the whole
- // object to callAI so registry (compat) providers are reachable too — not just
- // the five first-class ones. Extra keys (autoSave/tone) are ignored downstream.
- const settings = await chrome.storage.local.get([...PROVIDER_KEYS, 'autoSave', 'tone']);
- const { autoSave = true, tone } = settings;
-
+ let heartbeat = null;
try {
+ // Load every provider credential + model + routing in one read; pass the whole
+ // object to callAI so registry (compat) providers are reachable too — not just
+ // the five first-class ones. Extra keys (autoSave/tone) are ignored downstream.
+ // Keep this inside the try so any storage/runtime failure updates the output tab
+ // instead of leaving it spinning forever.
+ const settings = await chrome.storage.local.get([...PROVIDER_KEYS, 'autoSave', 'tone']);
+ const { autoSave = true, tone } = settings;
+
+ const touchSession = async () => {
+ const cur = (await chrome.storage.session.get(sessionKey))[sessionKey];
+ if (!cur?.loading) return;
+ await chrome.storage.session.set({
+ [sessionKey]: { ...cur, heartbeatAt: Date.now(), updatedAt: Date.now() },
+ });
+ };
+ heartbeat = setInterval(() => {
+ touchSession().catch(() => {});
+ }, 8_000);
+
startScanAnim(tabId); // fire-and-forget; no-ops without a tabId / when disabled / reduced motion
// Snapshot the previous cached analysis for diff comparison (before it's overwritten).
- const prevCached = await getCached(detected.platform, detected.repoId).catch(() => null);
+ // Run it beside metadata fetch so retries/rescans do not pay a serial cache read.
+ const prevCachedPromise = getCached(detected.platform, detected.repoId).catch(() => null);
// Fetch metadata + README
- await chrome.storage.session.set({ [sessionKey]: { loading: true, status: 'fetching', statusMsg: 'Fetching repo metadata…', ...detected } });
+ await chrome.storage.session.set({
+ [sessionKey]: { loading: true, status: 'fetching', statusMsg: 'Fetching repo metadata…', ...detected },
+ });
const repoData = await fetchRepoData(detected.platform, detected.repoId);
// Write quick snapshot so the output tab can render something while AI thinks.
@@ -677,12 +877,17 @@ async function runAnalysis(sessionKey, detected, tabId) {
try {
const plan = buildAttemptPlan({ routing: settings.partRouting || {}, part: 'core', keys: settings });
if (plan[0]) primaryProvider = providerLabel(plan[0].provider);
- } catch { /* leave blank — the tab falls back to a generic phrase */ }
+ } catch {
+ /* leave blank — the tab falls back to a generic phrase */
+ }
await chrome.storage.session.set({
[sessionKey]: {
- loading: true, status: 'thinking',
+ loading: true,
+ status: 'thinking',
statusMsg: primaryProvider ? `Asking ${primaryProvider}…` : 'Analysing with AI…',
- quickData, ...detected, provider: primaryProvider,
+ quickData,
+ ...detected,
+ provider: primaryProvider,
},
});
@@ -702,7 +907,7 @@ async function runAnalysis(sessionKey, detected, tabId) {
};
// Attach diff against the previous scan (null on first scan — tab renders "Nothing to compare").
- const diff = diffAnalyses(prevCached, fullData);
+ const diff = diffAnalyses(await prevCachedPromise, fullData);
await chrome.storage.session.set({ [sessionKey]: { ...fullData, diff } });
cacheAnalysis(detected.platform, detected.repoId, fullData).catch(() => {}); // history/cache (no diff stored)
@@ -716,7 +921,12 @@ async function runAnalysis(sessionKey, detected, tabId) {
await chrome.storage.session.set({
[sessionKey]: saveErr
- ? { ...fullData, diff, saved: false, saveError: saveErr.message || 'Could not save to your library' }
+ ? {
+ ...fullData,
+ diff,
+ saved: false,
+ saveError: saveErr.message || 'Could not save to your library',
+ }
: { ...fullData, diff, saved: true, saveError: null },
});
@@ -724,12 +934,15 @@ async function runAnalysis(sessionKey, detected, tabId) {
// Best-effort — never throws.
if (!saveErr) {
const sourcePayload = repoNodePayload(fullData.repoId, fullData, true);
- for (const alt of (fullData.alternatives || [])) {
+ for (const alt of fullData.alternatives || []) {
if (!alt?.name) continue;
await linkRepos({
- source: fullData.repoId, sourcePayload,
- targetKey: alt.name, targetPayload: { name: alt.name, analyzed: false },
- label: 'ALTERNATIVE_TO', properties: { name: alt.name, when: alt.when || '' },
+ source: fullData.repoId,
+ sourcePayload,
+ targetKey: alt.name,
+ targetPayload: { name: alt.name, analyzed: false },
+ label: 'ALTERNATIVE_TO',
+ properties: { name: alt.name, when: alt.when || '' },
});
}
}
@@ -740,7 +953,9 @@ async function runAnalysis(sessionKey, detected, tabId) {
try {
const repoName = fullData.repoId?.split('/').pop() || fullData.repoId || 'Repo';
const fit = deriveFit(fullData);
- const fitMsg = { strong: 'Strong fit', solid: 'Solid fit', care: 'Use with care', risky: 'Risky' }[fit.level] || 'Analysis ready';
+ const fitMsg =
+ { strong: 'Strong fit', solid: 'Solid fit', care: 'Use with care', risky: 'Risky' }[fit.level] ||
+ 'Analysis ready';
const tabUrl = chrome.runtime.getURL(`output-tab.html?key=${sessionKey}`);
chrome.notifications.create(`rl_scan_${tabUrl}`, {
type: 'basic',
@@ -749,25 +964,29 @@ async function runAnalysis(sessionKey, detected, tabId) {
message: fitMsg,
silent: true,
});
- } catch { /* notifications are best-effort */ }
+ } catch {
+ /* notifications are best-effort */
+ }
+ if (heartbeat) clearInterval(heartbeat);
+ heartbeat = null;
stopScanAnim(tabId); // success: reset to the static icon
} catch (err) {
+ if (heartbeat) clearInterval(heartbeat);
+ heartbeat = null;
stopScanAnim(tabId); // error: reset to the static icon
// AI failures already carry a humanized message + kind; other failures (fetch,
// parse) get classified here so the tab can still route the error CTA.
const errorKind = err.kind || categorizeError(err).kind;
await chrome.storage.session.set({
- [sessionKey]: { ...detected, loading: false, error: err.message, errorKind }
+ [sessionKey]: { ...detected, loading: false, error: err.message, errorKind },
});
}
}
// ─── Deep Dive: multi-stage source analysis (on-demand from the output tab) ───
async function runDeepDive(sessionKey, detected) {
- const keys = await chrome.storage.local.get(
- [...PROVIDER_KEYS, 'tone', 'runnerUrl']
- );
+ const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone', 'runnerUrl']);
// Merge a patch into the session entry's deepDive object without clobbering analysis.
const setDeep = async (patch) => {
@@ -792,17 +1011,39 @@ async function runDeepDive(sessionKey, detected) {
const facts = await scanRepo(keys.runnerUrl, detected.platform, detected.repoId);
await setDeep({ status: 'atoms', degraded: !!source.degraded, facts });
- const { atoms } = parseAtoms(await callAI(keys, withTone(keys.tone, buildAtomsPrompt(repoData, source, facts)), 'deepdive'));
+ const { atoms } = parseAtoms(
+ await callAI(keys, withTone(keys.tone, buildAtomsPrompt(repoData, source, facts)), 'deepdive')
+ );
await setDeep({ atoms });
await setDeep({ status: 'lineage' });
- const lineage = parseLineage(await callAI(keys, withTone(keys.tone, buildLineagePrompt(atoms)), 'deepdive'));
+ const lineage = parseLineage(
+ await callAI(keys, withTone(keys.tone, buildLineagePrompt(atoms)), 'deepdive')
+ );
await setDeep({ lineage });
await setDeep({ status: 'feynman' });
- const feynman = parseFeynman(await callAI(keys, withTone(keys.tone, buildFeynmanPrompt(repoData, atoms, lineage)), 'deepdive'));
+ const feynman = parseFeynman(
+ await callAI(keys, withTone(keys.tone, buildFeynmanPrompt(repoData, atoms, lineage)), 'deepdive')
+ );
await setDeep({ feynman });
+ // Knowledge-Graph substrate: persist atoms, with embeddings when the configured
+ // provider supports them (else vectors stay null → lexical matching). Best-effort.
+ try {
+ const texts = atoms.map((a) => `${a.name} — ${a.purpose || ''}`.trim());
+ const emb = await callEmbeddings(keys, texts);
+ await setConcepts(detected.repoId, {
+ repoId: detected.repoId,
+ atoms,
+ vectors: emb ? emb.vectors : null,
+ embedModel: emb ? emb.model : null,
+ computedAt: new Date().toISOString(),
+ });
+ } catch {
+ /* substrate is additive; ignore */
+ }
+
await setDeep({ status: 'done' });
} catch (err) {
await setDeep({ status: 'error', error: err.message || 'Deep Dive failed' });
@@ -813,16 +1054,28 @@ async function runDeepDive(sessionKey, detected) {
// per-framework state under `slot` via the lens-runs reducer. Source is fetched once
// and reused across frameworks. Each AI call still flows through the throttled callAI,
// so "Run all" can't burst a provider; one framework's error doesn't sink the batch.
-const SYSTEMS_LENS = { slot: 'systems', build: buildSystemsPrompt, parse: parseSystems, label: 'Systems analysis' };
-const IDEATE_LENS = { slot: 'ideate', build: buildIdeatePrompt, parse: parseIdeate, label: 'Ideation' };
-const PRIORITIZE_LENS = { slot: 'prioritize', build: buildHeuristicsPrompt, parse: parseHeuristics, label: 'Prioritization' };
+const SYSTEMS_LENS = {
+ slot: 'systems',
+ build: buildSystemsPrompt,
+ parse: parseSystems,
+ label: 'Systems analysis',
+};
+const IDEATE_LENS = { slot: 'ideate', build: buildIdeatePrompt, parse: parseIdeate, label: 'Ideation' };
+const PRIORITIZE_LENS = {
+ slot: 'prioritize',
+ build: buildHeuristicsPrompt,
+ parse: parseHeuristics,
+ label: 'Prioritization',
+};
async function runFrameworkLens(sessionKey, detected, frameworks, cfg) {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const cur0 = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
const repoData = {
- repoId: detected.repoId, platform: detected.platform,
- description: cur0.description || '', language: cur0.language || '',
+ repoId: detected.repoId,
+ platform: detected.platform,
+ description: cur0.description || '',
+ language: cur0.language || '',
};
const setRun = async (fw, patch) => {
@@ -837,7 +1090,10 @@ async function runFrameworkLens(sessionKey, detected, frameworks, cfg) {
await setRun(fw, { status: 'fetching', error: null, result: null });
if (!source) source = await fetchSource(detected.platform, detected.repoId);
await setRun(fw, { status: 'running' });
- const result = cfg.parse(fw, await callAI(keys, withTone(keys.tone, cfg.build(fw, repoData, source)), 'lens'));
+ const result = cfg.parse(
+ fw,
+ await callAI(keys, withTone(keys.tone, cfg.build(fw, repoData, source)), 'lens')
+ );
await setRun(fw, { status: 'done', result });
} catch (err) {
await setRun(fw, { status: 'error', error: err.message || `${cfg.label} failed` });
@@ -847,9 +1103,7 @@ async function runFrameworkLens(sessionKey, detected, frameworks, cfg) {
// ─── SKTPG: one-tap directional-intelligence skill (on-demand) ────────────────
async function runSktpg(sessionKey, detected) {
- const keys = await chrome.storage.local.get(
- [...PROVIDER_KEYS, 'tone']
- );
+ const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const setSk = async (patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
@@ -871,7 +1125,9 @@ async function runSktpg(sessionKey, detected) {
const source = await fetchSource(detected.platform, detected.repoId);
await setSk({ status: 'running' });
- const result = parseSktpg(await callAI(keys, withTone(keys.tone, buildSktpgPrompt(repoData, source)), 'sktpg'));
+ const result = parseSktpg(
+ await callAI(keys, withTone(keys.tone, buildSktpgPrompt(repoData, source)), 'sktpg')
+ );
await setSk({ status: 'done', result });
} catch (err) {
@@ -944,7 +1200,11 @@ async function runMaintenance(sessionKey, detected) {
await setM({ status: 'running' });
const result = parseMaintenance(
- await callAI(keys, withTone(keys.tone, buildMaintenancePrompt(repoData, signals, source.tree)), 'maintenance'),
+ await callAI(
+ keys,
+ withTone(keys.tone, buildMaintenancePrompt(repoData, signals, source.tree)),
+ 'maintenance'
+ ),
signals
);
await setM({ status: 'done', result });
@@ -957,7 +1217,9 @@ async function runMaintenance(sessionKey, detected) {
async function runFitsStack(sessionKey, detected) {
const setF = async (patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
- await chrome.storage.session.set({ [sessionKey]: { ...cur, fitsStack: { ...(cur.fitsStack || {}), ...patch } } });
+ await chrome.storage.session.set({
+ [sessionKey]: { ...cur, fitsStack: { ...(cur.fitsStack || {}), ...patch } },
+ });
};
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
@@ -971,18 +1233,24 @@ async function runFitsStack(sessionKey, detected) {
capabilities: cur.capabilities || [],
};
const nearestRepos = await searchLibrary({
- query: [repoData.language, repoData.category, ...(repoData.capabilities || [])].filter(Boolean).join(' '),
+ query: [repoData.language, repoData.category, ...(repoData.capabilities || [])]
+ .filter(Boolean)
+ .join(' '),
topK: 8,
excludeRepoId: detected.repoId,
});
if (!nearestRepos.length) {
- await setF({ status: 'done', result: {
- verdict: 'new-paradigm',
- summary: 'Your library is empty — scan a few repos first to get a personalised stack fit.',
- integrations: [], risks: [],
- recommendation: 'Scan more repos, then re-run Fits MY Stack?',
- }});
+ await setF({
+ status: 'done',
+ result: {
+ verdict: 'new-paradigm',
+ summary: 'Your library is empty — scan a few repos first to get a personalised stack fit.',
+ integrations: [],
+ risks: [],
+ recommendation: 'Scan more repos, then re-run Fits MY Stack?',
+ },
+ });
return;
}
@@ -1009,11 +1277,11 @@ async function runStackBuild(sessionKey, repoIds) {
// Gather repo data from the library + cache.
const libRepos = await scrollLibrary({ limit: 500 });
- const libMap = new Map(libRepos.map(r => [r.repoId, r]));
+ const libMap = new Map(libRepos.map((r) => [r.repoId, r]));
const cacheList = await listCached().catch(() => []);
- const cacheMap = new Map(cacheList.map(c => [c.repoId, c]));
+ const cacheMap = new Map(cacheList.map((c) => [c.repoId, c]));
- const repos = repoIds.map(id => {
+ const repos = repoIds.map((id) => {
const lib = libMap.get(id) || {};
const cached = cacheMap.get(id) || {};
return {
@@ -1038,9 +1306,7 @@ async function runStackBuild(sessionKey, repoIds) {
// ─── Versus: head-to-head comparison (on-demand) ──────────────────────────────
async function runVersus(sessionKey, detectedA, competitorInput) {
- const keys = await chrome.storage.local.get(
- [...PROVIDER_KEYS, 'tone']
- );
+ const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const setVs = async (patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
@@ -1052,7 +1318,14 @@ async function runVersus(sessionKey, detectedA, competitorInput) {
const compB = resolveCompetitor(competitorInput);
try {
if (!compB.repoId) throw new Error('Enter a competitor repo (e.g. vuejs/vue or a repo URL).');
- await setVs({ status: 'fetching', competitor: compB.repoId, a: detectedA.repoId, b: compB.repoId, error: null, result: null });
+ await setVs({
+ status: 'fetching',
+ competitor: compB.repoId,
+ a: detectedA.repoId,
+ b: compB.repoId,
+ error: null,
+ result: null,
+ });
const [a, b] = await Promise.all([
fetchRepoData(detectedA.platform, detectedA.repoId),
@@ -1067,9 +1340,12 @@ async function runVersus(sessionKey, detectedA, competitorInput) {
// Semantic graph: A compared-to B (best-effort).
const curVs = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
await linkRepos({
- source: detectedA.repoId, sourcePayload: repoNodePayload(detectedA.repoId, curVs, true),
- targetKey: compB.repoId, targetPayload: repoNodePayload(compB.repoId, compB, false),
- label: 'COMPARED_TO', properties: { verdict: result?.verdict || '' },
+ source: detectedA.repoId,
+ sourcePayload: repoNodePayload(detectedA.repoId, curVs, true),
+ targetKey: compB.repoId,
+ targetPayload: repoNodePayload(compB.repoId, compB, false),
+ label: 'COMPARED_TO',
+ properties: { verdict: result?.verdict || '' },
});
} catch (err) {
await setVs({ status: 'error', error: err.message || `Couldn't compare against "${compB.repoId}".` });
@@ -1078,9 +1354,7 @@ async function runVersus(sessionKey, detectedA, competitorInput) {
// ─── Synergies: complementary repos grounded in the library ───────────────────
async function runSynergies(sessionKey, detected) {
- const keys = await chrome.storage.local.get(
- [...PROVIDER_KEYS, 'tone']
- );
+ const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const setSyn = async (patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
@@ -1092,27 +1366,43 @@ async function runSynergies(sessionKey, detected) {
try {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
const repoData = {
- repoId: detected.repoId, platform: detected.platform,
- description: cur.description || '', language: cur.language || '',
- category: cur.category || '', eli5: cur.eli5 || '',
+ repoId: detected.repoId,
+ platform: detected.platform,
+ description: cur.description || '',
+ language: cur.language || '',
+ category: cur.category || '',
+ eli5: cur.eli5 || '',
};
await setSyn({ status: 'running', error: null, result: null });
// Seed candidates from the user's library (same ecosystem, by language).
- const candidates = await searchLibrary({ query: repoData.language, topK: 12, excludeRepoId: repoData.repoId });
- const result = parseSynergies(await callAI(keys, withTone(keys.tone, buildSynergiesPrompt(repoData, candidates)), 'synergies'));
+ const candidates = await searchLibrary({
+ query: repoData.language,
+ topK: 12,
+ excludeRepoId: repoData.repoId,
+ });
+ const result = parseSynergies(
+ await callAI(keys, withTone(keys.tone, buildSynergiesPrompt(repoData, candidates)), 'synergies')
+ );
await setSyn({ status: 'done', result });
// Semantic graph: target synergizes-with each complement (best-effort).
const synSource = repoNodePayload(repoData.repoId, repoData, true);
- for (const s of (result?.synergies || [])) {
+ for (const s of result?.synergies || []) {
if (!s?.repoId) continue;
await linkRepos({
- source: repoData.repoId, sourcePayload: synSource,
+ source: repoData.repoId,
+ sourcePayload: synSource,
targetKey: s.repoId,
- targetPayload: { repoId: s.repoId, name: s.repoId.split('/').pop() || s.repoId, category: s.category || '', analyzed: !!s.in_library },
- label: 'SYNERGIZES_WITH', properties: { why: s.synergy || '' },
+ targetPayload: {
+ repoId: s.repoId,
+ name: s.repoId.split('/').pop() || s.repoId,
+ category: s.category || '',
+ analyzed: !!s.in_library,
+ },
+ label: 'SYNERGIZES_WITH',
+ properties: { why: s.synergy || '' },
});
}
} catch (err) {
@@ -1138,11 +1428,19 @@ async function runCombinator(sessionKey, detected, { mode = 'repo', wildness = 0
let rows = await scrollLibrary();
if (mode === 'repo') {
// Repo-anchored: ensure the current repo (the seed) is represented with its capabilities.
- const seedCaps = (Array.isArray(cur.capabilities) && cur.capabilities.length) ? cur.capabilities : deriveCapabilities(cur);
- const seedRow = { repoId: detected.repoId, name: detected.repoId.split('/').pop() || detected.repoId, capabilities: seedCaps, eli5: cur.eli5 || '' };
+ const seedCaps =
+ Array.isArray(cur.capabilities) && cur.capabilities.length
+ ? cur.capabilities
+ : deriveCapabilities(cur);
+ const seedRow = {
+ repoId: detected.repoId,
+ name: detected.repoId.split('/').pop() || detected.repoId,
+ capabilities: seedCaps,
+ eli5: cur.eli5 || '',
+ };
// Immutable: rebuild rather than mutate the objects scrollLibrary returned.
- rows = rows.some(r => r.repoId === detected.repoId)
- ? rows.map(r => (r.repoId === detected.repoId ? { ...r, capabilities: seedRow.capabilities } : r))
+ rows = rows.some((r) => r.repoId === detected.repoId)
+ ? rows.map((r) => (r.repoId === detected.repoId ? { ...r, capabilities: seedRow.capabilities } : r))
: [...rows, seedRow];
}
@@ -1150,15 +1448,23 @@ async function runCombinator(sessionKey, detected, { mode = 'repo', wildness = 0
const seed = mode === 'library' ? null : detected.repoId;
const sizes = mode === 'library' ? [2] : [2, 3];
const candidates = combineCandidates(rows, { seed, sizes, wildness, topK: 6 });
- if (!candidates.length) { await setC({ status: 'done', results: [], total: 0 }); return; }
+ if (!candidates.length) {
+ await setC({ status: 'done', results: [], total: 0 });
+ return;
+ }
await setC({ status: 'running', total: candidates.length });
const results = [];
for (const cand of candidates) {
try {
- const idea = parseCombinator(await callAI(keys, withTone(keys.tone, buildCombinatorPrompt(cand.rows)), 'combinator'), cand.repoIds);
+ const idea = parseCombinator(
+ await callAI(keys, withTone(keys.tone, buildCombinatorPrompt(cand.rows)), 'combinator'),
+ cand.repoIds
+ );
results.push({ repoIds: cand.repoIds, ...idea });
- } catch { /* skip a single failed synthesis, keep going */ }
+ } catch {
+ /* skip a single failed synthesis, keep going */
+ }
await setC({ status: 'running', results: [...results] }); // incremental render
}
await setC({ status: 'done', results });
@@ -1183,7 +1489,9 @@ async function runTagLibrary(sessionKey) {
const meta = pt.payload || {};
const caps = parseTags(await callAI(keys, buildTagPrompt(meta), 'retag'));
if (caps.length) await saveRepo({ ...meta, capabilities: caps }); // re-save preserves the full payload
- } catch { /* skip a single repo, keep going */ }
+ } catch {
+ /* skip a single repo, keep going */
+ }
done++;
await setT({ status: 'running', total: points.length, done });
}
@@ -1219,7 +1527,13 @@ function callAI(keys, prompt, part) {
return run;
}
-const PROVIDER_LABEL = { nous: 'Nous', google: 'Gemini', openrouter: 'OpenRouter', xai: 'Grok', anthropic: 'Anthropic' };
+const PROVIDER_LABEL = {
+ nous: 'Nous',
+ google: 'Gemini',
+ openrouter: 'OpenRouter',
+ xai: 'Grok',
+ anthropic: 'Anthropic',
+};
function providerLabel(provider) {
return PROVIDER_LABEL[provider] || compatProviderById(provider)?.label || provider;
@@ -1230,11 +1544,16 @@ function providerLabel(provider) {
// served by the generic OpenAI/Anthropic-compatible engines.
function dispatch(provider, model, keys, prompt) {
switch (provider) {
- case 'nous': return callNous(keys.nousKey, model, prompt);
- case 'google': return callGemini(keys.googleKey, model, prompt);
- case 'openrouter': return callOpenRouter(keys.openrouterKey, model, prompt);
- case 'xai': return callXAI(model, prompt);
- case 'anthropic': return callAnthropic(model, prompt);
+ case 'nous':
+ return callNous(keys.nousKey, model, prompt);
+ case 'google':
+ return callGemini(keys.googleKey, model, prompt);
+ case 'openrouter':
+ return callOpenRouter(keys.openrouterKey, model, prompt);
+ case 'xai':
+ return callXAI(model, prompt);
+ case 'anthropic':
+ return callAnthropic(model, prompt);
default:
// OpenAI connected via "Sign in with ChatGPT": mint/refresh the API key from the
// OAuth session on demand instead of using a statically-stored key.
@@ -1259,7 +1578,11 @@ function callCompat(provider, model, keys, prompt) {
}
// OpenAI-compatible (and Azure, which differs only in the auth header).
return callOpenAICompatible({
- endpoint, key, model: m, prompt, label: providerLabel(provider),
+ endpoint,
+ key,
+ model: m,
+ prompt,
+ label: providerLabel(provider),
headerStyle: protocol === 'azure' ? 'azure' : 'bearer',
});
}
@@ -1284,17 +1607,29 @@ async function fetchWithTimeout(url, opts = {}, label = 'Provider', ms = AI_FETC
// OpenAI-compatible chat completion. `key` may be empty for keyless local servers (Ollama).
// headerStyle 'azure' sends `api-key: ` (Azure OpenAI); otherwise `Authorization: Bearer`.
-async function callOpenAICompatible({ endpoint, key, model, prompt, label = 'Provider', maxTokens = 4096, headerStyle = 'bearer' }) {
+async function callOpenAICompatible({
+ endpoint,
+ key,
+ model,
+ prompt,
+ label = 'Provider',
+ maxTokens = 4096,
+ headerStyle = 'bearer',
+}) {
const headers = { 'Content-Type': 'application/json' };
if (key) {
if (headerStyle === 'azure') headers['api-key'] = key;
else headers['Authorization'] = `Bearer ${key}`;
}
- const res = await fetchWithTimeout(endpoint, {
- method: 'POST',
- headers,
- body: JSON.stringify(openaiBody(model, prompt, maxTokens)),
- }, label);
+ const res = await fetchWithTimeout(
+ endpoint,
+ {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(openaiBody(model, prompt, maxTokens)),
+ },
+ label
+ );
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message ?? err.message ?? `${label} API error ${res.status}`);
@@ -1302,6 +1637,26 @@ async function callOpenAICompatible({ endpoint, key, model, prompt, label = 'Pro
return parseOpenAiText(await res.json());
}
+// Embeddings via the configured OpenAI-protocol provider (BYO-key). Returns
+// number[][] aligned to `texts`, or null if no capable provider / on any error
+// (callers fall back to lexical matching). Never throws.
+async function callEmbeddings(keys, texts) {
+ const p = pickEmbeddingsProvider(keys);
+ if (!p || !p.endpoint || !p.key || !texts.length) return null;
+ try {
+ const res = await fetchWithTimeout(p.endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', Authorization: `Bearer ${p.key}` },
+ body: JSON.stringify(embeddingsBody(p.model, texts)),
+ });
+ if (!res.ok) return null;
+ const vectors = parseEmbeddings(await res.json());
+ return vectors.length === texts.length ? { vectors, model: p.model } : null;
+ } catch {
+ return null;
+ }
+}
+
// OpenAI via "Sign in with ChatGPT" (the Codex CLI OAuth flow). The OAuth session is
// exchanged for a normal OpenAI API key; on a 401 we refresh the session, re-mint, and
// retry once. Inference itself is the standard api.openai.com chat-completions engine.
@@ -1335,25 +1690,40 @@ async function mintAndStoreOpenAIKey() {
// Bare OpenAI chat request returning the raw Response, so callers can branch on 401.
function openaiChat(key, model, prompt) {
- return fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
- method: 'POST',
- headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
- body: JSON.stringify(openaiBody(model, prompt, 4096)),
- }, 'OpenAI');
+ return fetchWithTimeout(
+ 'https://api.openai.com/v1/chat/completions',
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify(openaiBody(model, prompt, 4096)),
+ },
+ 'OpenAI'
+ );
}
// Anthropic-compatible Messages API (x-api-key + anthropic-version).
-async function callAnthropicCompatible({ endpoint, key, model, prompt, label = 'Provider', maxTokens = 4096 }) {
- const res = await fetchWithTimeout(endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'anthropic-version': '2023-06-01',
- 'anthropic-dangerous-direct-browser-access': 'true',
- 'x-api-key': key,
+async function callAnthropicCompatible({
+ endpoint,
+ key,
+ model,
+ prompt,
+ label = 'Provider',
+ maxTokens = 4096,
+}) {
+ const res = await fetchWithTimeout(
+ endpoint,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'anthropic-version': '2023-06-01',
+ 'anthropic-dangerous-direct-browser-access': 'true',
+ 'x-api-key': key,
+ },
+ body: JSON.stringify(anthropicBody(model, prompt, maxTokens)),
},
- body: JSON.stringify(anthropicBody(model, prompt, maxTokens)),
- }, label);
+ label
+ );
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message ?? err.message ?? `${label} API error ${res.status}`);
@@ -1369,7 +1739,12 @@ async function testProvider(provider, keys) {
// OpenAI connected via "Sign in with ChatGPT" exercises the OAuth → mint → call path.
const isOpenAiOAuth = provider === 'openai' && !!keys[OPENAI_CREDENTIALS_KEY]?.refresh_token;
if (!isCompatConnected(provider, keys) && !isOpenAiOAuth) {
- return { ok: false, connection: false, function: false, detail: 'Not configured — add a key / endpoint first.' };
+ return {
+ ok: false,
+ connection: false,
+ function: false,
+ detail: 'Not configured — add a key / endpoint first.',
+ };
}
const out = { ok: false, connection: false, function: false, detail: '' };
try {
@@ -1380,13 +1755,17 @@ async function testProvider(provider, keys) {
out.connection = true;
out.function = /ready/i.test(reply || '');
out.ok = out.function;
- out.detail = out.function ? 'Model responded correctly.' : `Reached the model, but the reply was unexpected: ${String(reply).slice(0, 80)}`;
+ out.detail = out.function
+ ? 'Model responded correctly.'
+ : `Reached the model, but the reply was unexpected: ${String(reply).slice(0, 80)}`;
} catch (e) {
const msg = e?.message || String(e);
// A structured API error (auth/model/quota) still proves the endpoint is reachable.
const reachable = !/Failed to fetch|NetworkError|ENOTFOUND|ECONNREFUSED|load failed/i.test(msg);
out.connection = reachable;
- out.detail = reachable ? `Endpoint reachable, but the call failed: ${msg}` : `Could not reach the endpoint: ${msg}`;
+ out.detail = reachable
+ ? `Endpoint reachable, but the call failed: ${msg}`
+ : `Could not reach the endpoint: ${msg}`;
}
return out;
}
@@ -1421,30 +1800,50 @@ async function callAIInner(keys, prompt, part) {
throw err;
}
-// Anthropic Messages API with a standard Console API key (sk-ant-api…) via x-api-key.
-// Subscription/OAuth sign-in was removed: Anthropic binds Claude-subscription tokens to
-// the Claude Code client (server-side identity checks) and, as of 2026, prohibits using
-// subscription auth in third-party apps — so the only supported path is a Console key.
+// Anthropic Messages API. Supports either a standard Console API key (x-api-key)
+// or Claude Pro/Max OAuth tokens via the same Claude Code beta flow that Pi uses.
async function callAnthropic(model = 'claude-sonnet-4-6', prompt) {
- const { anthropicKey } = await chrome.storage.local.get('anthropicKey');
- if (!anthropicKey) throw new Error('No Anthropic API key — add one in Settings');
-
- const res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
- method: 'POST',
- headers: {
- 'anthropic-version': '2023-06-01',
- 'anthropic-dangerous-direct-browser-access': 'true',
- 'Content-Type': 'application/json',
- 'x-api-key': anthropicKey,
+ const s = await chrome.storage.local.get(['anthropicKey', ANTHROPIC_ACCESS_KEY, ANTHROPIC_REFRESH_KEY]);
+ if (!s.anthropicKey && !s[ANTHROPIC_ACCESS_KEY] && !s[ANTHROPIC_REFRESH_KEY]) {
+ throw new Error('No Anthropic credentials — connect Claude or add an API key in Settings');
+ }
+
+ const oauth = !s.anthropicKey;
+ const headers = {
+ accept: 'application/json',
+ 'anthropic-version': '2023-06-01',
+ 'anthropic-dangerous-direct-browser-access': 'true',
+ 'Content-Type': 'application/json',
+ };
+ if (oauth) {
+ headers.Authorization = `Bearer ${await refreshAnthropicAccessToken()}`;
+ headers['anthropic-beta'] = 'claude-code-20250219,oauth-2025-04-20';
+ headers['x-app'] = 'cli';
+ } else {
+ headers['x-api-key'] = s.anthropicKey;
+ }
+
+ const body = {
+ model,
+ max_tokens: 4096,
+ messages: [{ role: 'user', content: prompt }],
+ };
+ if (oauth) {
+ body.system = "You are Claude Code, Anthropic's official CLI for Claude.";
+ }
+
+ const res = await fetchWithTimeout(
+ 'https://api.anthropic.com/v1/messages',
+ {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
},
- body: JSON.stringify({
- model,
- max_tokens: 4096,
- messages: [{ role: 'user', content: prompt }]
- })
- }, 'Anthropic');
+ 'Anthropic'
+ );
if (!res.ok) {
const err = await res.json().catch(() => ({}));
+ if (oauth && (res.status === 401 || res.status === 403)) await clearAnthropicOAuthTokens();
throw new Error(err.error?.message ?? `Anthropic API error ${res.status}`);
}
const data = await res.json();
@@ -1454,15 +1853,23 @@ async function callAnthropic(model = 'claude-sonnet-4-6', prompt) {
}
async function callGemini(key, model = 'gemini-2.5-flash', prompt) {
- const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + encodeURIComponent(model) + ':generateContent?key=' + encodeURIComponent(key);
- const res = await fetchWithTimeout(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contents: [{ parts: [{ text: prompt }] }],
- generationConfig: { responseMimeType: 'application/json', maxOutputTokens: 4096 }
- })
- }, 'Gemini');
+ const url =
+ 'https://generativelanguage.googleapis.com/v1beta/models/' +
+ encodeURIComponent(model) +
+ ':generateContent?key=' +
+ encodeURIComponent(key);
+ const res = await fetchWithTimeout(
+ url,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { responseMimeType: 'application/json', maxOutputTokens: 4096 },
+ }),
+ },
+ 'Gemini'
+ );
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message ?? `Gemini API error ${res.status}`);
@@ -1484,15 +1891,19 @@ async function callNous(key, model = 'stepfun/step-3.7-flash', prompt) {
const body = JSON.stringify({
model: model || 'stepfun/step-3.7-flash',
max_tokens: 4096,
- messages: [{ role: 'user', content: prompt }]
+ messages: [{ role: 'user', content: prompt }],
});
for (let attempt = 0; ; attempt++) {
- const res = await fetchWithTimeout('https://inference-api.nousresearch.com/v1/chat/completions', {
- method: 'POST',
- headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
- body,
- }, 'Nous');
+ const res = await fetchWithTimeout(
+ 'https://inference-api.nousresearch.com/v1/chat/completions',
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
+ body,
+ },
+ 'Nous'
+ );
// Rate-limited / transient — back off and retry (honor Retry-After), up to 3 times.
if ((res.status === 429 || res.status === 503) && attempt < 3) {
@@ -1507,7 +1918,9 @@ async function callNous(key, model = 'stepfun/step-3.7-flash', prompt) {
// The endpoint answers a valid model with an x402 payment challenge when the
// caller isn't drawing on membership credits — surface that plainly.
if (err.x402Version || res.status === 402) {
- throw new Error('Nous returned a pay-per-request (x402) challenge — your key isn’t drawing on membership credits. Check your plan/key at portal.nousresearch.com.');
+ throw new Error(
+ 'Nous returned a pay-per-request (x402) challenge — your key isn’t drawing on membership credits. Check your plan/key at portal.nousresearch.com.'
+ );
}
throw new Error(err.error?.message ?? err.message ?? `Nous API error ${res.status}`);
}
@@ -1520,15 +1933,19 @@ async function callNous(key, model = 'stepfun/step-3.7-flash', prompt) {
}
async function callOpenRouter(key, model = 'x-ai/grok-4.3', prompt) {
- const res = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', {
- method: 'POST',
- headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
- body: JSON.stringify({
- model: model || 'x-ai/grok-4.3',
- max_tokens: 4096,
- messages: [{ role: 'user', content: prompt }]
- })
- }, 'OpenRouter');
+ const res = await fetchWithTimeout(
+ 'https://openrouter.ai/api/v1/chat/completions',
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: model || 'x-ai/grok-4.3',
+ max_tokens: 4096,
+ messages: [{ role: 'user', content: prompt }],
+ }),
+ },
+ 'OpenRouter'
+ );
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message ?? `OpenRouter API error ${res.status}`);
@@ -1548,11 +1965,11 @@ async function callXAI(model = 'grok-4.3', prompt) {
const token = isOAuth ? await refreshXaiToken() : xaiKey;
if (!token) throw new Error('No xAI credential — connect in Settings');
- const headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
+ const headers = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' };
const body = JSON.stringify({
model: model || 'grok-4.3',
max_tokens: 4096,
- messages: [{ role: 'user', content: prompt }]
+ messages: [{ role: 'user', content: prompt }],
});
// For OAuth tokens: try api.x.ai first (standard API), then chat proxy as fallback
@@ -1576,8 +1993,7 @@ async function callXAI(model = 'grok-4.3', prompt) {
throw new Error('xAI returned no text content');
}
const err = await res.json().catch(() => ({}));
- console.warn('[RepoLens xAI]', endpoint, res.status, JSON.stringify(err));
- lastErr = err.error?.message || ('xAI API error ' + res.status + ' at ' + endpoint);
+ lastErr = err.error?.message || 'xAI API error ' + res.status + ' at ' + endpoint;
if (res.status === 401 && isOAuth) {
await chrome.storage.local.remove(['xaiKey', 'xaiRefresh', 'xaiExpiry', 'xaiCredentials']);
throw new Error('xAI session expired — please reconnect in Settings');
diff --git a/backup.js b/backup.js
index e1a556d..c0d6762 100644
--- a/backup.js
+++ b/backup.js
@@ -15,25 +15,125 @@ export const BACKUP_VERSION = 2;
// Upper bounds on how much a single import may write, so a hostile or corrupt
// file can't pin the IndexedDB write lock or blow the storage quota. Anything
// past these is dropped with a surfaced warning (never silently).
-export const MAX_ROWS = { repos: 5000, nodes: 20000, edges: 50000, cache: 5000, collections: 2000, decisions: 5000, snapshots: 5000, scenes: 2000 };
+export const MAX_ROWS = {
+ repos: 5000,
+ nodes: 20000,
+ edges: 50000,
+ cache: 5000,
+ collections: 2000,
+ decisions: 5000,
+ snapshots: 5000,
+ scenes: 2000,
+};
+
+// String caps stop a hostile backup from smuggling a tiny row count with huge
+// values (e.g. a 1 MB repoId / note) that would pin the IDB lock or blow quota.
+export const MAX_STRING_LENGTHS = {
+ id: 512,
+ repoId: 256,
+ label: 128,
+ scalar: 20_000,
+};
// Per-repo snapshot ring-buffer cap — single source of truth in snapshots.js; each
// imported snapshots row is trimmed to its most recent SNAP_CAP entries.
const SNAP_CAP = SNAPSHOT_CAP;
const arr = (x) => (Array.isArray(x) ? x : []);
-const rowHasRepo = (r) => !!(r && r.id != null && r.payload && r.payload.repoId);
-const rowHasId = (r) => !!(r && r.id != null && r.payload != null);
-const edgeOk = (e) => !!(e && e.id != null && e.source != null && e.target != null && e.label);
-const cacheOk = (c) => !!(c && c.repoId && c.platform);
-const collectionOk = (c) => !!(c && c.id != null && c.payload && typeof c.payload.name === 'string');
-const decisionOk = (d) => !!(d && d.id != null && d.payload && d.payload.repoId && d.payload.decision);
-const snapshotOk = (r) => !!(r && r.id != null && r.repoId && Array.isArray(r.snaps));
-const sceneOk = (s) => !!(s && s.id && s.scope && Array.isArray(s.nodes) && Array.isArray(s.edges));
+const shortString = (x, max = MAX_STRING_LENGTHS.scalar) => typeof x !== 'string' || x.length <= max;
+const shortId = (x) => shortString(String(x ?? ''), MAX_STRING_LENGTHS.id);
+const shortRepoId = (x) => typeof x === 'string' && x.length > 0 && x.length <= MAX_STRING_LENGTHS.repoId;
+const shortLabel = (x) => typeof x === 'string' && x.length > 0 && x.length <= MAX_STRING_LENGTHS.label;
+function stringsWithin(value, max = MAX_STRING_LENGTHS.scalar) {
+ if (typeof value === 'string') return value.length <= max;
+ if (!value || typeof value !== 'object') return true;
+ if (Array.isArray(value)) return value.every((v) => stringsWithin(v, max));
+ return Object.values(value).every((v) => stringsWithin(v, max));
+}
+const rowHasRepo = (r) =>
+ !!(
+ r &&
+ r.id != null &&
+ shortId(r.id) &&
+ r.payload &&
+ shortRepoId(r.payload.repoId) &&
+ stringsWithin(r.payload)
+ );
+const rowHasId = (r) =>
+ !!(r && r.id != null && shortId(r.id) && r.payload != null && stringsWithin(r.payload));
+const edgeOk = (e) =>
+ !!(
+ e &&
+ e.id != null &&
+ shortId(e.id) &&
+ e.source != null &&
+ shortId(e.source) &&
+ e.target != null &&
+ shortId(e.target) &&
+ shortLabel(e.label) &&
+ stringsWithin(e.properties || {})
+ );
+const cacheOk = (c) =>
+ !!(
+ c &&
+ shortRepoId(c.repoId) &&
+ typeof c.platform === 'string' &&
+ shortString(c.platform, 64) &&
+ stringsWithin(c)
+ );
+const collectionOk = (c) =>
+ !!(
+ c &&
+ c.id != null &&
+ shortId(c.id) &&
+ c.payload &&
+ typeof c.payload.name === 'string' &&
+ shortString(c.payload.name, 120) &&
+ stringsWithin(c.payload)
+ );
+const decisionOk = (d) =>
+ !!(
+ d &&
+ d.id != null &&
+ shortId(d.id) &&
+ d.payload &&
+ shortRepoId(d.payload.repoId) &&
+ d.payload.decision &&
+ stringsWithin(d.payload)
+ );
+const snapshotOk = (r) =>
+ !!(
+ r &&
+ r.id != null &&
+ shortId(r.id) &&
+ shortRepoId(r.repoId) &&
+ Array.isArray(r.snaps) &&
+ stringsWithin(r.snaps)
+ );
+const sceneOk = (s) =>
+ !!(
+ s &&
+ s.id &&
+ shortString(s.id, MAX_STRING_LENGTHS.id) &&
+ s.scope &&
+ shortString(s.scope, 64) &&
+ Array.isArray(s.nodes) &&
+ Array.isArray(s.edges) &&
+ stringsWithin(s)
+ );
/** Empty normalized shape — the safe fallback when a file can't be parsed. */
function emptyValue() {
- return { repos: [], nodes: [], edges: [], cache: [], collections: [], decisions: [], snapshots: [], scenes: [] };
+ return {
+ repos: [],
+ nodes: [],
+ edges: [],
+ cache: [],
+ collections: [],
+ decisions: [],
+ snapshots: [],
+ scenes: [],
+ };
}
/**
@@ -42,14 +142,47 @@ function emptyValue() {
* @param {{ repos?: object[], nodes?: object[], edges?: object[], cache?: object[], exportedAt?: string }} [parts]
* @returns {object}
*/
-export function buildBackup({ repos, nodes, edges, cache, collections, decisions, snapshots, scenes, exportedAt } = {}) {
- const r = arr(repos), n = arr(nodes), e = arr(edges), c = arr(cache), col = arr(collections), dec = arr(decisions), snap = arr(snapshots), sc = arr(scenes);
+export function buildBackup({
+ repos,
+ nodes,
+ edges,
+ cache,
+ collections,
+ decisions,
+ snapshots,
+ scenes,
+ exportedAt,
+} = {}) {
+ const r = arr(repos),
+ n = arr(nodes),
+ e = arr(edges),
+ c = arr(cache),
+ col = arr(collections),
+ dec = arr(decisions),
+ snap = arr(snapshots),
+ sc = arr(scenes);
return {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: exportedAt || new Date().toISOString(),
- counts: { repos: r.length, nodes: n.length, edges: e.length, cache: c.length, collections: col.length, decisions: dec.length, snapshots: snap.length, scenes: sc.length },
- repos: r, nodes: n, edges: e, cache: c, collections: col, decisions: dec, snapshots: snap, scenes: sc,
+ counts: {
+ repos: r.length,
+ nodes: n.length,
+ edges: e.length,
+ cache: c.length,
+ collections: col.length,
+ decisions: dec.length,
+ snapshots: snap.length,
+ scenes: sc.length,
+ },
+ repos: r,
+ nodes: n,
+ edges: e,
+ cache: c,
+ collections: col,
+ decisions: dec,
+ snapshots: snap,
+ scenes: sc,
};
}
@@ -64,7 +197,12 @@ export function buildBackup({ repos, nodes, edges, cache, collections, decisions
export function validateBackup(obj) {
const errors = [];
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
- return { ok: false, errors: ['Not a RepoLens backup file (empty or not a JSON object).'], warnings: [], value: emptyValue() };
+ return {
+ ok: false,
+ errors: ['Not a RepoLens backup file (empty or not a JSON object).'],
+ warnings: [],
+ value: emptyValue(),
+ };
}
if (obj.format !== BACKUP_FORMAT) {
errors.push(`Unrecognized file — expected a "${BACKUP_FORMAT}" export.`);
@@ -73,7 +211,9 @@ export function validateBackup(obj) {
if (!Number.isFinite(version) || version < 1) {
errors.push('Missing or invalid backup version.');
} else if (version > BACKUP_VERSION) {
- errors.push(`This backup is from a newer RepoLens (format v${version}); update the extension to import it.`);
+ errors.push(
+ `This backup is from a newer RepoLens (format v${version}); update the extension to import it.`
+ );
}
const warnings = [];
const clamp = (key, list) => {
@@ -87,22 +227,30 @@ export function validateBackup(obj) {
const filterWarn = (key, list, ok) => {
const kept = list.filter(ok);
const dropped = list.length - kept.length;
- if (dropped > 0) warnings.push(`Backup has ${dropped} invalid ${key} row${dropped === 1 ? '' : 's'}; skipping ${dropped === 1 ? 'it' : 'them'}.`);
+ if (dropped > 0)
+ warnings.push(
+ `Backup has ${dropped} invalid ${key} row${dropped === 1 ? '' : 's'}; skipping ${dropped === 1 ? 'it' : 'them'}.`
+ );
return kept;
};
const value = {
- repos: clamp('repos', arr(obj.repos).filter(rowHasRepo)),
- nodes: clamp('nodes', arr(obj.nodes).filter(rowHasId)),
- edges: clamp('edges', arr(obj.edges).filter(edgeOk)),
- cache: clamp('cache', arr(obj.cache).filter(cacheOk)),
- collections: clamp('collections', arr(obj.collections).filter(collectionOk)),
- decisions: clamp('decisions', arr(obj.decisions).filter(decisionOk)),
- snapshots: clamp('snapshots', arr(obj.snapshots).filter(snapshotOk).map((r) => ({
- ...r,
- // Trim to the cap and coerce each snap's flags to an array — a corrupt/hostile
- // file may carry a non-array `flags` that would later throw in snapshotTrend.
- snaps: arr(r.snaps).slice(-SNAP_CAP).map((s) => (s && typeof s === 'object' ? { ...s, flags: arr(s.flags) } : s)),
- }))),
+ repos: clamp('repos', filterWarn('repo', arr(obj.repos), rowHasRepo)),
+ nodes: clamp('nodes', filterWarn('node', arr(obj.nodes), rowHasId)),
+ edges: clamp('edges', filterWarn('edge', arr(obj.edges), edgeOk)),
+ cache: clamp('cache', filterWarn('cache', arr(obj.cache), cacheOk)),
+ collections: clamp('collections', filterWarn('collection', arr(obj.collections), collectionOk)),
+ decisions: clamp('decisions', filterWarn('decision', arr(obj.decisions), decisionOk)),
+ snapshots: clamp(
+ 'snapshots',
+ filterWarn('snapshot', arr(obj.snapshots), snapshotOk).map((r) => ({
+ ...r,
+ // Trim to the cap and coerce each snap's flags to an array — a corrupt/hostile
+ // file may carry a non-array `flags` that would later throw in snapshotTrend.
+ snaps: arr(r.snaps)
+ .slice(-SNAP_CAP)
+ .map((s) => (s && typeof s === 'object' ? { ...s, flags: arr(s.flags) } : s)),
+ }))
+ ),
scenes: clamp('scenes', filterWarn('scene', arr(obj.scenes), sceneOk)),
};
return { ok: errors.length === 0, errors, warnings, value };
@@ -116,7 +264,16 @@ export function validateBackup(obj) {
*/
export function summarizeBackup(obj) {
const { value } = validateBackup(obj);
- return { repos: value.repos.length, nodes: value.nodes.length, edges: value.edges.length, cache: value.cache.length, collections: value.collections.length, decisions: value.decisions.length, snapshots: value.snapshots.length, scenes: value.scenes.length };
+ return {
+ repos: value.repos.length,
+ nodes: value.nodes.length,
+ edges: value.edges.length,
+ cache: value.cache.length,
+ collections: value.collections.length,
+ decisions: value.decisions.length,
+ snapshots: value.snapshots.length,
+ scenes: value.scenes.length,
+ };
}
/**
diff --git a/batch.html b/batch.html
index ebe01d2..11898fd 100644
--- a/batch.html
+++ b/batch.html
@@ -1,139 +1,378 @@
-
+
-
-
-
-RepoLens — Batch Scan
-
-
-
-
-
-
📦
-
-
Batch Scan
-
Scan multiple repos and populate your library in one go
Supports GitHub, GitLab, npm, and PyPI. Repos are scanned sequentially to respect rate limits. Each scan uses one AI call and saves automatically to your library.
+ Supports GitHub, GitLab, npm, and PyPI. Repos are scanned sequentially to respect rate limits. Each
+ scan uses one AI call and saves automatically to your library.
+