From 6ecff43dd6b412e52b2f194ddb38182ea243807c Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:57:13 -0700 Subject: [PATCH 01/20] docs(spec): Concept Substrate (Pillar 3, Spec 2a) design --- .../2026-06-16-concept-substrate-design.html | 138 ++++++++++++++++++ .../2026-06-16-concept-substrate-design.md | 111 ++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-concept-substrate-design.html create mode 100644 docs/superpowers/specs/2026-06-16-concept-substrate-design.md diff --git a/docs/superpowers/specs/2026-06-16-concept-substrate-design.html b/docs/superpowers/specs/2026-06-16-concept-substrate-design.html new file mode 100644 index 0000000..90c3fd3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-concept-substrate-design.html @@ -0,0 +1,138 @@ + + + + + + Concept Substrate — Pillar 3, Spec 2a + + + +
+ +
+

Concept Substrate

+
+ Pillar 3 · Spec 2a (the Knowledge Graph) · 2026-06-16 · Status: Approved (design) — pending plan
+ Surface: the deep-dive pipeline + a new pure concept module + the local graph store. No user-facing UI in 2a. +
+
+ Spec 2 decomposition: +
2a — Concept substrate ← this (persist atoms, index concepts, link repos by shared concepts)
+
2b — Concept connections in the UI (corkboard / ego-graph + mastery annotation)
+
2c — Anchor-to-library explanation (explain a new repo against repos you know)
+
+
+ +

The gap

+

RepoLens already has a persistent graph (the nodes/edges stores) shown as the Corkboard and the per-repo Connections ego-graph — but repos are linked only by relationships (ALTERNATIVE_TO, SYNERGIZES_WITH, COMPARED_TO, COMBINES), never by shared concepts. The deep dive produces rich per-repo atoms ({id,name,kind,purpose,files}), uses them for the blueprint, then discards them. So nothing answers "which repos touch event-sourcing?" That substrate is the missing foundation for "plug pieces together, think in new ways."

+ +

Goals

+ + +

Non-goals

+ + +
+ Two departures to call out. +
    +
  1. 2a touches background.js and adds a new AI call (the embeddings request) — a deliberate departure from the Mastery Loop's "no background/AI changes," the direct consequence of choosing embeddings. Still no hosted backend: the call goes to the BYO provider, vectors cache in IDB, cosine is local.
  2. +
  3. 2a ships nothing the user sees — the visible payoff (concept links on the Corkboard) is 2b. Output here = persisted data + the link API, validated by unit tests. Build order within 2a: lexical substrate first (the fallback), then embeddings layered on.
  4. +
+
+ +

Components

+ +
+ ① Persist atoms — new concepts IDB store (v6→v7) +

On deep-dive completion, persist atoms (best-effort — never fails the scan), keyed by raw repoId (mirrors decisions/mastery):

+
concepts[repoId] = {
+  repoId,
+  atoms: [{ id, name, kind, purpose, files }],   // from parseAtoms
+  vectors: number[][] | null,                     // per-atom embedding (aligned), null when none
+  embedModel: string | null,
+  computedAt,
+}
+

store.js CRUD (no compute in the store): getConcepts, getAllConcepts, setConcepts.

+
+ +
+ ② Pure logic — concepts.js (no DOM/network/AI) + +
+ +
+ ③ Embeddings path — provider-gated + +
+ +

Architecture (files)

+ + +

Testing

+ + +

Acceptance criteria

+ + +
+ Resolved decisions: slice = 2a substrate; matcher = hybrid (embeddings + lexical, per-repo); build lexical-first then embeddings; atoms → new concepts IDB store (v7), CRUD-only; embeddings via an OpenAI-compatible /embeddings call in background.js, provider-gated, vectors + cosine local; no UI in 2a. +
+ + + +
+ + diff --git a/docs/superpowers/specs/2026-06-16-concept-substrate-design.md b/docs/superpowers/specs/2026-06-16-concept-substrate-design.md new file mode 100644 index 0000000..ff6fbbf --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-concept-substrate-design.md @@ -0,0 +1,111 @@ +# Concept Substrate (Pillar 3, Spec 2a) + +- **Date:** 2026-06-16 +- **Status:** Approved (design) — pending implementation plan +- **Surface:** the deep-dive pipeline (persist + embed atoms) + a new pure concept module + the local graph store. **No user-facing UI** in 2a. +- **Part of:** **Pillar 3 — The Knowledge Game**, Spec 2 (the Knowledge Graph). Spec 1 (the Mastery Loop) shipped (PR #37). + - **2a — Concept substrate** ← *this* (persist atoms, index concepts, link repos by shared concepts) + - **2b — Concept connections in the UI** (surface shared-concept links on the corkboard / ego-graph; annotate nodes with mastery) + - **2c — Anchor-to-library explanation** (output tab: explain a new repo against repos you know) + +## Problem / the gap + +RepoLens already has a persistent graph (the `nodes`/`edges` IDB stores) and surfaces it as the **Corkboard** (a library-wide canvas of repos + ideas joined by relationship edges: `ALTERNATIVE_TO`, `SYNERGIZES_WITH`, `COMPARED_TO`, `COMBINES`) and the per-repo **Connections** ego-graph. But repos are linked only by those *relationships* — **never by shared concepts**. + +The deep dive produces rich per-repo semantic **atoms** (`{id, name, kind, purpose, files}`) via `parseAtoms` (`deepdive.js`), uses them for the blueprint, then **discards them** — they are never persisted or compared across repos. So there is no data answering "which repos touch event-sourcing?" This substrate is the missing foundation for the "plug pieces together, think in new ways" vision, and for annotating the graph with the just-shipped mastery signal. + +## Goals + +- **Persist** each repo's deep-dive atoms locally (they're already produced — no new call for the atoms themselves). +- **Link repos by shared concepts** via a pure, pluggable matcher: **embeddings** when the provider supports them, **lexical** fallback otherwise. +- Expose a clean **link/index API** (`deriveConceptLinks`, `conceptIndex`) that 2b (visualize) and 2c (anchor) consume. + +## Non-goals + +- **No user-facing UI** in 2a — that is 2b. 2a is data + API, validated by tests. +- No hosted backend (embeddings go to the user's BYO provider; vectors are cached in IDB; cosine is local JS). +- No changes to the existing relationship edges / Corkboard / Connections rendering (2b territory). + +## Decisions (resolved in brainstorming) + +- First slice = **2a (substrate)** — the genuinely missing capability and the foundation. +- Matcher = **hybrid**: embeddings where the configured provider exposes an embeddings endpoint; lexical (normalized-tag + token-overlap) fallback otherwise. Per-repo fallback (a repo with vectors uses embeddings; without, lexical). +- **Build order within 2a:** the lexical substrate first (the fallback path, works everywhere, no AI/background changes), then the embeddings path layered on. + +## Two explicit departures to call out + +1. **2a touches `background.js` and adds a new AI call** (the embeddings request) — a deliberate departure from the Mastery Loop's "no background/AI changes." It is the direct consequence of choosing embeddings. There is still **no hosted backend**: the `/embeddings` call goes to the user's configured provider (BYO-key), vectors are cached in IDB, and all matching/cosine is local JS. +2. **2a ships nothing the user sees.** The visible payoff (concept links on the Corkboard) is 2b. This spec is the substrate; its "output" is persisted data + the link API, validated by unit tests. + +## Components + +### ① Persist atoms — new `concepts` IDB store + +On deep-dive completion (the deep-dive runner in `background.js`), persist the atoms (already produced) to a new `concepts` store (additive: `store/idb.js` DB_VERSION 6→7, add `'concepts'` to `STORES`). Best-effort write — a ledger write must never fail a scan (mirrors `appendScanSnapshot`). + +Record, keyed by raw repoId (mirrors `decisions`/`mastery`): +``` +concepts[repoId] = { + repoId, + atoms: [{ id, name, kind, purpose, files }], // from parseAtoms + vectors: number[][] | null, // per-atom embedding (aligned to atoms), null when none + embedModel: string | null, // model that produced vectors, e.g. 'text-embedding-3-small' + computedAt: string, // ISO +} +``` +`store.js` CRUD (no compute in the store): `getConcepts(repoId)`, `getAllConcepts()` (→ `{repoId: record}` map), `setConcepts(repoId, record)`. + +### ② Pure concept logic — `concepts.js` (no DOM / network / AI) + +Fully unit-testable. Exports: +- `normalizeConcept(atom)` → a canonical lexical key (lowercase, strip punctuation, drop stopwords). Used by the lexical matcher. +- `cosineSimilarity(vecA, vecB)` → number in [-1, 1] (0 for zero/length-mismatched vectors). +- **Matchers** (pure functions over the records map), behind one interface: + - `lexicalMatcher` — builds a concept→repos index from normalized atom keys (with a light token-overlap merge of near-duplicate keys via `store/search.js`), then links repos sharing ≥1 concept. + - `embeddingMatcher(threshold)` — links repos that have a cross-repo atom pair with `cosineSimilarity >= threshold` (default tunable, e.g. 0.82). +- `deriveConceptLinks(records, { matcher })` → `[{ a: repoId, b: repoId, shared: string[], score: number }]`. Per-repo hybrid selection: use the embedding matcher between two repos only when **both** have `vectors`; otherwise fall back to lexical for that pair. `shared` is the linking labels — **lexical**: the shared normalized concept keys; **embedding**: the names of the matched atom pair(s). +- `conceptIndex(records)` → `{ conceptLabel: repoId[] }` over the **lexical** normalized concepts (named concept → repos; what 2b/2c read to show "N repos touch X"). Note: this index is lexical-only because embeddings link repo *pairs* without producing discrete concept labels — so `conceptIndex` takes no matcher, while `deriveConceptLinks` is the hybrid repo-linker. + +### ③ Embeddings path — provider-gated (the sharp matcher) + +- **Capability:** extend the provider registry (`providers.js`) with embeddings support per provider — `{ embeddingsEndpoint, embeddingsModel }` for those that have one (OpenAI → `text-embedding-3-small`; Google → `text-embedding-004`; most OpenAI-compatible → their `/embeddings`); none for Anthropic and any without an endpoint. A helper `providerSupportsEmbeddings(id)`. +- **Call:** new `callEmbeddings(texts)` in `background.js` — an OpenAI-compatible `/embeddings` POST using the configured provider's key + model, wrapped in the same hard timeout as `callAI`. Returns `number[][]` aligned to `texts`. Errors degrade to `null` vectors (→ lexical fallback), never breaking the deep dive. +- **When:** on deep-dive completion, if `providerSupportsEmbeddings(configured)`, embed each atom's `name + ' — ' + purpose` and store the vectors; else store `vectors: null`. + +## Architecture (files) + +- **Create** `concepts.js` — pure model (normalize, cosine, matchers, `deriveConceptLinks`, `conceptIndex`). One responsibility: concept indexing/linking. +- **Create** `tests/concepts.test.js`. +- **Modify** `store/idb.js` — v6→v7, add `'concepts'` store. +- **Modify** `store.js` — `getConcepts` / `getAllConcepts` / `setConcepts` (CRUD) + a store test (`tests/store-concepts.test.js`). +- **Modify** `providers.js` — embeddings capability metadata + `providerSupportsEmbeddings`. +- **Modify** `background.js` — persist atoms on deep-dive completion; `callEmbeddings`; embed-on-deep-dive (provider-gated, best-effort). + +## Testing + +- **vitest (pure):** `concepts.js` — `cosineSimilarity` (orthogonal=0, identical=1, length-mismatch=0), `normalizeConcept`, `lexicalMatcher` (shared-key linking + near-dup merge), `embeddingMatcher` (links above threshold using hand-built vectors), `deriveConceptLinks` hybrid selection (both-have-vectors → embedding; else lexical), `conceptIndex` shape. +- **vitest + fake-indexeddb:** concepts CRUD round-trip; v7 store creation. +- **Provider/AI:** `callEmbeddings` tested with a mocked `global.fetch` (mirrors `tests/fetcher.test.js`), incl. the error→null path; `providerSupportsEmbeddings` table tests. +- Existing suite green; `eslint .` 0 errors; HTML gate passes; `node --check` on touched files. + +## Constraints + +- Zero-build, zero-dep, vanilla ES modules. Embeddings via the BYO-key provider; vectors in IDB; cosine local — **no hosted backend**. +- `background.js`/AI changes are scoped to the embeddings path only; the existing scan/deep-dive/lens contracts are untouched. +- Mono Ink etc. apply once 2b adds UI (none here). + +## Acceptance criteria + +- [ ] After a deep dive, the repo's atoms are persisted to the `concepts` store (best-effort; failure never breaks the scan). +- [ ] With an embeddings-capable provider configured, atom vectors are computed + cached; with Anthropic (or any without an endpoint), `vectors` is `null` and the lexical matcher is used. +- [ ] `deriveConceptLinks` links repos by shared concepts and reports the shared concept labels; hybrid selection uses embeddings only when both repos have vectors, else lexical. +- [ ] `concepts.js` is fully unit-tested (cosine, normalize, both matchers, hybrid selection, index); concepts persistence tested with fake-indexeddb; `callEmbeddings` tested against a mocked fetch incl. the error→null fallback. +- [ ] No hosted backend added; the embeddings call is the only new provider call; existing scan/lens contracts unchanged. +- [ ] All existing tests pass + new tests; `eslint .` 0 errors; HTML gate passes. + +## Resolved decisions + +- Slice = 2a substrate; matcher = hybrid (embeddings + lexical fallback, per-repo); build lexical-first then embeddings. +- Atoms persisted to a new `concepts` IDB store (v7), keyed by raw repoId, CRUD-only store API. +- Embeddings via an OpenAI-compatible `/embeddings` call in `background.js`, provider-gated; vectors local; cosine local. +- No UI in 2a (deferred to 2b). From c330dd1f05e17d2d64d31877f26af3e797c93d90 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:06:59 -0700 Subject: [PATCH 02/20] docs(plan): Concept Substrate implementation plan (Pillar 3, Spec 2a) --- .../plans/2026-06-16-concept-substrate.html | 110 ++++ .../plans/2026-06-16-concept-substrate.md | 596 ++++++++++++++++++ 2 files changed, 706 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-concept-substrate.html create mode 100644 docs/superpowers/plans/2026-06-16-concept-substrate.md diff --git a/docs/superpowers/plans/2026-06-16-concept-substrate.html b/docs/superpowers/plans/2026-06-16-concept-substrate.html new file mode 100644 index 0000000..9c55da0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-concept-substrate.html @@ -0,0 +1,110 @@ + + + + + + Concept Substrate — Implementation Plan + + + +
+ +
+

Concept Substrate

+
+ Implementation plan · Pillar 3 / Spec 2a · 2026-06-16 · branch feat/concept-substrate
+ Overview for review. Full step-by-step code (TDD steps, commands, commits) lives in the companion .md. +
+
+ +
+ Goal. Persist deep-dive atoms per repo and link repos by shared concepts — lexical everywhere, embeddings where the provider supports them — exposing a link/index API for the later UI (2b) and anchor explanations (2c). No user-visible surface in 2a; no hosted backend. +
+ +

Build order (two phases, one plan)

+ + +
+ v1 embeddings scope: OpenAI-protocol compat providers (OpenAI is in COMPAT_PROVIDERS; plus any tagged with an embeddings model). The 5 first-class providers (Anthropic/Google/OpenRouter/xAI/Nous) use a separate call path and fall back to lexical in v1 — Google embeddings is a deliberate follow-up (the spec mentioned it; narrowed to stay bounded). +
+ +
+ Phase 1 — Lexical substrate +
+
Task 1 · concepts.js + tests create
+
concepts.js, tests/concepts.test.js
+
Full TDD: normalizeConcept, cosineSimilarity, conceptIndex (lexical), lexicalMatcher, bestEmbeddingMatch, deriveConceptLinks (per-pair hybrid: both-have-vectors → embedding, else lexical).
+
+
+
Task 2 · concepts IDB store + CRUD modify
+
store/idb.js (v6→v7), store.js, tests/store-concepts.test.js
+
getConcepts / getAllConcepts / setConcepts, mirroring mastery; round-trip tested with fake-indexeddb.
+
+
+
Task 3 · Persist atoms on deep-dive completion modify
+
background.js (runDeepDive, ~L806)
+
Best-effort setConcepts(repoId, {atoms, vectors:null, …}) before status:'done'. Never fails the dive.
+
+
+ +
+ Phase 2 — Embeddings path +
+
Task 4 · Provider embeddings capability modify
+
providers.js, tests/concepts-embeddings.test.js
+
embeddingsModel on OpenAI's entry + providerSupportsEmbeddings, compatEmbeddingsEndpoint (derive /embeddings from the chat URL), embeddingsModelFor, pickEmbeddingsProvider.
+
+
+
Task 5 · callEmbeddings + embed-on-deep-dive modify
+
background.js, providers.js (pure body/parse), tests
+
Pure embeddingsBody/parseEmbeddings in providers.js (testable); callEmbeddings wraps fetchWithTimeout (errors → null → lexical); runDeepDive embeds atom text when a provider supports it.
+
+
+ +
+ Phase 3 — Verification +
+
Task 6 · Full pass
+
vitest run (prior + 3 new test files) · eslint . 0 errors · check:html · node --check · manual note (OpenAI key → vectors cached; Anthropic → null → lexical; no user surface yet).
+
+
+ +

Spec coverage

+ + +

Out of scope (per spec)

+

UI (2b), anchor-to-library (2c), embeddings for first-class providers incl. Google (v1 = OpenAI-protocol compat providers).

+ + + +
+ + diff --git a/docs/superpowers/plans/2026-06-16-concept-substrate.md b/docs/superpowers/plans/2026-06-16-concept-substrate.md new file mode 100644 index 0000000..4555621 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-concept-substrate.md @@ -0,0 +1,596 @@ +# Concept Substrate — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist deep-dive atoms per repo and link repos by shared concepts — lexical matching everywhere, embeddings where the provider supports them — exposing a link/index API the later Knowledge-Graph UI (2b) and anchor explanations (2c) consume. + +**Architecture:** A new pure module `concepts.js` does all indexing/linking/cosine math (fully unit-tested). `store.js` gains a new IDB `concepts` store (CRUD). The deep-dive runner in `background.js` persists atoms on completion. Then a provider-gated embeddings path (new `callEmbeddings` + `providers.js` capability metadata) caches per-atom vectors when the configured provider exposes an embeddings endpoint; the pure module's per-pair hybrid uses vectors when both repos have them, else lexical. No hosted backend; cosine is local JS. + +**Tech Stack:** Vanilla ES modules (zero-build, no deps), Vitest (+ `fake-indexeddb`), mocked `global.fetch` for the provider call, `node --check` + `npm run check:html` for glue. + +--- + +## Build order (two phases in one plan) + +- **Phase 1 (Tasks 1–3) — lexical substrate.** `concepts.js` (incl. the cosine/embedding math as pure functions), the `concepts` store, and atom persistence (`vectors: null`). No AI, no provider/`background.js` call changes beyond persisting atoms. Ships a working lexical concept graph + the hybrid's fallback path. +- **Phase 2 (Tasks 4–5) — embeddings path.** `providers.js` capability + `callEmbeddings` + embed-on-deep-dive, so vectors get cached when supported. **v1 scope: OpenAI-protocol compat providers** (OpenAI is in `COMPAT_PROVIDERS`; plus any tagged with an embeddings model). The 5 first-class providers (Anthropic/Google/OpenRouter/xAI/Nous) use a separate call path and fall back to lexical in v1 — Google embeddings is a clean follow-up. (Spec mentioned Google; this narrowing is deliberate to stay bounded — flagged here.) + +## File Structure + +- **Create** `concepts.js` — pure: `normalizeConcept`, `cosineSimilarity`, `conceptIndex`, `lexicalMatcher`, `bestEmbeddingMatch`, `deriveConceptLinks`. +- **Create** `tests/concepts.test.js`. +- **Modify** `store/idb.js` — v6→v7, add `'concepts'`. +- **Modify** `store.js` — `getConcepts` / `getAllConcepts` / `setConcepts` (CRUD). **Create** `tests/store-concepts.test.js`. +- **Modify** `background.js` — persist atoms on deep-dive completion (Phase 1); `callEmbeddings` + embed-on-deep-dive (Phase 2). +- **Modify** `providers.js` — embeddings capability metadata + helpers (Phase 2). **Create** `tests/concepts-embeddings.test.js`. + +--- + +## Phase 1 — Lexical substrate + +### Task 1: `concepts.js` + tests + +**Files:** +- Create: `concepts.js` +- Test: `tests/concepts.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/concepts.test.js +import { describe, it, expect } from 'vitest'; +import { + normalizeConcept, cosineSimilarity, conceptIndex, + lexicalMatcher, bestEmbeddingMatch, deriveConceptLinks, +} from '../concepts.js'; + +const rec = (repoId, names, vectors = null) => ({ + repoId, vectors, + atoms: names.map((n, i) => ({ id: `a${i}`, name: n, purpose: `does ${n}` })), +}); + +describe('normalizeConcept', () => { + it('lowercases, strips punctuation, drops stopwords', () => { + expect(normalizeConcept({ name: 'The Routing Layer!' })).toBe('routing'); + expect(normalizeConcept({ name: 'Auth/Session' })).toBe('auth-session'); + }); +}); + +describe('cosineSimilarity', () => { + it('is 1 for identical, 0 for orthogonal, 0 for mismatched/empty', () => { + expect(cosineSimilarity([1, 0], [1, 0])).toBeCloseTo(1); + expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0); + expect(cosineSimilarity([1, 2, 3], [1, 2])).toBe(0); + expect(cosineSimilarity([], [])).toBe(0); + }); +}); + +describe('conceptIndex (lexical)', () => { + it('maps each normalized concept to the repos that have it', () => { + const recs = { x: rec('a/x', ['Router', 'Cache']), y: rec('c/y', ['router', 'Queue']) }; + const idx = conceptIndex(recs); + expect(idx['router'].sort()).toEqual(['a/x', 'c/y']); + expect(idx['cache']).toEqual(['a/x']); + }); +}); + +describe('lexicalMatcher', () => { + it('links repos sharing a normalized concept, scored by overlap', () => { + const recs = { x: rec('a/x', ['Router', 'Cache']), y: rec('c/y', ['Routing', 'Cache']) }; + const links = lexicalMatcher(recs); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ a: 'a/x', b: 'c/y', shared: ['cache'], score: 1 }); + }); +}); + +describe('bestEmbeddingMatch', () => { + it('returns the best atom-pair label when above threshold, else null', () => { + const a = rec('a/x', ['Router'], [[1, 0]]); + const b = rec('c/y', ['Dispatch'], [[1, 0]]); + const m = bestEmbeddingMatch(a, b, 0.82); + expect(m.score).toBeCloseTo(1); + expect(m.label).toBe('Router ~ Dispatch'); + const far = rec('e/z', ['X'], [[0, 1]]); + expect(bestEmbeddingMatch(a, far, 0.82)).toBeNull(); + }); +}); + +describe('deriveConceptLinks (per-pair hybrid)', () => { + it('uses embeddings when BOTH repos have vectors', () => { + const recs = { + x: rec('a/x', ['Router'], [[1, 0]]), + y: rec('c/y', ['Dispatch'], [[1, 0]]), + }; + const links = deriveConceptLinks(recs, { threshold: 0.82 }); + expect(links).toHaveLength(1); + expect(links[0].score).toBeCloseTo(1); + expect(links[0].shared).toEqual(['Router ~ Dispatch']); + }); + + it('falls back to lexical when either repo lacks vectors', () => { + const recs = { + x: rec('a/x', ['Cache'], [[1, 0]]), // has vectors + y: rec('c/y', ['Cache'], null), // no vectors → lexical for this pair + }; + const links = deriveConceptLinks(recs, { threshold: 0.82 }); + expect(links).toHaveLength(1); + expect(links[0].shared).toEqual(['cache']); + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `npx vitest run tests/concepts.test.js` +Expected: FAIL — `Cannot find module '../concepts.js'`. + +- [ ] **Step 3: Write the module** + +```js +// concepts.js +// Pure concept model for the Knowledge Graph. Indexes deep-dive atoms across the +// library and links repos by shared concepts. No DOM/network/AI — the embedding +// VECTORS are produced in background.js (when the provider supports it); this +// module only does the math/matching, so it stays fully unit-testable. + +const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'or', 'for', 'to', 'in', 'on', 'with', 'is', 'it', 'its', 'that', 'this', 'layer', 'module', 'system', 'core']); + +/** Canonical lexical key for an atom (lowercase, strip punctuation, drop stopwords). */ +export function normalizeConcept(atom) { + const raw = ((atom && (atom.name || atom.id)) || '').toLowerCase(); + const tokens = raw.replace(/[^a-z0-9\s-]/g, ' ').split(/[\s-]+/).filter((t) => t && !STOPWORDS.has(t)); + return tokens.join('-'); +} + +/** Cosine similarity of two equal-length numeric vectors; 0 for empty/mismatched. */ +export function cosineSimilarity(a, b) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return 0; + let dot = 0, na = 0, nb = 0; + for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } + if (na === 0 || nb === 0) return 0; + return dot / (Math.sqrt(na) * Math.sqrt(nb)); +} + +export const EMBED_THRESHOLD = 0.82; + +const keysOf = (rec) => new Set(((rec && rec.atoms) || []).map(normalizeConcept).filter(Boolean)); + +/** Lexical concept → repos index. */ +export function conceptIndex(records) { + const idx = {}; + for (const rec of Object.values(records || {})) { + if (!rec || !rec.repoId) continue; + for (const k of keysOf(rec)) (idx[k] ||= []).push(rec.repoId); + } + return idx; +} + +/** Lexical matcher: link repos sharing >=1 normalized concept key. */ +export function lexicalMatcher(records) { + const recs = Object.values(records || {}).filter((r) => r && r.repoId); + const links = []; + for (let i = 0; i < recs.length; i++) { + for (let j = i + 1; j < recs.length; j++) { + const ka = keysOf(recs[i]); + const shared = [...keysOf(recs[j])].filter((k) => ka.has(k)); + if (shared.length) links.push({ a: recs[i].repoId, b: recs[j].repoId, shared, score: shared.length }); + } + } + return links; +} + +/** Best cross-repo atom-pair match by cosine; { score, label } if >= threshold, else null. */ +export function bestEmbeddingMatch(recA, recB, threshold = EMBED_THRESHOLD) { + const va = recA && recA.vectors, vb = recB && recB.vectors; + if (!Array.isArray(va) || !Array.isArray(vb) || !va.length || !vb.length) return null; + let best = { score: 0, label: null }; + for (let i = 0; i < va.length; i++) { + for (let j = 0; j < vb.length; j++) { + const s = cosineSimilarity(va[i], vb[j]); + if (s > best.score) best = { score: s, label: `${recA.atoms[i]?.name} ~ ${recB.atoms[j]?.name}` }; + } + } + return best.score >= threshold ? best : null; +} + +/** + * Link repos by shared concepts. Per-pair hybrid: when BOTH repos have vectors, + * use the embedding matcher; otherwise lexical for that pair. + * @returns {{a:string,b:string,shared:string[],score:number}[]} + */ +export function deriveConceptLinks(records, { threshold = EMBED_THRESHOLD } = {}) { + const recs = Object.values(records || {}).filter((r) => r && r.repoId); + const links = []; + for (let i = 0; i < recs.length; i++) { + for (let j = i + 1; j < recs.length; j++) { + const a = recs[i], b = recs[j]; + const bothVec = Array.isArray(a.vectors) && a.vectors.length && Array.isArray(b.vectors) && b.vectors.length; + if (bothVec) { + const m = bestEmbeddingMatch(a, b, threshold); + if (m) links.push({ a: a.repoId, b: b.repoId, shared: [m.label], score: m.score }); + } else { + const ka = keysOf(a); + const shared = [...keysOf(b)].filter((k) => ka.has(k)); + if (shared.length) links.push({ a: a.repoId, b: b.repoId, shared, score: shared.length }); + } + } + } + return links; +} +``` + +- [ ] **Step 4: Run tests + confirm pass** + +Run: `npx vitest run tests/concepts.test.js` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +Run: `npx eslint concepts.js tests/concepts.test.js` (0 errors) +```bash +git add concepts.js tests/concepts.test.js +git commit -m "feat(concepts): pure concept index + lexical/embedding link matchers" +``` + +### Task 2: `concepts` IDB store + CRUD + +**Files:** +- Modify: `store/idb.js:9-10` +- Modify: `store.js` (after the mastery section) +- Test: `tests/store-concepts.test.js` + +- [ ] **Step 1: Register the store** + +In `store/idb.js`, replace the version + STORES lines: + +```js +// v6 added 'mastery'. v7 added 'concepts' (the Knowledge-Graph concept substrate). +// Each upgrade is additive — onupgradeneeded creates any new store, data survives. +const DB_VERSION = 7; +const STORES = ['repos', 'nodes', 'edges', 'collections', 'decisions', 'snapshots', 'scenes', 'mastery', 'concepts']; +``` + +- [ ] **Step 2: Write the failing test** + +```js +// tests/store-concepts.test.js +import { describe, it, expect } from 'vitest'; +import 'fake-indexeddb/auto'; +import { setConcepts, getConcepts, getAllConcepts } from '../store.js'; + +describe('concepts persistence', () => { + it('round-trips a record by repoId', async () => { + const rec = { repoId: 'honojs/hono', atoms: [{ id: 'r', name: 'Router' }], vectors: null, embedModel: null, computedAt: '2026-06-16T00:00:00.000Z' }; + await setConcepts('honojs/hono', rec); + expect(await getConcepts('honojs/hono')).toEqual(rec); + }); + it('returns null for an unknown repo', async () => { + expect(await getConcepts('nope/none')).toBeNull(); + }); + it('getAllConcepts returns a repoId→record map', async () => { + await setConcepts('a/b', { repoId: 'a/b', atoms: [] }); + const map = await getAllConcepts(); + expect(map['a/b'].repoId).toBe('a/b'); + }); +}); +``` + +- [ ] **Step 3: Run it and confirm it fails** + +Run: `npx vitest run tests/store-concepts.test.js` +Expected: FAIL — `setConcepts is not a function`. + +- [ ] **Step 4: Add the store functions** + +In `store.js`, after the mastery section, add (mirrors `mastery`): + +```js +// ─── concepts: per-repo deep-dive atoms + embeddings (Knowledge Graph) ──────── + +/** Persist a repo's concept record (atoms [+ vectors]). Throws on failure. */ +export async function setConcepts(repoId, record) { + if (!repoId) throw new Error('setConcepts needs a repoId'); + await idbPut('concepts', { id: repoId, payload: record }); +} + +/** Get a repo's concept record, or null. */ +export async function getConcepts(repoId) { + try { + const row = await idbGet('concepts', repoId); + return (row && row.payload) || null; + } catch { + return null; + } +} + +/** All concept records as a { repoId: record } map. Best-effort — {} on failure. */ +export async function getAllConcepts() { + try { + const rows = await idbGetAll('concepts'); + const out = {}; + for (const r of rows || []) if (r && r.id) out[r.id] = r.payload; + return out; + } catch { + return {}; + } +} +``` + +- [ ] **Step 5: Run tests + lint + commit** + +Run: `npx vitest run tests/store-concepts.test.js` (PASS), `npx eslint store.js store/idb.js tests/store-concepts.test.js` (0 errors) +```bash +git add store/idb.js store.js tests/store-concepts.test.js +git commit -m "feat(concepts): IDB concepts store + CRUD persistence" +``` + +### Task 3: Persist atoms on deep-dive completion + +**Files:** +- Modify: `background.js` — `runDeepDive` (lines 767-810). + +- [ ] **Step 1: Read the runner** + +Read `background.js:767-810`. `atoms` is parsed at line 795; the dive completes at `setDeep({ status: 'done' })` (line 806). `detected.repoId` is in scope. `setConcepts` will be imported from `./store.js`. + +- [ ] **Step 2: Import setConcepts** + +At the top of `background.js`, add to the `./store.js` import group: + +```js +import { setConcepts } from './store.js'; +``` + +(If `background.js` already imports other names from `./store.js`, add `setConcepts` to that existing import list rather than a second statement.) + +- [ ] **Step 3: Persist after the dive succeeds** + +In `runDeepDive`, immediately before `await setDeep({ status: 'done' });` (line 806), add a best-effort concept persist (Phase 1: no vectors yet): + +```js + // Persist atoms for the Knowledge-Graph concept substrate (best-effort — + // a substrate write must never fail the dive). Vectors are added in Phase 2. + try { + await setConcepts(detected.repoId, { + repoId: detected.repoId, + atoms, + vectors: null, + embedModel: null, + computedAt: new Date().toISOString(), + }); + } catch { /* substrate is additive; ignore */ } +``` + +- [ ] **Step 4: Verify** + +Run: `node --check background.js` +Run: `npx vitest run` (full suite still green; no contract changed) +Manual note: after a deep dive, `getConcepts(repoId)` returns the atoms (verified indirectly via the store test + code review; no DOM test env). + +- [ ] **Step 5: Commit** + +```bash +git add background.js +git commit -m "feat(concepts): persist deep-dive atoms to the concept store" +``` + +--- + +## Phase 2 — Embeddings path + +### Task 4: Provider embeddings capability (`providers.js`) + +**Files:** +- Modify: `providers.js` — add an embeddings model to OpenAI's entry + helpers. +- Test: `tests/concepts-embeddings.test.js` (capability portion) + +- [ ] **Step 1: Read the registry helpers** + +Read `providers.js:18-30` (the `openai` entry), `:193-235` (`compatProviderById`, `provKeyName`/`provModelName`/`provBaseName`, `isCompatConnected`, `compatModelFor`), and `:263-280` (`compatEndpoint`, `normalizeOpenAiUrl`). Confirm `openai`'s `endpoint` is `https://api.openai.com/v1/chat/completions`. + +- [ ] **Step 2: Write the failing capability test** + +```js +// tests/concepts-embeddings.test.js +import { describe, it, expect } from 'vitest'; +import { providerSupportsEmbeddings, compatEmbeddingsEndpoint, embeddingsModelFor } from '../providers.js'; + +describe('embeddings capability', () => { + it('openai supports embeddings when connected (has a key)', () => { + expect(providerSupportsEmbeddings('openai', { openaiKey: 'sk-x' })).toBe(true); + expect(providerSupportsEmbeddings('openai', {})).toBe(false); // no key → not connected + }); + it('a provider without an embeddings model does not support it', () => { + expect(providerSupportsEmbeddings('groq', { groqKey: 'x' })).toBe(false); + }); + it('derives the /embeddings endpoint from the chat endpoint', () => { + expect(compatEmbeddingsEndpoint('openai', {})).toBe('https://api.openai.com/v1/embeddings'); + }); + it('embeddingsModelFor prefers an override then the default', () => { + expect(embeddingsModelFor('openai', {})).toBe('text-embedding-3-small'); + expect(embeddingsModelFor('openai', { openaiEmbedModel: 'text-embedding-3-large' })).toBe('text-embedding-3-large'); + }); +}); +``` + +- [ ] **Step 3: Run it + confirm fail** + +Run: `npx vitest run tests/concepts-embeddings.test.js` +Expected: FAIL — `providerSupportsEmbeddings is not a function`. + +- [ ] **Step 4: Add the capability metadata + helpers** + +In `providers.js`, add `embeddingsModel: 'text-embedding-3-small'` to the `openai` provider object (the entry starting `id: 'openai'`). Then add, near the other compat helpers: + +```js +export const provEmbedModelName = (id) => `${id}EmbedModel`; // optional embeddings-model override + +/** The embeddings model for a provider (override → registry default → ''). */ +export function embeddingsModelFor(id, keys = {}) { + const p = compatProviderById(id); + return (keys[provEmbedModelName(id)] || (p && p.embeddingsModel) || '').trim(); +} + +/** Derive the POST-able /embeddings URL from the provider's chat endpoint. '' when unknown. */ +export function compatEmbeddingsEndpoint(id, keys = {}) { + const chat = compatEndpoint(id, keys); // e.g. .../v1/chat/completions + if (!chat) return ''; + return chat.replace(/\/chat\/completions(\?.*)?$/, '/embeddings'); +} + +/** True when an OpenAI-protocol provider is connected AND has an embeddings model. */ +export function providerSupportsEmbeddings(id, keys = {}) { + return compatProtocol(id, keys) === 'openai' + && !!embeddingsModelFor(id, keys) + && isCompatConnected(id, keys); +} + +/** First connected provider that supports embeddings → { id, endpoint, key, model }, or null. */ +export function pickEmbeddingsProvider(keys = {}) { + for (const p of COMPAT_PROVIDERS) { + if (providerSupportsEmbeddings(p.id, keys)) { + return { id: p.id, endpoint: compatEmbeddingsEndpoint(p.id, keys), key: keys[provKeyName(p.id)], model: embeddingsModelFor(p.id, keys) }; + } + } + return null; +} +``` + +- [ ] **Step 5: Run tests + lint + commit** + +Run: `npx vitest run tests/concepts-embeddings.test.js` (PASS), `npx eslint providers.js tests/concepts-embeddings.test.js` (0 errors) +```bash +git add providers.js tests/concepts-embeddings.test.js +git commit -m "feat(concepts): provider embeddings-capability registry helpers" +``` + +### Task 5: `callEmbeddings` + embed-on-deep-dive + +**Files:** +- Modify: `background.js` — add `callEmbeddings`; embed in `runDeepDive`. +- Test: extend `tests/concepts-embeddings.test.js` (call portion) — but `callEmbeddings` lives in `background.js` (a service-worker module). If `background.js` can't be imported under vitest (chrome globals), test the **pure body/parse** instead: factor the request body + response parse into `providers.js` and test those; the `fetch` wrapper stays in `background.js` and is verified by `node --check` + code review. + +- [ ] **Step 1: Read the existing compat call** + +Read `background.js:1287-1345` (`callOpenAICompatible`) and the `fetchWithTimeout` helper (~1273). Mirror its header style (`Authorization: Bearer `), `AbortController` timeout, and error handling. + +- [ ] **Step 2: Add pure embeddings body/parse to `providers.js` (testable)** + +```js +/** Request body for an OpenAI-compatible /embeddings POST. */ +export function embeddingsBody(model, input) { + return { model, input }; +} + +/** Parse embedding vectors from an OpenAI-compatible /embeddings response, ordered by index. */ +export function parseEmbeddings(json) { + const data = Array.isArray(json?.data) ? json.data : []; + return data + .slice() + .sort((x, y) => (x.index ?? 0) - (y.index ?? 0)) + .map((d) => (Array.isArray(d.embedding) ? d.embedding : [])); +} +``` + +Add to `tests/concepts-embeddings.test.js`: + +```js +import { embeddingsBody, parseEmbeddings } from '../providers.js'; + +describe('embeddings body + parse', () => { + it('builds the request body', () => { + expect(embeddingsBody('text-embedding-3-small', ['a', 'b'])).toEqual({ model: 'text-embedding-3-small', input: ['a', 'b'] }); + }); + it('parses vectors ordered by index', () => { + const json = { data: [{ index: 1, embedding: [3, 4] }, { index: 0, embedding: [1, 2] }] }; + expect(parseEmbeddings(json)).toEqual([[1, 2], [3, 4]]); + }); +}); +``` + +Run: `npx vitest run tests/concepts-embeddings.test.js` (PASS after adding the functions). + +- [ ] **Step 3: Add `callEmbeddings` in `background.js`** + +Near `callOpenAICompatible`, add (uses the same `fetchWithTimeout`; import `embeddingsBody`, `parseEmbeddings`, `pickEmbeddingsProvider` from `./providers.js`): + +```js +// 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; + } +} +``` + +- [ ] **Step 4: Embed in `runDeepDive`** + +Replace the Phase-1 persist block (Task 3) so vectors are computed when available. In `runDeepDive`, before `setDeep({ status: 'done' })`: + +```js + // 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 */ } +``` + +- [ ] **Step 5: Verify** + +Run: `node --check background.js && node --check providers.js` +Run: `npx vitest run` (all pass) +Run: `npx eslint background.js providers.js` (0 errors) + +- [ ] **Step 6: Commit** + +```bash +git add background.js providers.js tests/concepts-embeddings.test.js +git commit -m "feat(concepts): callEmbeddings + embed atoms on deep dive (provider-gated)" +``` + +--- + +## Phase 3 — Verification + +### Task 6: Full pass + +- [ ] **Step 1:** `npx vitest run` — all pass (prior total + `concepts.test.js`, `store-concepts.test.js`, `concepts-embeddings.test.js`). +- [ ] **Step 2:** `npx eslint .` — 0 errors. +- [ ] **Step 3:** `npm run check:html` — all files parse. +- [ ] **Step 4:** `node --check` on `concepts.js`, `store.js`, `store/idb.js`, `providers.js`, `background.js`. +- [ ] **Step 5: Manual note** (no DOM test env): with an OpenAI key configured, a deep dive caches vectors (`getConcepts(repoId).vectors` non-null); with Anthropic, `vectors` is null and `deriveConceptLinks` uses lexical. No user-visible surface yet (that's 2b). + +--- + +## Spec coverage check + +- Persist atoms to a new `concepts` store: Tasks 2, 3. +- Pure `concepts.js` (normalize, cosine, lexical + embedding matchers, `deriveConceptLinks` per-pair hybrid, `conceptIndex` lexical-only): Task 1. +- Provider-gated embeddings (capability + `callEmbeddings` + embed-on-deep-dive), error→null→lexical fallback: Tasks 4, 5. +- No hosted backend; embeddings the only new provider call; existing scan/lens contracts unchanged: Tasks 3–5 (additive, best-effort). +- Fully unit-tested pure module; persistence via fake-indexeddb; embeddings body/parse + capability tested; `callEmbeddings` fetch wrapper verified by `node --check` + review (can't import the SW module under vitest): Tasks 1, 2, 4, 5. + +## Out of scope (per spec) + +UI (2b), anchor-to-library explanation (2c), embeddings for the 5 first-class providers incl. Google (v1 covers OpenAI-protocol compat providers; Google is a clean follow-up via its separate call path). From a399c52c6b30c9967c8ca114e615c1702a21eae4 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:11:11 -0700 Subject: [PATCH 03/20] feat(concepts): pure concept index + lexical/embedding link matchers --- concepts.js | 90 ++++++++++++++++++++++++++++++++++++++++++ tests/concepts.test.js | 80 +++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 concepts.js create mode 100644 tests/concepts.test.js diff --git a/concepts.js b/concepts.js new file mode 100644 index 0000000..2f43ef7 --- /dev/null +++ b/concepts.js @@ -0,0 +1,90 @@ +// concepts.js +// Pure concept model for the Knowledge Graph. Indexes deep-dive atoms across the +// library and links repos by shared concepts. No DOM/network/AI — the embedding +// VECTORS are produced in background.js (when the provider supports it); this +// module only does the math/matching, so it stays fully unit-testable. + +const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'or', 'for', 'to', 'in', 'on', 'with', 'is', 'it', 'its', 'that', 'this', 'layer', 'module', 'system', 'core']); + +/** Canonical lexical key for an atom (lowercase, strip punctuation, drop stopwords). */ +export function normalizeConcept(atom) { + const raw = ((atom && (atom.name || atom.id)) || '').toLowerCase(); + const tokens = raw.replace(/[^a-z0-9\s-]/g, ' ').split(/[\s-]+/).filter((t) => t && !STOPWORDS.has(t)); + return tokens.join('-'); +} + +/** Cosine similarity of two equal-length numeric vectors; 0 for empty/mismatched. */ +export function cosineSimilarity(a, b) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return 0; + let dot = 0, na = 0, nb = 0; + for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } + if (na === 0 || nb === 0) return 0; + return dot / (Math.sqrt(na) * Math.sqrt(nb)); +} + +export const EMBED_THRESHOLD = 0.82; + +const keysOf = (rec) => new Set(((rec && rec.atoms) || []).map(normalizeConcept).filter(Boolean)); + +/** Lexical concept → repos index. */ +export function conceptIndex(records) { + const idx = {}; + for (const rec of Object.values(records || {})) { + if (!rec || !rec.repoId) continue; + for (const k of keysOf(rec)) (idx[k] ||= []).push(rec.repoId); + } + return idx; +} + +/** Lexical matcher: link repos sharing >=1 normalized concept key. */ +export function lexicalMatcher(records) { + const recs = Object.values(records || {}).filter((r) => r && r.repoId); + const links = []; + for (let i = 0; i < recs.length; i++) { + for (let j = i + 1; j < recs.length; j++) { + const ka = keysOf(recs[i]); + const shared = [...keysOf(recs[j])].filter((k) => ka.has(k)); + if (shared.length) links.push({ a: recs[i].repoId, b: recs[j].repoId, shared, score: shared.length }); + } + } + return links; +} + +/** Best cross-repo atom-pair match by cosine; { score, label } if >= threshold, else null. */ +export function bestEmbeddingMatch(recA, recB, threshold = EMBED_THRESHOLD) { + const va = recA && recA.vectors, vb = recB && recB.vectors; + if (!Array.isArray(va) || !Array.isArray(vb) || !va.length || !vb.length) return null; + let best = { score: 0, label: null }; + for (let i = 0; i < va.length; i++) { + for (let j = 0; j < vb.length; j++) { + const s = cosineSimilarity(va[i], vb[j]); + if (s > best.score) best = { score: s, label: `${recA.atoms[i]?.name} ~ ${recB.atoms[j]?.name}` }; + } + } + return best.score >= threshold ? best : null; +} + +/** + * Link repos by shared concepts. Per-pair hybrid: when BOTH repos have vectors, + * use the embedding matcher; otherwise lexical for that pair. + * @returns {{a:string,b:string,shared:string[],score:number}[]} + */ +export function deriveConceptLinks(records, { threshold = EMBED_THRESHOLD } = {}) { + const recs = Object.values(records || {}).filter((r) => r && r.repoId); + const links = []; + for (let i = 0; i < recs.length; i++) { + for (let j = i + 1; j < recs.length; j++) { + const a = recs[i], b = recs[j]; + const bothVec = Array.isArray(a.vectors) && a.vectors.length && Array.isArray(b.vectors) && b.vectors.length; + if (bothVec) { + const m = bestEmbeddingMatch(a, b, threshold); + if (m) links.push({ a: a.repoId, b: b.repoId, shared: [m.label], score: m.score }); + } else { + const ka = keysOf(a); + const shared = [...keysOf(b)].filter((k) => ka.has(k)); + if (shared.length) links.push({ a: a.repoId, b: b.repoId, shared, score: shared.length }); + } + } + } + return links; +} diff --git a/tests/concepts.test.js b/tests/concepts.test.js new file mode 100644 index 0000000..5552061 --- /dev/null +++ b/tests/concepts.test.js @@ -0,0 +1,80 @@ +// tests/concepts.test.js +import { describe, it, expect } from 'vitest'; +import { + normalizeConcept, cosineSimilarity, conceptIndex, + lexicalMatcher, bestEmbeddingMatch, deriveConceptLinks, +} from '../concepts.js'; + +const rec = (repoId, names, vectors = null) => ({ + repoId, vectors, + atoms: names.map((n, i) => ({ id: `a${i}`, name: n, purpose: `does ${n}` })), +}); + +describe('normalizeConcept', () => { + it('lowercases, strips punctuation, drops stopwords', () => { + expect(normalizeConcept({ name: 'The Routing Layer!' })).toBe('routing'); + expect(normalizeConcept({ name: 'Auth/Session' })).toBe('auth-session'); + }); +}); + +describe('cosineSimilarity', () => { + it('is 1 for identical, 0 for orthogonal, 0 for mismatched/empty', () => { + expect(cosineSimilarity([1, 0], [1, 0])).toBeCloseTo(1); + expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0); + expect(cosineSimilarity([1, 2, 3], [1, 2])).toBe(0); + expect(cosineSimilarity([], [])).toBe(0); + }); +}); + +describe('conceptIndex (lexical)', () => { + it('maps each normalized concept to the repos that have it', () => { + const recs = { x: rec('a/x', ['Router', 'Cache']), y: rec('c/y', ['router', 'Queue']) }; + const idx = conceptIndex(recs); + expect(idx['router'].sort()).toEqual(['a/x', 'c/y']); + expect(idx['cache']).toEqual(['a/x']); + }); +}); + +describe('lexicalMatcher', () => { + it('links repos sharing a normalized concept, scored by overlap', () => { + const recs = { x: rec('a/x', ['Router', 'Cache']), y: rec('c/y', ['Routing', 'Cache']) }; + const links = lexicalMatcher(recs); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ a: 'a/x', b: 'c/y', shared: ['cache'], score: 1 }); + }); +}); + +describe('bestEmbeddingMatch', () => { + it('returns the best atom-pair label when above threshold, else null', () => { + const a = rec('a/x', ['Router'], [[1, 0]]); + const b = rec('c/y', ['Dispatch'], [[1, 0]]); + const m = bestEmbeddingMatch(a, b, 0.82); + expect(m.score).toBeCloseTo(1); + expect(m.label).toBe('Router ~ Dispatch'); + const far = rec('e/z', ['X'], [[0, 1]]); + expect(bestEmbeddingMatch(a, far, 0.82)).toBeNull(); + }); +}); + +describe('deriveConceptLinks (per-pair hybrid)', () => { + it('uses embeddings when BOTH repos have vectors', () => { + const recs = { + x: rec('a/x', ['Router'], [[1, 0]]), + y: rec('c/y', ['Dispatch'], [[1, 0]]), + }; + const links = deriveConceptLinks(recs, { threshold: 0.82 }); + expect(links).toHaveLength(1); + expect(links[0].score).toBeCloseTo(1); + expect(links[0].shared).toEqual(['Router ~ Dispatch']); + }); + + it('falls back to lexical when either repo lacks vectors', () => { + const recs = { + x: rec('a/x', ['Cache'], [[1, 0]]), // has vectors + y: rec('c/y', ['Cache'], null), // no vectors → lexical for this pair + }; + const links = deriveConceptLinks(recs, { threshold: 0.82 }); + expect(links).toHaveLength(1); + expect(links[0].shared).toEqual(['cache']); + }); +}); From 6478665e47baa0e3c2e83f6b057141046973bdec Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:15:58 -0700 Subject: [PATCH 04/20] feat(concepts): IDB concepts store + CRUD persistence --- store.js | 30 ++++++++++++++++++++++++++++++ store/idb.js | 9 +++++---- tests/store-concepts.test.js | 20 ++++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 tests/store-concepts.test.js diff --git a/store.js b/store.js index 2698b1a..a4a7c04 100644 --- a/store.js +++ b/store.js @@ -281,6 +281,36 @@ export async function getAllMastery() { } } +// ─── concepts: per-repo deep-dive atoms + embeddings (Knowledge Graph) ──────── + +/** Persist a repo's concept record (atoms [+ vectors]). Throws on failure. */ +export async function setConcepts(repoId, record) { + if (!repoId) throw new Error('setConcepts needs a repoId'); + await idbPut('concepts', { id: repoId, payload: record }); +} + +/** Get a repo's concept record, or null. */ +export async function getConcepts(repoId) { + try { + const row = await idbGet('concepts', repoId); + return (row && row.payload) || null; + } catch { + return null; + } +} + +/** All concept records as a { repoId: record } map. Best-effort — {} on failure. */ +export async function getAllConcepts() { + try { + const rows = await idbGetAll('concepts'); + const out = {}; + for (const r of rows || []) if (r && r.id) out[r.id] = r.payload; + return out; + } catch { + return {}; + } +} + // ─── Canvas scenes ──────────────────────────────────────────────────────────── /** Persist a canvas scene (upsert by id). Throws on failure. */ diff --git a/store/idb.js b/store/idb.js index 7cff7dc..6290f58 100644 --- a/store/idb.js +++ b/store/idb.js @@ -3,10 +3,11 @@ const DB_NAME = 'repolens'; // v2 added 'collections'. v3 added 'decisions'. v4 added 'snapshots'. v5 added -// 'scenes'. v6 added 'mastery' (the Knowledge Game signal). Each upgrade is -// additive — onupgradeneeded creates any new store, so existing data survives. -const DB_VERSION = 6; -const STORES = ['repos', 'nodes', 'edges', 'collections', 'decisions', 'snapshots', 'scenes', 'mastery']; +// 'scenes'. v6 added 'mastery' (the Knowledge Game signal). v7 added 'concepts' +// (the Knowledge-Graph concept substrate). Each upgrade is additive — +// onupgradeneeded creates any new store, so existing data survives. +const DB_VERSION = 7; +const STORES = ['repos', 'nodes', 'edges', 'collections', 'decisions', 'snapshots', 'scenes', 'mastery', 'concepts']; let dbPromise = null; diff --git a/tests/store-concepts.test.js b/tests/store-concepts.test.js new file mode 100644 index 0000000..84b1331 --- /dev/null +++ b/tests/store-concepts.test.js @@ -0,0 +1,20 @@ +// tests/store-concepts.test.js +import { describe, it, expect } from 'vitest'; +import 'fake-indexeddb/auto'; +import { setConcepts, getConcepts, getAllConcepts } from '../store.js'; + +describe('concepts persistence', () => { + it('round-trips a record by repoId', async () => { + const rec = { repoId: 'honojs/hono', atoms: [{ id: 'r', name: 'Router' }], vectors: null, embedModel: null, computedAt: '2026-06-16T00:00:00.000Z' }; + await setConcepts('honojs/hono', rec); + expect(await getConcepts('honojs/hono')).toEqual(rec); + }); + it('returns null for an unknown repo', async () => { + expect(await getConcepts('nope/none')).toBeNull(); + }); + it('getAllConcepts returns a repoId→record map', async () => { + await setConcepts('a/b', { repoId: 'a/b', atoms: [] }); + const map = await getAllConcepts(); + expect(map['a/b'].repoId).toBe('a/b'); + }); +}); From 92f453ba652a9a55b64c68ff2aa9ed938a5f7c7f Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:19:49 -0700 Subject: [PATCH 05/20] feat(concepts): persist deep-dive atoms to the concept store --- background.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/background.js b/background.js index 8251008..3c52e4e 100644 --- a/background.js +++ b/background.js @@ -2,7 +2,7 @@ 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, @@ -803,6 +803,18 @@ async function runDeepDive(sessionKey, detected) { const feynman = parseFeynman(await callAI(keys, withTone(keys.tone, buildFeynmanPrompt(repoData, atoms, lineage)), 'deepdive')); await setDeep({ feynman }); + // Persist atoms for the Knowledge-Graph concept substrate (best-effort — + // a substrate write must never fail the dive). Vectors are added in Phase 2. + try { + await setConcepts(detected.repoId, { + repoId: detected.repoId, + atoms, + vectors: null, + embedModel: 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' }); From dfb468a05bf92c1db603b3f04d5a36535044c0bf Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:24:25 -0700 Subject: [PATCH 06/20] feat(concepts): provider embeddings-capability registry helpers --- providers.js | 34 +++++++++++++++++++++++++++++++ tests/concepts-embeddings.test.js | 19 +++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 tests/concepts-embeddings.test.js diff --git a/providers.js b/providers.js index 4fa084b..4b432db 100644 --- a/providers.js +++ b/providers.js @@ -21,6 +21,7 @@ export const COMPAT_PROVIDERS = [ id: 'openai', label: 'OpenAI (GPT)', protocol: 'openai', hint: 'Official OpenAI API', keyHint: 'sk-…', endpoint: 'https://api.openai.com/v1/chat/completions', + embeddingsModel: 'text-embedding-3-small', host: 'https://api.openai.com/*', docsUrl: 'https://platform.openai.com/api-keys', models: [ { value: 'gpt-4.1', label: 'GPT-4.1', recommended: true }, @@ -276,6 +277,39 @@ export function compatEndpoint(id, keys = {}) { return p.endpoint || ''; } +// ── embeddings capability (OpenAI-protocol providers only) ───────────────────── +export const provEmbedModelName = (id) => `${id}EmbedModel`; // optional embeddings-model override + +/** The embeddings model for a provider (override → registry default → ''). */ +export function embeddingsModelFor(id, keys = {}) { + const p = compatProviderById(id); + return (keys[provEmbedModelName(id)] || (p && p.embeddingsModel) || '').trim(); +} + +/** Derive the POST-able /embeddings URL from the provider's chat endpoint. '' when unknown. */ +export function compatEmbeddingsEndpoint(id, keys = {}) { + const chat = compatEndpoint(id, keys); // e.g. .../v1/chat/completions + if (!chat) return ''; + return chat.replace(/\/chat\/completions(\?.*)?$/, '/embeddings'); +} + +/** True when an OpenAI-protocol provider is connected AND has an embeddings model. */ +export function providerSupportsEmbeddings(id, keys = {}) { + return compatProtocol(id, keys) === 'openai' + && !!embeddingsModelFor(id, keys) + && isCompatConnected(id, keys); +} + +/** First connected provider that supports embeddings → { id, endpoint, key, model }, or null. */ +export function pickEmbeddingsProvider(keys = {}) { + for (const p of COMPAT_PROVIDERS) { + if (providerSupportsEmbeddings(p.id, keys)) { + return { id: p.id, endpoint: compatEmbeddingsEndpoint(p.id, keys), key: keys[provKeyName(p.id)], model: embeddingsModelFor(p.id, keys) }; + } + } + return null; +} + // ── pure request bodies + response parsers (shared by call + test paths) ─────── export function openaiBody(model, prompt, maxTokens = 4096) { return { model, max_tokens: maxTokens, messages: [{ role: 'user', content: prompt }] }; diff --git a/tests/concepts-embeddings.test.js b/tests/concepts-embeddings.test.js new file mode 100644 index 0000000..9e723df --- /dev/null +++ b/tests/concepts-embeddings.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { providerSupportsEmbeddings, compatEmbeddingsEndpoint, embeddingsModelFor } from '../providers.js'; + +describe('embeddings capability', () => { + it('openai supports embeddings when connected (has a key)', () => { + expect(providerSupportsEmbeddings('openai', { openaiKey: 'sk-x' })).toBe(true); + expect(providerSupportsEmbeddings('openai', {})).toBe(false); // no key → not connected + }); + it('a provider without an embeddings model does not support it', () => { + expect(providerSupportsEmbeddings('groq', { groqKey: 'x' })).toBe(false); + }); + it('derives the /embeddings endpoint from the chat endpoint', () => { + expect(compatEmbeddingsEndpoint('openai', {})).toBe('https://api.openai.com/v1/embeddings'); + }); + it('embeddingsModelFor prefers an override then the default', () => { + expect(embeddingsModelFor('openai', {})).toBe('text-embedding-3-small'); + expect(embeddingsModelFor('openai', { openaiEmbedModel: 'text-embedding-3-large' })).toBe('text-embedding-3-large'); + }); +}); From fe02df666fa903db78441ad3baa44633f068be54 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:30:13 -0700 Subject: [PATCH 07/20] feat(concepts): callEmbeddings + embed atoms on deep dive (provider-gated) --- background.js | 33 +++++++++++++++++++++++++++---- providers.js | 14 +++++++++++++ tests/concepts-embeddings.test.js | 11 +++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/background.js b/background.js index 3c52e4e..d13b14e 100644 --- a/background.js +++ b/background.js @@ -17,6 +17,9 @@ import { parseOpenAiText, parseAnthropicText, compatStorageKeys, + embeddingsBody, + parseEmbeddings, + pickEmbeddingsProvider, } from './providers.js'; import { withRetry } from './retry.js'; import { categorizeError, rankErrors } from './errors.js'; @@ -803,14 +806,16 @@ async function runDeepDive(sessionKey, detected) { const feynman = parseFeynman(await callAI(keys, withTone(keys.tone, buildFeynmanPrompt(repoData, atoms, lineage)), 'deepdive')); await setDeep({ feynman }); - // Persist atoms for the Knowledge-Graph concept substrate (best-effort — - // a substrate write must never fail the dive). Vectors are added in Phase 2. + // 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: null, - embedModel: null, + vectors: emb ? emb.vectors : null, + embedModel: emb ? emb.model : null, computedAt: new Date().toISOString(), }); } catch { /* substrate is additive; ignore */ } @@ -1314,6 +1319,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. diff --git a/providers.js b/providers.js index 4b432db..52a95e0 100644 --- a/providers.js +++ b/providers.js @@ -330,3 +330,17 @@ export function parseAnthropicText(json) { if (!t) throw new Error('Provider returned no text content'); return t; } + +/** Request body for an OpenAI-compatible /embeddings POST. */ +export function embeddingsBody(model, input) { + return { model, input }; +} + +/** Parse embedding vectors from an OpenAI-compatible /embeddings response, ordered by index. */ +export function parseEmbeddings(json) { + const data = Array.isArray(json?.data) ? json.data : []; + return data + .slice() + .sort((x, y) => (x.index ?? 0) - (y.index ?? 0)) + .map((d) => (Array.isArray(d.embedding) ? d.embedding : [])); +} diff --git a/tests/concepts-embeddings.test.js b/tests/concepts-embeddings.test.js index 9e723df..c6bf039 100644 --- a/tests/concepts-embeddings.test.js +++ b/tests/concepts-embeddings.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { providerSupportsEmbeddings, compatEmbeddingsEndpoint, embeddingsModelFor } from '../providers.js'; +import { embeddingsBody, parseEmbeddings } from '../providers.js'; describe('embeddings capability', () => { it('openai supports embeddings when connected (has a key)', () => { @@ -17,3 +18,13 @@ describe('embeddings capability', () => { expect(embeddingsModelFor('openai', { openaiEmbedModel: 'text-embedding-3-large' })).toBe('text-embedding-3-large'); }); }); + +describe('embeddings body + parse', () => { + it('builds the request body', () => { + expect(embeddingsBody('text-embedding-3-small', ['a', 'b'])).toEqual({ model: 'text-embedding-3-small', input: ['a', 'b'] }); + }); + it('parses vectors ordered by index', () => { + const json = { data: [{ index: 1, embedding: [3, 4] }, { index: 0, embedding: [1, 2] }] }; + expect(parseEmbeddings(json)).toEqual([[1, 2], [3, 4]]); + }); +}); From 755cc68814b0ea273701b95e34ae98a241ce0ef0 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:38:44 -0700 Subject: [PATCH 08/20] fix(concepts): emit embeddings-model override slot so it loads at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compatStorageKeys() did not emit `${id}EmbedModel`, so the embeddings-model override (read + tested in embeddingsModelFor/providerSupportsEmbeddings) could never load via PROVIDER_KEYS — the default model always won. Emit the slot for embeddings-capable providers + assert it in the test. --- providers.js | 1 + tests/concepts-embeddings.test.js | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/providers.js b/providers.js index 52a95e0..a596ccd 100644 --- a/providers.js +++ b/providers.js @@ -211,6 +211,7 @@ export function compatStorageKeys() { if (p.keyless) out.push(provEnabledName(p.id)); if (p.custom) out.push(provProtoName(p.id)); if (p.needsVersion) out.push(provVerName(p.id)); + if (p.embeddingsModel) out.push(provEmbedModelName(p.id)); // so the embeddings-model override actually loads } return out; } diff --git a/tests/concepts-embeddings.test.js b/tests/concepts-embeddings.test.js index c6bf039..96c4bdc 100644 --- a/tests/concepts-embeddings.test.js +++ b/tests/concepts-embeddings.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { providerSupportsEmbeddings, compatEmbeddingsEndpoint, embeddingsModelFor } from '../providers.js'; -import { embeddingsBody, parseEmbeddings } from '../providers.js'; +import { embeddingsBody, parseEmbeddings, compatStorageKeys } from '../providers.js'; describe('embeddings capability', () => { it('openai supports embeddings when connected (has a key)', () => { @@ -17,6 +17,10 @@ describe('embeddings capability', () => { expect(embeddingsModelFor('openai', {})).toBe('text-embedding-3-small'); expect(embeddingsModelFor('openai', { openaiEmbedModel: 'text-embedding-3-large' })).toBe('text-embedding-3-large'); }); + it('exposes the embeddings-model override slot so it actually loads at runtime', () => { + // Without this, keys[`${id}EmbedModel`] is never read and the override is a no-op. + expect(compatStorageKeys()).toContain('openaiEmbedModel'); + }); }); describe('embeddings body + parse', () => { From 6c70ff2b71114b3cc0e751195a120e2b5f09cd56 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Thu, 18 Jun 2026 07:42:58 -0700 Subject: [PATCH 09/20] feat: ship provider catalogs and release hardening --- .github/workflows/ci.yml | 23 +- .github/workflows/deploy-pages.yml | 2 +- .github/workflows/release.yml | 54 + .prettierignore | 2 + CHANGELOG.md | 20 +- README.md | 79 +- ask-library.js | 55 +- ask-repo.js | 23 +- background.js | 754 ++- backup.js | 109 +- batch.html | 487 +- batch.js | 43 +- blueprint-adapter.js | 9 +- canvas-demo.html | 191 +- canvas-engine.js | 153 +- canvas-export.js | 214 +- canvas-layout.js | 58 +- coachmark.js | 85 +- collections.js | 20 +- combinator-prompt.js | 17 +- combinator.js | 79 +- compare-repos.js | 35 +- concepts.js | 47 +- corkboard-demo.html | 178 +- decision-log.js | 11 +- deepdive.js | 128 +- demo-repo.js | 31 +- diagram.js | 88 +- diff-analysis.js | 10 +- docs-quality.js | 5 +- docs/CODE_GRAPH_EVIDENCE.md | 61 + docs/ROADMAP.md | 96 +- docs/style/stop-slop/SKILL.md | 14 +- docs/style/stop-slop/references/phrases.md | 26 +- docs/style/stop-slop/references/structures.md | 138 +- errors.js | 72 +- eslint.config.js | 4 +- evaluations.js | 9 +- explainers.js | 114 +- exporter.js | 114 +- fetcher.js | 78 +- fits-stack.js | 13 +- format.js | 8 +- graph.js | 53 +- heuristics.js | 14 +- icon-anim.js | 53 +- icon-draw.js | 6 +- ideate.js | 28 +- layouts.js | 61 +- lens-guide.js | 40 +- library-data.js | 28 +- library-filters.js | 7 +- library-preview.html | 738 +++ library-scene.js | 20 +- library.html | 3218 +++++++--- library.js | 1678 ++++-- license-compat.js | 86 +- maintenance.js | 16 +- manifest.json | 24 +- mascot-preview.html | 708 ++- mascot.css | 187 +- mascot.js | 8 +- mastery.js | 31 +- mcp/README.md | 23 +- mcp/anthropic.js | 5 +- mcp/deep-dive.js | 17 +- mcp/package.json | 8 +- mcp/server.js | 5 +- models.js | 55 +- oauth-anthropic.js | 156 + oauth-openai.js | 6 +- oauth-xai.js | 17 +- onboarding-copy.js | 2 +- onboarding-demo.html | 267 +- options-providers.js | 132 +- options.html | 1387 +++-- options.js | 483 +- output-tab.html | 5168 ++++++++++++++--- output-tab.js | 1707 ++++-- package-lock.json | 2160 +++---- package.json | 6 +- palette.css | 28 +- palette.js | 67 +- parser.js | 71 +- prompt.js | 2 +- providers.js | 209 +- repair-graph.js | 68 +- retry.js | 19 +- routing.js | 25 +- scene.js | 20 +- settings-backup.js | 4 +- share.html | 344 +- sktpg.js | 64 +- snapshots.js | 8 +- stack-demo.html | 154 +- stack-prompt.js | 42 +- stack-scene.js | 35 +- stack-tab.html | 386 +- stack-tab.js | 44 +- store.js | 110 +- store/egograph.js | 4 +- store/idb.js | 12 +- synergies.js | 6 +- systems.js | 19 +- tag-prompt.js | 16 +- taxonomy.js | 125 +- tests/backup.test.js | 100 +- tests/blueprint-adapter.test.js | 14 +- tests/cache-backup.test.js | 13 +- tests/cache.test.js | 23 +- tests/canvas-engine.test.js | 8 +- tests/canvas-export.test.js | 37 +- tests/collections.test.js | 10 +- tests/combinator-prompt.test.js | 12 +- tests/combinator.test.js | 24 +- tests/compare-repos.test.js | 21 +- tests/concepts-embeddings.test.js | 23 +- tests/concepts.test.js | 15 +- tests/corkboard-layout.test.js | 5 +- tests/decision-log.test.js | 22 +- tests/deepdive.test.js | 105 +- tests/diagram.test.js | 32 +- tests/diff-analysis.test.js | 11 +- tests/docs-quality.test.js | 4 +- tests/errors.test.js | 5 +- tests/exporter.test.js | 27 +- tests/fetcher.test.js | 84 +- tests/filter-rows.test.js | 4 +- tests/fits-stack.test.js | 8 +- tests/format.test.js | 3 +- tests/graph.test.js | 4 +- tests/heuristics.test.js | 19 +- tests/icon-draw.test.js | 44 +- tests/ideate.test.js | 17 +- tests/layouts.test.js | 20 +- tests/lens-guide.test.js | 18 +- tests/library-data.test.js | 98 +- tests/library-scene.test.js | 12 +- tests/library-stats.test.js | 12 +- tests/license-compat.test.js | 21 +- tests/maintenance.test.js | 59 +- tests/mascot.test.js | 11 +- tests/mastery.test.js | 36 +- tests/mcp-deep-dive.test.js | 44 +- tests/mcp-scan-repo.test.js | 39 +- tests/models.test.js | 8 +- tests/oauth-anthropic.test.js | 107 + tests/oauth-openai.test.js | 41 +- tests/onboarding-copy.test.js | 43 +- tests/onboarding.test.js | 29 +- tests/parser.test.js | 58 +- tests/prompt.test.js | 40 +- tests/providers.test.js | 68 +- tests/repair-graph.test.js | 10 +- tests/retry.test.js | 24 +- tests/routing.test.js | 27 +- tests/runner.test.js | 24 +- tests/safe-html.test.js | 8 +- tests/settings-backup.test.js | 5 +- tests/sktpg.test.js | 10 +- tests/snapshots.test.js | 24 +- tests/stack-prompt.test.js | 29 +- tests/stack-scene.test.js | 14 +- tests/store-backup.test.js | 107 +- tests/store-concepts.test.js | 8 +- tests/store-mastery.test.js | 6 +- tests/store-scenes.test.js | 15 +- tests/store-snapshot-lifecycle.test.js | 43 +- tests/store.test.js | 24 +- tests/synergies.test.js | 30 +- tests/systems.test.js | 17 +- tests/tag-prompt.test.js | 5 +- tests/taxonomy.test.js | 25 +- tests/theme.test.js | 77 +- tests/tone.test.js | 14 +- tests/url-detector.test.js | 30 +- tests/verdict.test.js | 29 +- tests/versus.test.js | 16 +- theme.js | 26 +- themes.css | 648 ++- tone.js | 27 +- tools/make-icons.html | 162 +- tour-runner.js | 67 +- tour.js | 21 +- url-detector.js | 6 +- verdict.js | 14 +- versus.js | 2 +- website/next.config.mjs | 5 +- website/package-lock.json | 313 +- website/package.json | 3 + whats-new.html | 1701 ++++-- 191 files changed, 21529 insertions(+), 7792 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/CODE_GRAPH_EVIDENCE.md create mode 100644 library-preview.html create mode 100644 oauth-anthropic.js create mode 100644 tests/oauth-anthropic.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dfd3ef..8c65b36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,27 @@ jobs: - name: Format check (advisory) run: npm run format:check continue-on-error: true - - name: Dependency audit (advisory) + - name: Dependency audit run: npm audit --audit-level=high + + 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..895ab7e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +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") + 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 ./*.js ./*.css repolens-release/ + cp -R icons assets store migrate vendor repolens-release/ + (cd repolens-release && zip -r "../dist/repolens-v${VERSION}.zip" . -x '*.DS_Store') + - 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..4301cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,7 +106,7 @@ behavioural changes to features, just fixes and guardrails. ### Removed -- **The Claude *subscription* sign-in ("Sign in with Claude").** It never reliably +- **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 @@ -118,7 +118,7 @@ behavioural changes to features, just fixes and guardrails. ### 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; + 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. - Dropped the now-unused `claude.ai`, `platform.claude.com`, and `console.anthropic.com` host permissions (kept `api.anthropic.com` for inference). @@ -128,7 +128,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 +137,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 +152,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 +173,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..1ec3789 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ ![Chrome Manifest V3](https://img.shields.io/badge/Chrome-Manifest_V3-1a73e8?logo=googlechrome&logoColor=white) ![Zero build](https://img.shields.io/badge/build-none-0e1722) ![Vanilla ES modules](https://img.shields.io/badge/vanilla-ES_modules-f7df1e?logo=javascript&logoColor=black) -![Tests](https://img.shields.io/badge/tests-730%2B_passing-2f7d34) -![Version](https://img.shields.io/badge/version-3.0.1-c2691c) +![Tests](https://img.shields.io/badge/tests-890%2B_passing-2f7d34) +![Version](https://img.shields.io/badge/version-3.1.0-c2691c) ![Storage](https://img.shields.io/badge/storage-in--browser_IndexedDB-38bdf8) @@ -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,13 @@ 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)**. +### 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. @@ -67,12 +74,12 @@ A correctness, security, and tooling pass from a full code audit — fixes only, ### v1.6.0 — Claude is API-key only -- 🔑 **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. +- 🔑 **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. - 🆓 **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,12 +89,12 @@ 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 @@ -127,11 +134,11 @@ On top of those, RepoLens works with **almost any other AI service** through one > **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). -> 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 +148,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 +166,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 +183,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 +195,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 d13b14e..a74cf28 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, setConcepts } from './store.js'; +import { + saveAnalysis, + searchLibrary, + upsertNode, + addEdge, + scrollLibrary, + scrollPoints, + saveRepo, + setConcepts, +} from './store.js'; import { buildAttemptPlan } from './routing.js'; import { COMPAT_PROVIDERS, @@ -31,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, @@ -44,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'; @@ -102,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). @@ -111,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). @@ -156,9 +197,13 @@ 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 */ + } }); // Scan a link right-clicked anywhere — detect platform from the href, open output tab. @@ -168,15 +213,40 @@ 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 gateKeys = await chrome.storage.local.get([ + 'anthropicKey', + ANTHROPIC_ACCESS_KEY, + ANTHROPIC_REFRESH_KEY, + 'googleKey', + 'openrouterKey', + 'xaiKey', + 'nousKey', + ...compatStorageKeys(), + ]); + const hasKey = + gateKeys.anthropicKey || + gateKeys[ANTHROPIC_ACCESS_KEY] || + gateKeys[ANTHROPIC_REFRESH_KEY] || + 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' } }); + 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}`) }); return; } @@ -191,7 +261,7 @@ 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; } @@ -217,17 +287,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. @@ -259,13 +359,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; @@ -312,7 +415,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']); @@ -323,12 +428,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); @@ -339,7 +454,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; @@ -351,7 +469,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) { @@ -367,10 +488,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' }); @@ -385,7 +512,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 }); @@ -402,7 +532,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) { @@ -431,7 +564,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 }; } @@ -462,10 +598,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) { @@ -528,7 +661,13 @@ 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; } @@ -545,11 +684,26 @@ chrome.action.onClicked.addListener(async (tab) => { } // 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 gateKeys = await chrome.storage.local.get([ + 'anthropicKey', + ANTHROPIC_ACCESS_KEY, + ANTHROPIC_REFRESH_KEY, + 'googleKey', + 'openrouterKey', + 'xaiKey', + 'xaiRefresh', + 'nousKey', + ...compatStorageKeys(), + ]); + const firstClass = + gateKeys.anthropicKey || + gateKeys[ANTHROPIC_ACCESS_KEY] || + gateKeys[ANTHROPIC_REFRESH_KEY] || + gateKeys.googleKey || + gateKeys.openrouterKey || + gateKeys.xaiKey || + gateKeys.xaiRefresh || + gateKeys.nousKey; const anyCompat = COMPAT_PROVIDERS.some((p) => isCompatConnected(p.id, gateKeys)); if (!firstClass && !anyCompat) { chrome.runtime.openOptionsPage(); @@ -566,9 +720,20 @@ 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', + 'anthropicKey', + ANTHROPIC_ACCESS_KEY, + ANTHROPIC_REFRESH_KEY, + ANTHROPIC_EXPIRY_KEY, + '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) 'partRouting', // per-part model routing map (loaded alongside provider keys) @@ -585,7 +750,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); @@ -597,7 +764,9 @@ 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]: { loading: true, status: 'fetching', platform: items[i].platform, repoId: items[i].repoId }, + }); runAnalysis(subKey, { platform: items[i].platform, repoId: items[i].repoId }); // Poll until the sub-analysis finishes (max 90 s per repo) @@ -635,9 +804,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'), @@ -645,7 +812,9 @@ 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). @@ -662,7 +831,9 @@ async function runAnalysis(sessionKey, detected, tabId) { const prevCached = await 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. @@ -680,12 +851,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, }, }); @@ -719,7 +895,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 }, }); @@ -727,12 +908,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 || '' }, }); } } @@ -743,7 +927,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', @@ -752,7 +938,9 @@ async function runAnalysis(sessionKey, detected, tabId) { message: fitMsg, silent: true, }); - } catch { /* notifications are best-effort */ } + } catch { + /* notifications are best-effort */ + } stopScanAnim(tabId); // success: reset to the static icon } catch (err) { @@ -761,16 +949,14 @@ async function runAnalysis(sessionKey, detected, tabId) { // 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) => { @@ -795,15 +981,21 @@ 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 @@ -818,7 +1010,9 @@ async function runDeepDive(sessionKey, detected) { embedModel: emb ? emb.model : null, computedAt: new Date().toISOString(), }); - } catch { /* substrate is additive; ignore */ } + } catch { + /* substrate is additive; ignore */ + } await setDeep({ status: 'done' }); } catch (err) { @@ -830,16 +1024,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) => { @@ -854,7 +1060,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` }); @@ -864,9 +1073,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] || {}; @@ -888,7 +1095,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) { @@ -961,7 +1170,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 }); @@ -974,7 +1187,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']); @@ -988,18 +1203,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; } @@ -1026,11 +1247,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 { @@ -1055,9 +1276,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] || {}; @@ -1069,7 +1288,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), @@ -1084,9 +1310,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}".` }); @@ -1095,9 +1324,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] || {}; @@ -1109,27 +1336,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) { @@ -1155,11 +1398,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]; } @@ -1167,15 +1418,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 }); @@ -1200,7 +1459,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 }); } @@ -1236,7 +1497,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; @@ -1247,11 +1514,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. @@ -1276,7 +1548,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', }); } @@ -1301,17 +1577,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}`); @@ -1372,25 +1660,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}`); @@ -1406,7 +1709,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 { @@ -1417,13 +1725,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; } @@ -1458,30 +1770,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(); @@ -1490,16 +1822,24 @@ async function callAnthropic(model = 'claude-sonnet-4-6', prompt) { return text; } -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'); +async function callGemini(key, model = 'gemini-3.1-pro-preview', 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' + ); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error?.message ?? `Gemini API error ${res.status}`); @@ -1521,15 +1861,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) { @@ -1544,7 +1888,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}`); } @@ -1557,15 +1903,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}`); @@ -1585,11 +1935,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 @@ -1614,7 +1964,7 @@ async function callXAI(model = 'grok-4.3', prompt) { } 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..90c4214 100644 --- a/backup.js +++ b/backup.js @@ -15,7 +15,16 @@ 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, +}; // 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. @@ -33,7 +42,16 @@ const sceneOk = (s) => !!(s && s.id && s.scope && Array.isArray(s.nodes) && Arra /** 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 +60,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 +115,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 +129,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,7 +145,10 @@ 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 = { @@ -97,12 +158,19 @@ export function validateBackup(obj) { 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)), - }))), + 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)), + })) + ), scenes: clamp('scenes', filterWarn('scene', arr(obj.scenes), sceneOk)), }; return { ok: errors.length === 0, errors, warnings, value }; @@ -116,7 +184,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
-
- 📚 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.

-
- - - + /* Loading states */ + .batch-spinner { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + animation: dot-pulse 1.2s ease-in-out infinite; + display: inline-block; + margin-right: 6px; + } + + + +
+ +
+
Batch Scan
+
Scan multiple repos and populate your library in one go
+
+ 📚 Library
-
-
-
- Scanning… - -
-
-
+
+
+ + +

+ 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. +

+
+ + + +
+
- -
+
+
+ Scanning… + +
+
+
+ + +
- - + + diff --git a/batch.js b/batch.js index 67e12f9..186517f 100644 --- a/batch.js +++ b/batch.js @@ -39,12 +39,15 @@ function updateCount() { textarea.addEventListener('input', updateCount); // Pre-fill from library "Refresh stale" button -chrome.storage.session.get('repolens_batch_prefill').then(({ repolens_batch_prefill }) => { - if (!repolens_batch_prefill?.length) return; - chrome.storage.session.remove('repolens_batch_prefill'); - textarea.value = repolens_batch_prefill.join('\n'); - updateCount(); -}).catch(() => {}); +chrome.storage.session + .get('repolens_batch_prefill') + .then(({ repolens_batch_prefill }) => { + if (!repolens_batch_prefill?.length) return; + chrome.storage.session.remove('repolens_batch_prefill'); + textarea.value = repolens_batch_prefill.join('\n'); + updateCount(); + }) + .catch(() => {}); clearBtn.addEventListener('click', () => { textarea.value = ''; @@ -65,13 +68,16 @@ const FIT_LABELS = { strong: 'Strong', solid: 'Solid', care: 'Care', risky: 'Ris function rowHtml(item, idx) { const icon = STATUS_ICON[item.status] ?? ''; const isScanning = item.status === 'scanning'; - const iconHtml = isScanning - ? '' - : `${icon}`; + const iconHtml = isScanning ? '' : `${icon}`; const label = STATUS_LABEL[item.status] ?? item.status; - const fitHtml = item.fit ? `${FIT_LABELS[item.fit] ?? item.fit}` : ''; - const errHtml = item.error ? `${esc(item.error.slice(0, 60))}` : ''; - const displayId = item.repoId || item.url?.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '') || `#${idx + 1}`; + const fitHtml = item.fit + ? `${FIT_LABELS[item.fit] ?? item.fit}` + : ''; + const errHtml = item.error + ? `${esc(item.error.slice(0, 60))}` + : ''; + const displayId = + item.repoId || item.url?.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '') || `#${idx + 1}`; return `
${iconHtml} ${esc(displayId)} @@ -100,7 +106,10 @@ async function poll(key) { while (polling && Date.now() < deadline) { const stored = await chrome.storage.session.get(key).catch(() => ({})); const data = stored[key]; - if (!data) { await sleep(500); continue; } + if (!data) { + await sleep(500); + continue; + } const items = data.items || []; const done = items.filter((i) => i.status !== 'queued' && i.status !== 'scanning').length; @@ -118,7 +127,9 @@ async function poll(key) { polling = false; } -function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} // ─── Start scan ────────────────────────────────────────────────────────────── @@ -140,7 +151,9 @@ scanBtn.addEventListener('click', async () => { // Send to background sessionKey = 'repolens_batch_' + crypto.randomUUID(); - await chrome.storage.session.set({ [sessionKey]: { type: 'batch', total: urls.length, items: initItems, done: false } }); + await chrome.storage.session.set({ + [sessionKey]: { type: 'batch', total: urls.length, items: initItems, done: false }, + }); chrome.runtime.sendMessage({ type: 'BATCH_SCAN', sessionKey, urls }).catch(() => {}); diff --git a/blueprint-adapter.js b/blueprint-adapter.js index c43d111..72abf4f 100644 --- a/blueprint-adapter.js +++ b/blueprint-adapter.js @@ -15,7 +15,14 @@ import { layoutBlueprint } from './canvas-layout.js'; * @param {boolean} [args.withIssues] when true, returns { scene, issues } * @returns {object|{scene:object, issues:object[]}} */ -export function buildBlueprintScene({ deepDive, repoId, title, scanAt = null, layerOf = (a) => a.kind, withIssues = false }) { +export function buildBlueprintScene({ + deepDive, + repoId, + title, + scanAt = null, + layerOf = (a) => a.kind, + withIssues = false, +}) { const atoms = (deepDive && deepDive.atoms) || []; const links = (deepDive && deepDive.lineage && deepDive.lineage.links) || []; const roots = new Set((deepDive && deepDive.lineage && deepDive.lineage.roots) || []); diff --git a/canvas-demo.html b/canvas-demo.html index ef21f80..193d4ea 100644 --- a/canvas-demo.html +++ b/canvas-demo.html @@ -1,70 +1,135 @@ - + - - - Canvas demo — RepoLens Blueprint - - - - -

🔭 RepoLens — Blueprint canvas (live demo)

-

Real pipeline: buildBlueprintScenelayoutBlueprintmountCanvasbuildTour/startTour. No extension, no API.

-
-
- output-tab · evanw/esbuild - - + + + Canvas demo — RepoLens Blueprint + + + + +

🔭 RepoLens — Blueprint canvas (live demo)

+

+ Real pipeline: buildBlueprintScenelayoutBlueprint → + mountCanvasbuildTour/startTour. No extension, no API. +

+
+
+ output-tab · evanw/esbuild + + +
+
-
-
- - + const host = document.getElementById('host'); + const scene = buildBlueprintScene({ deepDive, repoId: 'evanw/esbuild', title: 'esbuild' }); + const api = mountCanvas(host, scene, {}); + let tour = null; + document.getElementById('tour').onclick = () => { + if (tour) tour.exit(); + tour = startTour({ + host, + engine: api, + steps: buildTour(scene, { roots: deepDive.lineage.roots }), + autoplay: false, + }); + }; + window.__startTour = () => document.getElementById('tour').click(); // for the screenshot harness + + diff --git a/canvas-engine.js b/canvas-engine.js index 9f513e6..c069082 100644 --- a/canvas-engine.js +++ b/canvas-engine.js @@ -3,11 +3,19 @@ // Layout is pure+memoized (positions live in the scene); selection/spotlight is an overlay pass. const SVGNS = 'http://www.w3.org/2000/svg'; -export const NODE_W = 132, NODE_H = 44; +export const NODE_W = 132, + NODE_H = 44; // Auto-width card bounds: each card fits its label, clamped to [MIN_W, MAX_W]; // labels past MAX_W are ellipsised (full text kept in the tooltip). -const MIN_W = 96, MAX_W = 210, PAD_X = 14, CHAR_W = 7.8; -const el = (name, attrs = {}) => { const e = document.createElementNS(SVGNS, name); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; }; +const MIN_W = 96, + MAX_W = 210, + PAD_X = 14, + CHAR_W = 7.8; +const el = (name, attrs = {}) => { + const e = document.createElementNS(SVGNS, name); + for (const k in attrs) e.setAttribute(k, attrs[k]); + return e; +}; /** * Pure: cubic-bezier from the source node's right-middle to the target's left-middle. @@ -19,8 +27,11 @@ const el = (name, attrs = {}) => { const e = document.createElementNS(SVGNS, nam * @returns {string} */ export function edgeBezier(a, b) { - const sx = a.x + ((a && a._w) || NODE_W), sy = a.y + NODE_H / 2; - const tx = b.x, ty = b.y + NODE_H / 2, mx = (sx + tx) / 2; + const sx = a.x + ((a && a._w) || NODE_W), + sy = a.y + NODE_H / 2; + const tx = b.x, + ty = b.y + NODE_H / 2, + mx = (sx + tx) / 2; return `M${sx},${sy} C${mx},${sy} ${mx},${ty} ${tx},${ty}`; } @@ -42,15 +53,24 @@ export function mountCanvas(host, inputScene, { onChange } = {}) { // Leading-edge debounce: the first change saves immediately (so a direct // api.moveNode(...) is observable synchronously), then a 250ms cooldown // coalesces the high-frequency pointer paths into a single trailing save. - let saveTimer = null, pendingDuringCooldown = false; - const fire = () => { if (onChange) onChange(structuredClone(scene)); }; + let saveTimer = null, + pendingDuringCooldown = false; + const fire = () => { + if (onChange) onChange(structuredClone(scene)); + }; const persist = () => { if (!onChange) return; - if (saveTimer) { pendingDuringCooldown = true; return; } + if (saveTimer) { + pendingDuringCooldown = true; + return; + } fire(); saveTimer = setTimeout(() => { saveTimer = null; - if (pendingDuringCooldown) { pendingDuringCooldown = false; persist(); } + if (pendingDuringCooldown) { + pendingDuringCooldown = false; + persist(); + } }, 250); }; @@ -71,7 +91,7 @@ export function mountCanvas(host, inputScene, { onChange } = {}) { const byId = (id) => scene.nodes.find((n) => n.id === id); function edgePath(e) { - return (byId(e.from) && byId(e.to)) ? edgeBezier(byId(e.from), byId(e.to)) : ''; + return byId(e.from) && byId(e.to) ? edgeBezier(byId(e.from), byId(e.to)) : ''; } // Measure a candidate string in the live text node; fall back to a monospace @@ -101,7 +121,8 @@ export function mountCanvas(host, inputScene, { onChange } = {}) { for (const e of scene.edges) { const p = el('path', { class: `rl-edge rl-${e.rel}`, d: edgePath(e), fill: 'none' }); p.dataset.edge = e.id; - edgeLayer.append(p); edgeEls.set(e.id, p); + edgeLayer.append(p); + edgeEls.set(e.id, p); } for (const n of scene.nodes) { const g = el('g', { class: nodeClass(n), transform: `translate(${n.x},${n.y})`, tabindex: '0' }); @@ -109,52 +130,114 @@ export function mountCanvas(host, inputScene, { onChange } = {}) { const rect = el('rect', { width: NODE_W, height: NODE_H, rx: 8 }); const text = el('text', { y: NODE_H / 2, 'text-anchor': 'middle', 'dominant-baseline': 'central' }); text.textContent = n.label; - const title = el('title'); title.textContent = n.label; // full label on hover (survives truncation) + const title = el('title'); + title.textContent = n.label; // full label on hover (survives truncation) g.append(rect, text, title); - nodeLayer.append(g); nodeEls.set(n.id, g); + nodeLayer.append(g); + nodeEls.set(n.id, g); sizeNode(n, rect, text); // fit the card to its label wireDrag(g, n); } applyCamera(); function wireDrag(g, n) { - let startX, startY, ox, oy, dragging = false; + let startX, + startY, + ox, + oy, + dragging = false; g.addEventListener('pointerdown', (ev) => { - dragging = true; g.setPointerCapture?.(ev.pointerId); - startX = ev.clientX; startY = ev.clientY; ox = n.x; oy = n.y; ev.stopPropagation(); + dragging = true; + g.setPointerCapture?.(ev.pointerId); + startX = ev.clientX; + startY = ev.clientY; + ox = n.x; + oy = n.y; + ev.stopPropagation(); }); g.addEventListener('pointermove', (ev) => { if (!dragging) return; moveNode(n.id, ox + (ev.clientX - startX) / cam.zoom, oy + (ev.clientY - startY) / cam.zoom); }); - g.addEventListener('pointerup', (ev) => { if (dragging) { dragging = false; g.releasePointerCapture?.(ev.pointerId); persist(); } }); + g.addEventListener('pointerup', (ev) => { + if (dragging) { + dragging = false; + g.releasePointerCapture?.(ev.pointerId); + persist(); + } + }); } - let panning = false, px, py, pcx, pcy; - svg.addEventListener('pointerdown', (ev) => { if (ev.target === svg || ev.target === root) { panning = true; px = ev.clientX; py = ev.clientY; pcx = cam.x; pcy = cam.y; } }); - svg.addEventListener('pointermove', (ev) => { if (panning) { cam.x = pcx + (ev.clientX - px); cam.y = pcy + (ev.clientY - py); applyCamera(); } }); - svg.addEventListener('pointerup', () => { if (panning) { panning = false; persist(); } }); - svg.addEventListener('wheel', (ev) => { - ev.preventDefault(); - const factor = ev.deltaY < 0 ? 1.1 : 1 / 1.1; - cam.zoom = Math.max(0.2, Math.min(3, cam.zoom * factor)); - applyCamera(); persist(); - }, { passive: false }); + let panning = false, + px, + py, + pcx, + pcy; + svg.addEventListener('pointerdown', (ev) => { + if (ev.target === svg || ev.target === root) { + panning = true; + px = ev.clientX; + py = ev.clientY; + pcx = cam.x; + pcy = cam.y; + } + }); + svg.addEventListener('pointermove', (ev) => { + if (panning) { + cam.x = pcx + (ev.clientX - px); + cam.y = pcy + (ev.clientY - py); + applyCamera(); + } + }); + svg.addEventListener('pointerup', () => { + if (panning) { + panning = false; + persist(); + } + }); + svg.addEventListener( + 'wheel', + (ev) => { + ev.preventDefault(); + const factor = ev.deltaY < 0 ? 1.1 : 1 / 1.1; + cam.zoom = Math.max(0.2, Math.min(3, cam.zoom * factor)); + applyCamera(); + persist(); + }, + { passive: false } + ); function moveNode(id, x, y) { - const n = byId(id); if (!n) return; - n.x = x; n.y = y; - const g = nodeEls.get(id); if (g) g.setAttribute('transform', `translate(${x},${y})`); - for (const e of scene.edges) if (e.from === id || e.to === id) { const p = edgeEls.get(e.id); if (p) p.setAttribute('d', edgePath(e)); } + const n = byId(id); + if (!n) return; + n.x = x; + n.y = y; + const g = nodeEls.get(id); + if (g) g.setAttribute('transform', `translate(${x},${y})`); + for (const e of scene.edges) + if (e.from === id || e.to === id) { + const p = edgeEls.get(e.id); + if (p) p.setAttribute('d', edgePath(e)); + } persist(); } function setSpotlight(ids) { const set = new Set(ids); - for (const [id, g] of nodeEls) { g.classList.toggle('is-spotlight', set.has(id)); g.classList.toggle('is-dim', !set.has(id)); } + for (const [id, g] of nodeEls) { + g.classList.toggle('is-spotlight', set.has(id)); + g.classList.toggle('is-dim', !set.has(id)); + } + } + function clearSpotlight() { + for (const [, g] of nodeEls) g.classList.remove('is-spotlight', 'is-dim'); + } + function getScene() { + return structuredClone(scene); + } + function destroy() { + clearTimeout(saveTimer); + host.innerHTML = ''; } - function clearSpotlight() { for (const [, g] of nodeEls) g.classList.remove('is-spotlight', 'is-dim'); } - function getScene() { return structuredClone(scene); } - function destroy() { clearTimeout(saveTimer); host.innerHTML = ''; } return { moveNode, setSpotlight, clearSpotlight, getScene, destroy }; } diff --git a/canvas-export.js b/canvas-export.js index 95ff2fc..af76056 100644 --- a/canvas-export.js +++ b/canvas-export.js @@ -3,8 +3,13 @@ import { escapeHtml as esc } from './safe-html.js'; -const NW = 132, NH = 44; -const seedFrom = (s) => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) & 0xffffffff; return Math.abs(h) || 1; }; +const NW = 132, + NH = 44; +const seedFrom = (s) => { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) & 0xffffffff; + return Math.abs(h) || 1; +}; // Coerce any coordinate to a finite number before it reaches an SVG attribute or // Excalidraw field. Guards against non-numeric values (e.g. strings from untrusted // JSON) breaking out of an attribute context. Non-finite → 0. @@ -21,7 +26,8 @@ export function toCanvasSvg(scene) { // Seeds match the old literals: minX/minY start at 0, maxX at 200, maxY at 200. const b = nodes.reduce( (acc, n) => { - const x = num(n.x), y = num(n.y); + const x = num(n.x), + y = num(n.y); return { minX: Math.min(acc.minX, x), minY: Math.min(acc.minY, y), @@ -36,27 +42,45 @@ export function toCanvasSvg(scene) { const maxX = b.maxX + 20; const maxY = b.maxY + 60; - const edgeSvg = edges.map((e) => { - const a = pos[e.from], b = pos[e.to]; - if (!a || !b) return ''; - // Start the edge at the source node's real right edge (auto-width `_w`), not the - // fixed constant, so edges on wide cards stay attached. - const aw = num(a._w) || NW; - const x1 = num(a.x) + aw, y1 = num(a.y) + NH / 2, x2 = num(b.x), y2 = num(b.y) + NH / 2, mx = (x1 + x2) / 2; - return `<path class="ce-edge ce-${esc(e.rel)}" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}" fill="none"/>`; - }).join(''); + const edgeSvg = edges + .map((e) => { + const a = pos[e.from], + b = pos[e.to]; + if (!a || !b) return ''; + // Start the edge at the source node's real right edge (auto-width `_w`), not the + // fixed constant, so edges on wide cards stay attached. + const aw = num(a._w) || NW; + const x1 = num(a.x) + aw, + y1 = num(a.y) + NH / 2, + x2 = num(b.x), + y2 = num(b.y) + NH / 2, + mx = (x1 + x2) / 2; + return `<path class="ce-edge ce-${esc(e.rel)}" d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}" fill="none"/>`; + }) + .join(''); - const nodeSvg = nodes.map((n) => { - const x = num(n.x), y = num(n.y), w = num(n._w) || NW; - return `<g class="ce-node ce-kind-${esc(n.kind)}"><rect x="${x}" y="${y}" width="${w}" height="${NH}" rx="8"/>` + - `<text x="${x + w / 2}" y="${y + NH / 2}" text-anchor="middle" dominant-baseline="central">${esc(n.label)}</text></g>`; - }).join(''); + const nodeSvg = nodes + .map((n) => { + const x = num(n.x), + y = num(n.y), + w = num(n._w) || NW; + return ( + `<g class="ce-node ce-kind-${esc(n.kind)}"><rect x="${x}" y="${y}" width="${w}" height="${NH}" rx="8"/>` + + `<text x="${x + w / 2}" y="${y + NH / 2}" text-anchor="middle" dominant-baseline="central">${esc(n.label)}</text></g>` + ); + }) + .join(''); - const annSvg = ann.map((a) => { - const x = num(a.x), y = num(a.y); - return `<g class="ce-note ce-${esc(a.tone)}"><rect x="${x}" y="${y}" width="150" height="48" rx="4"/>` + - `<text x="${x + 8}" y="${y + 20}">${esc(a.text)}</text></g>`; - }).join(''); + const annSvg = ann + .map((a) => { + const x = num(a.x), + y = num(a.y); + return ( + `<g class="ce-note ce-${esc(a.tone)}"><rect x="${x}" y="${y}" width="150" height="48" rx="4"/>` + + `<text x="${x + 8}" y="${y + 20}">${esc(a.text)}</text></g>` + ); + }) + .join(''); return `<svg class="canvas-export" viewBox="${minX} ${minY} ${maxX - minX} ${maxY - minY}" xmlns="http://www.w3.org/2000/svg">${edgeSvg}${nodeSvg}${annSvg}</svg>`; } @@ -65,52 +89,128 @@ export function toCanvasSvg(scene) { export function toExcalidraw(scene) { const elements = []; const base = (id, extra) => ({ - id, x: 0, y: 0, width: 0, height: 0, angle: 0, strokeColor: '#1e1a14', backgroundColor: 'transparent', - fillStyle: 'solid', strokeWidth: 1, strokeStyle: 'solid', roughness: 1, opacity: 100, - groupIds: [], frameId: null, roundness: { type: 3 }, seed: seedFrom(id), versionNonce: seedFrom('n' + id), - version: 1, isDeleted: false, boundElements: [], updated: 1, link: null, locked: false, ...extra, + id, + x: 0, + y: 0, + width: 0, + height: 0, + angle: 0, + strokeColor: '#1e1a14', + backgroundColor: 'transparent', + fillStyle: 'solid', + strokeWidth: 1, + strokeStyle: 'solid', + roughness: 1, + opacity: 100, + groupIds: [], + frameId: null, + roundness: { type: 3 }, + seed: seedFrom(id), + versionNonce: seedFrom('n' + id), + version: 1, + isDeleted: false, + boundElements: [], + updated: 1, + link: null, + locked: false, + ...extra, }); for (const n of scene.nodes || []) { - const rid = `rect-${n.id}`, tid = `txt-${n.id}`; - const x = num(n.x), y = num(n.y), w = num(n._w) || NW; - elements.push(base(rid, { - type: 'rectangle', x, y, width: w, height: 44, - backgroundColor: n.kind === 'subsystem' ? '#c2691c' : '#fffdf6', - boundElements: [{ type: 'text', id: tid }], - })); - elements.push(base(tid, { - type: 'text', x: x + 8, y: y + 14, width: w - 16, height: 20, text: String(n.label), - fontSize: 16, fontFamily: 1, textAlign: 'center', verticalAlign: 'middle', containerId: rid, - originalText: String(n.label), lineHeight: 1.25, - })); + const rid = `rect-${n.id}`, + tid = `txt-${n.id}`; + const x = num(n.x), + y = num(n.y), + w = num(n._w) || NW; + elements.push( + base(rid, { + type: 'rectangle', + x, + y, + width: w, + height: 44, + backgroundColor: n.kind === 'subsystem' ? '#c2691c' : '#fffdf6', + boundElements: [{ type: 'text', id: tid }], + }) + ); + elements.push( + base(tid, { + type: 'text', + x: x + 8, + y: y + 14, + width: w - 16, + height: 20, + text: String(n.label), + fontSize: 16, + fontFamily: 1, + textAlign: 'center', + verticalAlign: 'middle', + containerId: rid, + originalText: String(n.label), + lineHeight: 1.25, + }) + ); } const pos = Object.fromEntries((scene.nodes || []).map((n) => [n.id, n])); for (const e of scene.edges || []) { - const a = pos[e.from], b = pos[e.to]; + const a = pos[e.from], + b = pos[e.to]; if (!a || !b) continue; - const x1 = num(a.x) + (num(a._w) || NW), y1 = num(a.y) + 22, x2 = num(b.x), y2 = num(b.y) + 22; - elements.push(base(`arrow-${e.id}`, { - type: 'arrow', x: x1, y: y1, width: x2 - x1, height: y2 - y1, - points: [[0, 0], [x2 - x1, y2 - y1]], - startBinding: { elementId: `rect-${e.from}`, focus: 0, gap: 4 }, - endBinding: { elementId: `rect-${e.to}`, focus: 0, gap: 4 }, - strokeColor: e.rel === 'triggers' ? '#3b6ea5' : e.rel === 'enables' ? '#2f7d34' : '#1e1a14', - })); + const x1 = num(a.x) + (num(a._w) || NW), + y1 = num(a.y) + 22, + x2 = num(b.x), + y2 = num(b.y) + 22; + elements.push( + base(`arrow-${e.id}`, { + type: 'arrow', + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + points: [ + [0, 0], + [x2 - x1, y2 - y1], + ], + startBinding: { elementId: `rect-${e.from}`, focus: 0, gap: 4 }, + endBinding: { elementId: `rect-${e.to}`, focus: 0, gap: 4 }, + strokeColor: e.rel === 'triggers' ? '#3b6ea5' : e.rel === 'enables' ? '#2f7d34' : '#1e1a14', + }) + ); } for (const a of scene.annotations || []) { - const x = num(a.x), y = num(a.y); - elements.push(base(`note-${a.id}`, { - type: 'text', x, y, width: 150, height: 40, text: String(a.text), - fontSize: 14, fontFamily: 1, textAlign: 'left', verticalAlign: 'top', - originalText: String(a.text), lineHeight: 1.25, strokeColor: a.tone === 'warn' ? '#8a480f' : '#1e1a14', - })); + const x = num(a.x), + y = num(a.y); + elements.push( + base(`note-${a.id}`, { + type: 'text', + x, + y, + width: 150, + height: 40, + text: String(a.text), + fontSize: 14, + fontFamily: 1, + textAlign: 'left', + verticalAlign: 'top', + originalText: String(a.text), + lineHeight: 1.25, + strokeColor: a.tone === 'warn' ? '#8a480f' : '#1e1a14', + }) + ); } - return JSON.stringify({ - type: 'excalidraw', version: 2, source: 'https://github.com/RepoLens', - elements, appState: { gridSize: null, viewBackgroundColor: '#fbf6ea' }, files: {}, - }, null, 2); + return JSON.stringify( + { + type: 'excalidraw', + version: 2, + source: 'https://github.com/RepoLens', + elements, + appState: { gridSize: null, viewBackgroundColor: '#fbf6ea' }, + files: {}, + }, + null, + 2 + ); } diff --git a/canvas-layout.js b/canvas-layout.js index 57affa6..5ca1f00 100644 --- a/canvas-layout.js +++ b/canvas-layout.js @@ -2,7 +2,9 @@ // Pure seed-layout for the Blueprint scope. Left→right layered DAG. // Ports diagram.js's cycle-safe depth relaxation; emits {x,y} not SVG. -const COL_W = 220, ROW_H = 110, PAD = 40; +const COL_W = 220, + ROW_H = 110, + PAD = 40; /** * @param {object[]} nodes scene nodes (mutated copies returned, inputs untouched) @@ -18,41 +20,64 @@ export function layoutBlueprint(nodes, edges) { const depth = Object.fromEntries(ids.map((id) => [id, 0])); for (let i = 0; i < ids.length; i++) { let changed = false; - for (const e of valid) if (depth[e.to] < depth[e.from] + 1) { depth[e.to] = depth[e.from] + 1; changed = true; } + for (const e of valid) + if (depth[e.to] < depth[e.from] + 1) { + depth[e.to] = depth[e.from] + 1; + changed = true; + } if (!changed) break; } const cols = {}; - ids.forEach((id) => { (cols[depth[id]] ||= []).push(id); }); + ids.forEach((id) => { + (cols[depth[id]] ||= []).push(id); + }); const pos = {}; Object.keys(cols).forEach((d) => { const col = cols[d]; - col.forEach((id, i) => { pos[id] = { x: PAD + Number(d) * COL_W, y: PAD + i * ROW_H }; }); + col.forEach((id, i) => { + pos[id] = { x: PAD + Number(d) * COL_W, y: PAD + i * ROW_H }; + }); }); return nodes.map((n) => (n.pinned ? { ...n } : { ...n, x: pos[n.id].x, y: pos[n.id].y })); } -const CARD_W = 150, CARD_H = 64, GAP_X = 60, GAP_Y = 44, ORIGIN = 40; +const CARD_W = 150, + CARD_H = 64, + GAP_X = 60, + GAP_Y = 44, + ORIGIN = 40; /** Simple seed layout for the corkboard: union-find components, grid-place ordered by * (component, id) so related repos start adjacent. Pinned nodes keep their position. Pure. */ export function layoutCorkboard(nodes, edges) { const parent = Object.fromEntries(nodes.map((n) => [n.id, n.id])); - const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }; - const union = (a, b) => { if (parent[a] === undefined || parent[b] === undefined) return; parent[find(a)] = find(b); }; + const find = (x) => { + while (parent[x] !== x) { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + return x; + }; + const union = (a, b) => { + if (parent[a] === undefined || parent[b] === undefined) return; + parent[find(a)] = find(b); + }; for (const e of edges) union(e.from, e.to); const ordered = nodes.slice().sort((p, q) => { - const rp = find(p.id), rq = find(q.id); - return rp < rq ? -1 : rp > rq ? 1 : (p.id < q.id ? -1 : p.id > q.id ? 1 : 0); + const rp = find(p.id), + rq = find(q.id); + return rp < rq ? -1 : rp > rq ? 1 : p.id < q.id ? -1 : p.id > q.id ? 1 : 0; }); const cols = Math.max(1, Math.ceil(Math.sqrt(ordered.length))); const pos = {}; ordered.forEach((n, i) => { - const r = Math.floor(i / cols), c = i % cols; + const r = Math.floor(i / cols), + c = i % cols; pos[n.id] = { x: ORIGIN + c * (CARD_W + GAP_X), y: ORIGIN + r * (CARD_H + GAP_Y) }; }); @@ -64,10 +89,15 @@ export function layoutStack(nodes, order = []) { const rank = Object.fromEntries((order || []).map((id, i) => [String(id), i])); const repos = nodes.filter((n) => n.kind !== 'gap'); const gaps = nodes.filter((n) => n.kind === 'gap'); - const sorted = repos.slice().sort((a, b) => - ((rank[a.id] ?? 999) - (rank[b.id] ?? 999)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const sorted = repos + .slice() + .sort((a, b) => (rank[a.id] ?? 999) - (rank[b.id] ?? 999) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); const pos = {}; - sorted.forEach((n, i) => { pos[n.id] = { x: ORIGIN + i * (CARD_W + GAP_X), y: ORIGIN }; }); - gaps.forEach((n, i) => { pos[n.id] = { x: ORIGIN + i * (CARD_W + GAP_X), y: ORIGIN + 2 * (CARD_H + GAP_Y) }; }); + sorted.forEach((n, i) => { + pos[n.id] = { x: ORIGIN + i * (CARD_W + GAP_X), y: ORIGIN }; + }); + gaps.forEach((n, i) => { + pos[n.id] = { x: ORIGIN + i * (CARD_W + GAP_X), y: ORIGIN + 2 * (CARD_H + GAP_Y) }; + }); return nodes.map((n) => (n.pinned ? { ...n } : { ...n, x: pos[n.id].x, y: pos[n.id].y })); } diff --git a/coachmark.js b/coachmark.js index 70c690c..1fb7434 100644 --- a/coachmark.js +++ b/coachmark.js @@ -3,13 +3,15 @@ // (with Vee) anchored beside it, Back/Next/Skip + keyboard. No deps, MV3-safe. import { renderMascot, setMascotState } from './mascot.js'; -const GAP = 12, MARGIN = 8; +const GAP = 12, + MARGIN = 8; /** Pure: where to put the card relative to a target rect (or center if null). */ export function placeCard(rect, card, vp) { if (!rect) return { side: 'center', left: (vp.w - card.w) / 2, top: (vp.h - card.h) / 2 }; - const below = rect.y + rect.height + GAP, above = rect.y - GAP - card.h; - const side = (below + card.h <= vp.h) ? 'below' : (above >= 0 ? 'above' : 'below'); + const below = rect.y + rect.height + GAP, + above = rect.y - GAP - card.h; + const side = below + card.h <= vp.h ? 'below' : above >= 0 ? 'above' : 'below'; const top = side === 'below' ? below : above; let left = rect.x + rect.width / 2 - card.w / 2; left = Math.max(MARGIN, Math.min(left, vp.w - card.w - MARGIN)); @@ -24,23 +26,40 @@ export function placeCard(rect, card, vp) { export function startCoachmark({ steps, copy, onExit }) { let i = 0; const reduce = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches; - const veil = document.createElement('div'); veil.className = 'cm-veil'; - const spot = document.createElement('div'); spot.className = 'cm-spotlight'; - const card = document.createElement('div'); card.className = 'cm-card'; - const veeSlot = document.createElement('div'); veeSlot.className = 'cm-vee'; + const veil = document.createElement('div'); + veil.className = 'cm-veil'; + const spot = document.createElement('div'); + spot.className = 'cm-spotlight'; + const card = document.createElement('div'); + card.className = 'cm-card'; + const veeSlot = document.createElement('div'); + veeSlot.className = 'cm-vee'; const vee = renderMascot(veeSlot); - const text = document.createElement('p'); text.className = 'cm-text'; - const ctl = document.createElement('div'); ctl.className = 'cm-ctl'; - const back = document.createElement('button'); back.textContent = 'Back'; - const next = document.createElement('button'); next.textContent = 'Next'; - const skip = document.createElement('button'); skip.textContent = 'Skip'; skip.className = 'cm-skip'; + const text = document.createElement('p'); + text.className = 'cm-text'; + const ctl = document.createElement('div'); + ctl.className = 'cm-ctl'; + const back = document.createElement('button'); + back.textContent = 'Back'; + const next = document.createElement('button'); + next.textContent = 'Next'; + const skip = document.createElement('button'); + skip.textContent = 'Skip'; + skip.className = 'cm-skip'; ctl.append(skip, back, next); card.append(veeSlot, text, ctl); - veil.append(spot); document.body.append(veil, card); + veil.append(spot); + document.body.append(veil, card); async function render() { const s = steps[i]; - if (s.before) { try { await s.before(); } catch { /* step action best-effort */ } } + if (s.before) { + try { + await s.before(); + } catch { + /* step action best-effort */ + } + } setMascotState(vee, s.mascotState || 'idle'); text.textContent = copy[s.copyKey] || ''; back.disabled = i === 0; @@ -51,19 +70,41 @@ export function startCoachmark({ steps, copy, onExit }) { el.scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' }); const r = el.getBoundingClientRect(); spot.style.cssText = `display:block;left:${r.x - 6}px;top:${r.y - 6}px;width:${r.width + 12}px;height:${r.height + 12}px`; - const p = placeCard({ x: r.x, y: r.y, width: r.width, height: r.height }, { w: card.offsetWidth || 320, h: card.offsetHeight || 150 }, vp); - card.style.left = p.left + 'px'; card.style.top = p.top + 'px'; + const p = placeCard( + { x: r.x, y: r.y, width: r.width, height: r.height }, + { w: card.offsetWidth || 320, h: card.offsetHeight || 150 }, + vp + ); + card.style.left = p.left + 'px'; + card.style.top = p.top + 'px'; } else { spot.style.display = 'none'; const p = placeCard(null, { w: card.offsetWidth || 320, h: card.offsetHeight || 150 }, vp); - card.style.left = p.left + 'px'; card.style.top = p.top + 'px'; + card.style.left = p.left + 'px'; + card.style.top = p.top + 'px'; } } - function go(n) { i = Math.max(0, Math.min(steps.length - 1, n)); render(); } - function step(d) { (i + d >= steps.length) ? exit() : go(i + d); } - function exit() { veil.remove(); card.remove(); removeEventListener('keydown', onKey); onExit && onExit(); } - const onKey = (e) => { if (e.key === 'Escape') exit(); else if (e.key === 'ArrowRight') step(1); else if (e.key === 'ArrowLeft') step(-1); }; - back.onclick = () => step(-1); next.onclick = () => step(1); skip.onclick = exit; + function go(n) { + i = Math.max(0, Math.min(steps.length - 1, n)); + render(); + } + function step(d) { + i + d >= steps.length ? exit() : go(i + d); + } + function exit() { + veil.remove(); + card.remove(); + removeEventListener('keydown', onKey); + onExit && onExit(); + } + const onKey = (e) => { + if (e.key === 'Escape') exit(); + else if (e.key === 'ArrowRight') step(1); + else if (e.key === 'ArrowLeft') step(-1); + }; + back.onclick = () => step(-1); + next.onclick = () => step(1); + skip.onclick = exit; addEventListener('keydown', onKey); render(); return { next: () => step(1), prev: () => step(-1), exit }; diff --git a/collections.js b/collections.js index 9fc2f00..f886434 100644 --- a/collections.js +++ b/collections.js @@ -7,8 +7,14 @@ // Shape: { id, name, color, repoIds: string[], createdAt, updatedAt } export const COLLECTION_COLORS = [ - '#818cf8', '#22c55e', '#38bdf8', '#f59e0b', - '#ef4444', '#c084fc', '#f472b6', '#2dd4bf', + '#818cf8', + '#22c55e', + '#38bdf8', + '#f59e0b', + '#ef4444', + '#c084fc', + '#f472b6', + '#2dd4bf', ]; const MAX_NAME = 60; @@ -16,7 +22,9 @@ const MAX_NAME = 60; /** A color for the Nth collection, cycling the palette. */ export function nextColor(existingCount) { const n = Number.isFinite(existingCount) ? existingCount : 0; - return COLLECTION_COLORS[((n % COLLECTION_COLORS.length) + COLLECTION_COLORS.length) % COLLECTION_COLORS.length]; + return COLLECTION_COLORS[ + ((n % COLLECTION_COLORS.length) + COLLECTION_COLORS.length) % COLLECTION_COLORS.length + ]; } /** Build a new, empty collection. id/color/now are injected by the caller. */ @@ -65,7 +73,11 @@ export function addRepoToCollection(col, repoId, { now } = {}) { /** Remove a repo (immutable; a no-op returning the same ref if absent). */ export function removeRepoFromCollection(col, repoId, { now } = {}) { if (!collectionContains(col, repoId)) return col; - return { ...col, repoIds: (col.repoIds || []).filter((id) => id !== repoId), updatedAt: now || col.updatedAt }; + return { + ...col, + repoIds: (col.repoIds || []).filter((id) => id !== repoId), + updatedAt: now || col.updatedAt, + }; } /** Toggle a repo's membership. */ diff --git a/combinator-prompt.js b/combinator-prompt.js index d428cc8..3569a33 100644 --- a/combinator-prompt.js +++ b/combinator-prompt.js @@ -2,8 +2,9 @@ // self-score it. Pure string/parse functions (mirrors synergies.js / versus.js). export function buildCombinatorPrompt(repos) { - const block = repos.map(r => - `- ${r.repoId} [${(r.capabilities || []).join(', ')}]: ${r.eli5 || ''}`).join('\n'); + const block = repos + .map((r) => `- ${r.repoId} [${(r.capabilities || []).join(', ')}]: ${r.eli5 || ''}`) + .join('\n'); return `Invent ONE concrete project that fuses these repositories into something none of them is alone. Be specific and buildable — name what each one actually contributes. Reward genuine novelty, but stay grounded: it should be something a capable team could start this week. @@ -21,8 +22,12 @@ Return ONLY a valid JSON object. No markdown fences, no explanation — raw JSON } export function parseCombinator(rawText, inputRepoIds = []) { - let text = String(rawText).trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); - const start = text.indexOf('{'), end = text.lastIndexOf('}'); + const text = String(rawText) + .trim() + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```$/, ''); + const start = text.indexOf('{'), + end = text.lastIndexOf('}'); if (start === -1 || end === -1) throw new Error('No JSON object found in combinator response'); const data = JSON.parse(text.slice(start, end + 1)); const clamp = (n) => Math.max(0, Math.min(5, Math.round(Number(n) || 0))); @@ -31,7 +36,9 @@ export function parseCombinator(rawText, inputRepoIds = []) { title: String(data.title ?? ''), pitch: String(data.pitch ?? ''), contributions: Array.isArray(data.contributions) - ? data.contributions.filter(c => c && idset.has(c.repoId)).map(c => ({ repoId: c.repoId, role: String(c.role ?? '') })) + ? data.contributions + .filter((c) => c && idset.has(c.repoId)) + .map((c) => ({ repoId: c.repoId, role: String(c.role ?? '') })) : [], novelty: clamp(data.novelty), feasibility: clamp(data.feasibility), diff --git a/combinator.js b/combinator.js index f8bd620..ec9af86 100644 --- a/combinator.js +++ b/combinator.js @@ -5,10 +5,13 @@ import { layerOf, layersAdjacent } from './taxonomy.js'; -function layersOf(caps) { return new Set((caps || []).map(layerOf)); } +function layersOf(caps) { + return new Set((caps || []).map(layerOf)); +} function pairAdjacent(a, b) { - const la = layersOf(a.capabilities), lb = layersOf(b.capabilities); + const la = layersOf(a.capabilities), + lb = layersOf(b.capabilities); if (!la.size || !lb.size) return false; for (const x of la) for (const y of lb) if (layersAdjacent(x, y)) return true; return false; @@ -17,23 +20,32 @@ function pairAdjacent(a, b) { function disjointness(combo) { const counts = {}; let total = 0; - for (const r of combo) for (const t of (r.capabilities || [])) { counts[t] = (counts[t] || 0) + 1; total++; } + for (const r of combo) + for (const t of r.capabilities || []) { + counts[t] = (counts[t] || 0) + 1; + total++; + } if (!total) return 0; - const shared = Object.values(counts).filter(c => c > 1).reduce((s, c) => s + c, 0); + const shared = Object.values(counts) + .filter((c) => c > 1) + .reduce((s, c) => s + c, 0); return 1 - shared / total; } function adjacency(combo) { - let pairs = 0, adj = 0; - for (let i = 0; i < combo.length; i++) for (let j = i + 1; j < combo.length; j++) { - pairs++; if (pairAdjacent(combo[i], combo[j])) adj++; - } + let pairs = 0, + adj = 0; + for (let i = 0; i < combo.length; i++) + for (let j = i + 1; j < combo.length; j++) { + pairs++; + if (pairAdjacent(combo[i], combo[j])) adj++; + } return pairs ? adj / pairs : 0; } function distinctLayers(combo) { const set = new Set(); - for (const r of combo) for (const t of (r.capabilities || [])) set.add(layerOf(t)); + for (const r of combo) for (const t of r.capabilities || []) set.add(layerOf(t)); return set.size; } @@ -48,7 +60,9 @@ function spread(combo) { * gives the ranking resolution instead of saturating at 1.0 when repos carry one tag each. */ export function scoreCombo(combo, wildness = 0) { - const a = adjacency(combo), d = disjointness(combo), s = spread(combo); + const a = adjacency(combo), + d = disjointness(combo), + s = spread(combo); // wildness 0 rewards adjacency (coherent combos); wildness 1 rewards non-adjacency // (surprising combos); a linear blend between, so the dial actually changes the ranking. const coherence = (1 - wildness) * a + wildness * (1 - a); @@ -67,12 +81,16 @@ export function diversifyTopK(ranked, { seed = null, topK = 6, penalty = 0.7 } = const picked = []; const used = new Set(); while (picked.length < topK && pool.length) { - let bestIdx = 0, bestAdj = -Infinity; + let bestIdx = 0, + bestAdj = -Infinity; for (let i = 0; i < pool.length; i++) { - const others = pool[i].repoIds.filter(r => r !== seed); - const reused = others.length ? others.filter(r => used.has(r)).length / others.length : 0; + const others = pool[i].repoIds.filter((r) => r !== seed); + const reused = others.length ? others.filter((r) => used.has(r)).length / others.length : 0; const adj = pool[i].score * (1 - penalty * reused); - if (adj > bestAdj) { bestAdj = adj; bestIdx = i; } + if (adj > bestAdj) { + bestAdj = adj; + bestIdx = i; + } } const [chosen] = pool.splice(bestIdx, 1); picked.push(chosen); @@ -84,8 +102,15 @@ export function diversifyTopK(ranked, { seed = null, topK = 6, penalty = 0.7 } = function combosOf(arr, k) { const res = []; const rec = (start, acc) => { - if (acc.length === k) { res.push(acc.slice()); return; } - for (let i = start; i < arr.length; i++) { acc.push(arr[i]); rec(i + 1, acc); acc.pop(); } + if (acc.length === k) { + res.push(acc.slice()); + return; + } + for (let i = start; i < arr.length; i++) { + acc.push(arr[i]); + rec(i + 1, acc); + acc.pop(); + } }; rec(0, []); return res; @@ -97,28 +122,32 @@ function combosOf(arr, k) { * [{ repoIds, rows, score, adjacency, disjointness }], best first, deterministically. */ export function combineCandidates(rows, { seed = null, sizes = [2, 3], wildness = 0, topK = 6 } = {}) { - const byId = new Map(rows.map(r => [r.repoId, r])); + const byId = new Map(rows.map((r) => [r.repoId, r])); const seedRow = seed ? byId.get(seed) : null; if (seed && !seedRow) return []; - const pool = rows.filter(r => r.repoId !== seed); + const pool = rows.filter((r) => r.repoId !== seed); const out = []; const seen = new Set(); - const extraSizes = seed ? sizes.map(s => s - 1) : sizes; // seed fills one slot + const extraSizes = seed ? sizes.map((s) => s - 1) : sizes; // seed fills one slot for (const k of extraSizes) { if (k < 1) continue; for (const c of combosOf(pool, k)) { const combo = seed ? [seedRow, ...c] : c; - const key = combo.map(r => r.repoId).slice().sort().join('|'); + const key = combo + .map((r) => r.repoId) + .slice() + .sort() + .join('|'); if (seen.has(key)) continue; seen.add(key); const sc = scoreCombo(combo, wildness); - out.push({ repoIds: combo.map(r => r.repoId), rows: combo, ...sc }); + out.push({ repoIds: combo.map((r) => r.repoId), rows: combo, ...sc }); } } - out.sort((x, y) => - y.score - x.score || - y.disjointness - x.disjointness || - x.repoIds.join().localeCompare(y.repoIds.join())); + out.sort( + (x, y) => + y.score - x.score || y.disjointness - x.disjointness || x.repoIds.join().localeCompare(y.repoIds.join()) + ); return diversifyTopK(out, { seed, topK }); } diff --git a/compare-repos.js b/compare-repos.js index 5f81f0f..32ec118 100644 --- a/compare-repos.js +++ b/compare-repos.js @@ -11,20 +11,16 @@ function repoSection(label, a) { const lines = [ `## ${label}: ${a.repoId}`, a.description ? `Description: ${trunc(a.description, MAX)}` : '', - a.language ? `Language: ${a.language}` : '', - a.license ? `License: ${a.license}` : '', - a.stars ? `Stars: ${Number(a.stars).toLocaleString()}` : '', - a.category ? `Category: ${a.category}` : '', + a.language ? `Language: ${a.language}` : '', + a.license ? `License: ${a.license}` : '', + a.stars ? `Stars: ${Number(a.stars).toLocaleString()}` : '', + a.category ? `Category: ${a.category}` : '', (a.health?.score ?? a.health) ? `Health: ${a.health?.score ?? a.health}/100` : '', Array.isArray(a.capabilities) && a.capabilities.length ? `Capabilities: ${a.capabilities.join(', ')}` : '', - Array.isArray(a.pros) && a.pros.length - ? `Pros: ${a.pros.slice(0, 4).join('; ')}` - : '', - Array.isArray(a.cons) && a.cons.length - ? `Cons: ${a.cons.slice(0, 3).join('; ')}` - : '', + Array.isArray(a.pros) && a.pros.length ? `Pros: ${a.pros.slice(0, 4).join('; ')}` : '', + Array.isArray(a.cons) && a.cons.length ? `Cons: ${a.cons.slice(0, 3).join('; ')}` : '', a.eli5 ? `Summary: ${trunc(a.eli5, MAX)}` : '', ]; return lines.filter(Boolean).join('\n'); @@ -62,17 +58,22 @@ export function buildComparePrompt(a, b) { * Returns null if parsing fails. */ export function parseCompareResult(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 obj = JSON.parse(s); if (!obj || typeof obj !== 'object') return null; return { - winner: ['a', 'b', 'tie'].includes(obj.winner) ? obj.winner : 'tie', - reason: String(obj.reason || '').trim(), - verdict: String(obj.verdict || '').trim(), - pickA: String(obj.pickA || '').trim(), - pickB: String(obj.pickB || '').trim(), - tradeoffs: Array.isArray(obj.tradeoffs) ? obj.tradeoffs.map((t) => String(t).trim()).filter(Boolean) : [], + winner: ['a', 'b', 'tie'].includes(obj.winner) ? obj.winner : 'tie', + reason: String(obj.reason || '').trim(), + verdict: String(obj.verdict || '').trim(), + pickA: String(obj.pickA || '').trim(), + pickB: String(obj.pickB || '').trim(), + tradeoffs: Array.isArray(obj.tradeoffs) + ? obj.tradeoffs.map((t) => String(t).trim()).filter(Boolean) + : [], }; } catch { return null; diff --git a/concepts.js b/concepts.js index 2f43ef7..2475ae8 100644 --- a/concepts.js +++ b/concepts.js @@ -4,20 +4,50 @@ // VECTORS are produced in background.js (when the provider supports it); this // module only does the math/matching, so it stays fully unit-testable. -const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'or', 'for', 'to', 'in', 'on', 'with', 'is', 'it', 'its', 'that', 'this', 'layer', 'module', 'system', 'core']); +const STOPWORDS = new Set([ + 'the', + 'a', + 'an', + 'of', + 'and', + 'or', + 'for', + 'to', + 'in', + 'on', + 'with', + 'is', + 'it', + 'its', + 'that', + 'this', + 'layer', + 'module', + 'system', + 'core', +]); /** Canonical lexical key for an atom (lowercase, strip punctuation, drop stopwords). */ export function normalizeConcept(atom) { const raw = ((atom && (atom.name || atom.id)) || '').toLowerCase(); - const tokens = raw.replace(/[^a-z0-9\s-]/g, ' ').split(/[\s-]+/).filter((t) => t && !STOPWORDS.has(t)); + const tokens = raw + .replace(/[^a-z0-9\s-]/g, ' ') + .split(/[\s-]+/) + .filter((t) => t && !STOPWORDS.has(t)); return tokens.join('-'); } /** Cosine similarity of two equal-length numeric vectors; 0 for empty/mismatched. */ export function cosineSimilarity(a, b) { if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return 0; - let dot = 0, na = 0, nb = 0; - for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } + let dot = 0, + na = 0, + nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } if (na === 0 || nb === 0) return 0; return dot / (Math.sqrt(na) * Math.sqrt(nb)); } @@ -52,7 +82,8 @@ export function lexicalMatcher(records) { /** Best cross-repo atom-pair match by cosine; { score, label } if >= threshold, else null. */ export function bestEmbeddingMatch(recA, recB, threshold = EMBED_THRESHOLD) { - const va = recA && recA.vectors, vb = recB && recB.vectors; + const va = recA && recA.vectors, + vb = recB && recB.vectors; if (!Array.isArray(va) || !Array.isArray(vb) || !va.length || !vb.length) return null; let best = { score: 0, label: null }; for (let i = 0; i < va.length; i++) { @@ -74,8 +105,10 @@ export function deriveConceptLinks(records, { threshold = EMBED_THRESHOLD } = {} const links = []; for (let i = 0; i < recs.length; i++) { for (let j = i + 1; j < recs.length; j++) { - const a = recs[i], b = recs[j]; - const bothVec = Array.isArray(a.vectors) && a.vectors.length && Array.isArray(b.vectors) && b.vectors.length; + const a = recs[i], + b = recs[j]; + const bothVec = + Array.isArray(a.vectors) && a.vectors.length && Array.isArray(b.vectors) && b.vectors.length; if (bothVec) { const m = bestEmbeddingMatch(a, b, threshold); if (m) links.push({ a: a.repoId, b: b.repoId, shared: [m.label], score: m.score }); diff --git a/corkboard-demo.html b/corkboard-demo.html index f0bb6a6..4dc8c96 100644 --- a/corkboard-demo.html +++ b/corkboard-demo.html @@ -1,68 +1,120 @@ -<!DOCTYPE html> +<!doctype html> <html lang="en"> -<head> - <meta charset="utf-8" /> - <title>Corkboard demo — RepoLens - - - - -

🧭 RepoLens — Corkboard (live demo)

-

Real pipeline: getLibraryGraph-shape → buildLibraryScenelayoutCorkboardmountCanvas. Edges reference hashed node-ids and are joined back to repos.

-
-
- alternative - synergy - head-to-head - combined idea - · card border = fit (strong / solid / care / risky) -
+ + + Corkboard demo — RepoLens + + + + +

🧭 RepoLens — Corkboard (live demo)

+

+ Real pipeline: getLibraryGraph-shape → buildLibraryScene → + layoutCorkboardmountCanvas. Edges reference hashed node-ids and are joined + back to repos. +

+
+
+ alternative + synergy + head-to-head + combined idea + · card border = fit (strong / solid / + care / risky) +
- - + const board = document.getElementById('board'); + const scene = buildLibraryScene({ graph, repos }); + scene.nodes = layoutCorkboard(scene.nodes, scene.edges); + mountCanvas(board, scene, {}); + window.__edgeCount = scene.edges.length; // for the harness to assert the join worked + + diff --git a/decision-log.js b/decision-log.js index aceddab..9278428 100644 --- a/decision-log.js +++ b/decision-log.js @@ -4,10 +4,10 @@ export const DECISIONS = ['adopt', 'trial', 'hold', 'reject']; export const DECISION_META = { - adopt: { label: 'Adopt', color: 'var(--ok-ink)', bg: 'var(--ok-bg)', border: 'var(--ok-edge)' }, - trial: { label: 'Trial', color: '#60a5fa', bg: 'rgba(59,130,246,.1)', border: 'rgba(59,130,246,.35)' }, - hold: { label: 'Hold', color: 'var(--warn-ink)', bg: 'var(--warn-bg)', border: 'var(--warn-edge)' }, - reject: { label: 'Reject', color: 'var(--bad-ink)', bg: 'var(--bad-bg)', border: 'var(--bad-edge)' }, + adopt: { label: 'Adopt', color: 'var(--ok-ink)', bg: 'var(--ok-bg)', border: 'var(--ok-edge)' }, + trial: { label: 'Trial', color: '#60a5fa', bg: 'rgba(59,130,246,.1)', border: 'rgba(59,130,246,.35)' }, + hold: { label: 'Hold', color: 'var(--warn-ink)', bg: 'var(--warn-bg)', border: 'var(--warn-edge)' }, + reject: { label: 'Reject', color: 'var(--bad-ink)', bg: 'var(--bad-bg)', border: 'var(--bad-edge)' }, }; /** @@ -16,7 +16,8 @@ export const DECISION_META = { */ export function buildDecision({ repoId, decision, note = '', timestamp }) { if (!repoId) throw new Error('Decision needs a repoId'); - if (!DECISIONS.includes(decision)) throw new Error(`Invalid decision: "${decision}". Must be one of: ${DECISIONS.join(', ')}`); + if (!DECISIONS.includes(decision)) + throw new Error(`Invalid decision: "${decision}". Must be one of: ${DECISIONS.join(', ')}`); return { repoId: String(repoId), decision, diff --git a/deepdive.js b/deepdive.js index 75eb389..b00dca0 100644 --- a/deepdive.js +++ b/deepdive.js @@ -3,23 +3,46 @@ // Validation (explain-it-simply + gaps + self-test + confidence). Each stage is // its own AI call, chained, fed the previous stage's output. -const MAX_TREE_PATHS = 200; // file paths shown to the model -const MAX_KEY_FILES = 8; // source files fetched + included -const MAX_FILE_CHARS = 2500; // per-file content cap +const MAX_TREE_PATHS = 200; // file paths shown to the model +const MAX_KEY_FILES = 8; // source files fetched + included +const MAX_FILE_CHARS = 2500; // per-file content cap // Filenames that most reveal a project's shape, in priority order. const PRIORITY_FILES = [ - 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'requirements.txt', - 'setup.py', 'pom.xml', 'build.gradle', 'composer.json', 'Gemfile', - 'src/index.ts', 'src/index.js', 'src/index.tsx', 'index.ts', 'index.js', - 'src/main.ts', 'src/main.js', 'src/main.py', 'main.py', 'app.py', - 'src/lib.rs', 'src/main.rs', 'main.go', 'src/app.ts', 'src/App.tsx', + 'package.json', + 'pyproject.toml', + 'Cargo.toml', + 'go.mod', + 'requirements.txt', + 'setup.py', + 'pom.xml', + 'build.gradle', + 'composer.json', + 'Gemfile', + 'src/index.ts', + 'src/index.js', + 'src/index.tsx', + 'index.ts', + 'index.js', + 'src/main.ts', + 'src/main.js', + 'src/main.py', + 'main.py', + 'app.py', + 'src/lib.rs', + 'src/main.rs', + 'main.go', + 'src/app.ts', + 'src/App.tsx', ]; const CODE_EXT = /\.(ts|tsx|js|jsx|py|rs|go|java|rb|php|c|cc|cpp|h|hpp|kt|swift)$/i; /** Extract the first JSON object from a model response (tolerates code fences). */ export function extractJsonObject(rawText) { - let text = (rawText || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); + const text = (rawText || '') + .trim() + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```$/, ''); const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start === -1 || end === -1) throw new Error('No JSON object found in response'); @@ -48,7 +71,7 @@ export function selectKeyFiles(paths) { } // Fill remaining slots with shallow (depth <= 2) source files. const shallow = paths - .filter(p => CODE_EXT.test(p) && p.split('/').length <= 2 && !picked.includes(p)) + .filter((p) => CODE_EXT.test(p) && p.split('/').length <= 2 && !picked.includes(p)) .sort((a, b) => a.split('/').length - b.split('/').length || a.length - b.length); for (const p of shallow) { picked.push(p); @@ -68,16 +91,20 @@ export async function fetchSource(platform, repoId, opts = {}) { const meta = await ghJson(`https://api.github.com/repos/${repoId}`, opts); const branch = meta.default_branch || 'main'; const treeRes = await ghJson( - `https://api.github.com/repos/${repoId}/git/trees/${branch}?recursive=1`, opts + `https://api.github.com/repos/${repoId}/git/trees/${branch}?recursive=1`, + opts ); - const allPaths = (treeRes.tree || []).filter(e => e.type === 'blob').map(e => e.path); + const allPaths = (treeRes.tree || []).filter((e) => e.type === 'blob').map((e) => e.path); const tree = allPaths.slice(0, MAX_TREE_PATHS); const keyPaths = selectKeyFiles(allPaths); const files = []; for (const path of keyPaths) { try { - const data = await ghJson(`https://api.github.com/repos/${repoId}/contents/${encodeURIComponent(path).replace(/%2F/g, '/')}`, opts); + const data = await ghJson( + `https://api.github.com/repos/${repoId}/contents/${encodeURIComponent(path).replace(/%2F/g, '/')}`, + opts + ); if (data.encoding === 'base64' && data.content) { const content = atob(data.content.replace(/\n/g, '')).slice(0, MAX_FILE_CHARS); files.push({ path, content }); @@ -91,14 +118,28 @@ export async function fetchSource(platform, repoId, opts = {}) { // ─── Stage 1: Atomic Deconstruction (semantic chunking) ─────────────────────── +function graphItemLabel(item) { + if (!item) return ''; + if (typeof item === 'string') return item; + const name = item.name || item.qualifiedName || item.qualified_name || item.route || item.path || ''; + const file = item.file || item.path; + const degree = [item.inbound ? `in:${item.inbound}` : '', item.outbound ? `out:${item.outbound}` : ''] + .filter(Boolean) + .join(' '); + return [name, file && file !== name ? `(${file})` : '', degree].filter(Boolean).join(' '); +} + /** A compact "measured facts" block for the atoms prompt; '' when no runner facts. */ export function factsBlock(facts) { if (!facts) return ''; - const langs = (facts.languages || []).slice(0, 6).map(l => `${l.name} ${l.code}`).join(', '); + const langs = (facts.languages || []) + .slice(0, 6) + .map((l) => `${l.name} ${l.code}`) + .join(', '); const dep = (k) => (facts.dependencies && facts.dependencies[k]) || []; const depLine = ['npm', 'cargo', 'pip', 'go'] - .filter(k => dep(k).length) - .map(k => `${k}: ${dep(k).slice(0, 12).join(', ')}`) + .filter((k) => dep(k).length) + .map((k) => `${k}: ${dep(k).slice(0, 12).join(', ')}`) .join('; '); const lines = [ `- ${facts.fileCount} files. LOC by language: ${langs || '—'}.`, @@ -107,20 +148,47 @@ export function factsBlock(facts) { ]; const dg = facts.depGraph || {}; const scale = ['npm', 'cargo', 'pip', 'go'] - .filter(k => (dg[k] || {}).total) - .map(k => `${k} ${dg[k].direct} direct / ${dg[k].total} total`) + .filter((k) => (dg[k] || {}).total) + .map((k) => `${k} ${dg[k].direct} direct / ${dg[k].total} total`) .join('; '); if (scale) lines.push(`- Dependency scale (from lockfile): ${scale}.`); if (facts.license) lines.push(`- License: ${facts.license.spdx} (${facts.license.file}).`); const a = facts.architecture; if (a) { const bits = []; - if (a.monorepo) bits.push(`monorepo${(a.workspaces || []).length ? ` (${a.workspaces.slice(0, 4).join(', ')})` : ''}`); + if (a.monorepo) + bits.push(`monorepo${(a.workspaces || []).length ? ` (${a.workspaces.slice(0, 4).join(', ')})` : ''}`); if ((a.entryPoints || []).length) bits.push(`entry points: ${a.entryPoints.slice(0, 4).join(', ')}`); if (a.containerized) bits.push('containerized (Dockerfile)'); if (bits.length) lines.push(`- Architecture: ${bits.join('; ')}.`); } - if ((facts.secrets || []).length) lines.push(`- Static secret-scan flags: ${facts.secrets.length} (review).`); + if ((facts.secrets || []).length) + lines.push(`- Static secret-scan flags: ${facts.secrets.length} (review).`); + const cg = facts.codeGraph || facts.graph; + if (cg) { + const counts = []; + const nodes = cg.nodes || cg.nodeCount; + const edges = cg.edges || cg.edgeCount; + if (nodes) counts.push(`${nodes} nodes`); + if (edges) counts.push(`${edges} edges`); + const symbols = cg.symbols || {}; + const symbolBits = ['functions', 'classes', 'methods', 'routes'] + .filter((k) => symbols[k]) + .map((k) => `${symbols[k]} ${k}`); + const routes = (cg.routes || []).slice(0, 6).map(graphItemLabel).filter(Boolean); + const bits = [...counts, ...symbolBits]; + if (routes.length) bits.push(`routes: ${routes.join(', ')}`); + if (bits.length) lines.push(`- Code graph: ${bits.join('; ')}.`); + const hotspots = (cg.hotspots || cg.hotSpots || []).slice(0, 5).map(graphItemLabel).filter(Boolean); + if (hotspots.length) lines.push(`- Graph hotspots: ${hotspots.join('; ')}.`); + const dead = cg.deadCode || cg.dead_code || []; + if (dead.length) { + const examples = dead.slice(0, 4).map(graphItemLabel).filter(Boolean); + lines.push( + `- Potential dead code: ${dead.length} symbols flagged${examples.length ? ` — ${examples.join(', ')}` : ''}.` + ); + } + } return `\nMEASURED FACTS (from a real checkout via the runner — ground truth; prefer these over inference):\n${lines.join('\n')}\n`; } @@ -129,7 +197,7 @@ export function buildAtomsPrompt(repoData, source, facts) { ? `File tree (truncated):\n${source.tree.join('\n')}` : '(no file tree available — work from the README + description)'; const filesBlock = source.files.length - ? source.files.map(f => `=== ${f.path} ===\n${f.content}`).join('\n\n') + ? source.files.map((f) => `=== ${f.path} ===\n${f.content}`).join('\n\n') : '(no source files available)'; return `You are reverse-engineering a software repository into its ATOMIC SEMANTIC UNITS — the smallest set of self-contained concepts/subsystems that, taken together, explain how the project works. @@ -170,7 +238,7 @@ export function parseAtoms(rawText) { // ─── Stage 2: Mapping Causal Lineage ────────────────────────────────────────── export function buildLineagePrompt(atoms) { - const list = atoms.map(a => `- ${a.id}: ${a.name} — ${a.purpose}`).join('\n'); + const list = atoms.map((a) => `- ${a.id}: ${a.name} — ${a.purpose}`).join('\n'); return `Given these atomic units of a software project, map the CAUSAL LINEAGE between them — the directed cause→effect / dependency relationships. Atomic units: @@ -191,8 +259,8 @@ export function parseLineage(rawText) { const links = Array.isArray(data.links) ? data.links : []; return { links: links - .filter(l => l && l.from && l.to) - .map(l => ({ from: l.from, to: l.to, relation: l.relation || 'depends-on', why: l.why || '' })), + .filter((l) => l && l.from && l.to) + .map((l) => ({ from: l.from, to: l.to, relation: l.relation || 'depends-on', why: l.why || '' })), roots: Array.isArray(data.roots) ? data.roots : [], leaves: Array.isArray(data.leaves) ? data.leaves : [], }; @@ -201,8 +269,8 @@ export function parseLineage(rawText) { // ─── Stage 3: Execution & Validation (the Feynman Protocol) ─────────────────── export function buildFeynmanPrompt(repoData, atoms, lineage) { - const atomList = atoms.map(a => `- ${a.name}: ${a.purpose}`).join('\n'); - const linkList = lineage.links.map(l => `- ${l.from} ${l.relation} ${l.to} (${l.why})`).join('\n'); + const atomList = atoms.map((a) => `- ${a.name}: ${a.purpose}`).join('\n'); + const linkList = lineage.links.map((l) => `- ${l.from} ${l.relation} ${l.to} (${l.why})`).join('\n'); return `Apply the FEYNMAN PROTOCOL to validate an understanding of ${repoData.repoId}. Atomic units: @@ -235,7 +303,11 @@ export function parseFeynman(rawText) { explanation: data.explanation || '', gaps: arr(data.gaps), assumptions: arr(data.assumptions), - questions: arr(data.questions).map(q => ({ q: q.q || '', a: q.a || '' })), - confidence: arr(data.confidence).map(c => ({ claim: c.claim || '', level: c.level || 'medium', note: c.note || '' })), + questions: arr(data.questions).map((q) => ({ q: q.q || '', a: q.a || '' })), + confidence: arr(data.confidence).map((c) => ({ + claim: c.claim || '', + level: c.level || 'medium', + note: c.note || '', + })), }; } diff --git a/demo-repo.js b/demo-repo.js index 41edbea..a332297 100644 --- a/demo-repo.js +++ b/demo-repo.js @@ -6,8 +6,12 @@ import { buildBlueprintScene } from './blueprint-adapter.js'; export const DEMO_REPO = { repoId: 'honojs/hono', __demo__: true, - platform: 'github', language: 'TypeScript', license: 'MIT', stars: 21000, - category: 'Web framework', tags: ['edge', 'router'], + platform: 'github', + language: 'TypeScript', + license: 'MIT', + stars: 21000, + category: 'Web framework', + tags: ['edge', 'router'], description: 'Small, fast web framework for the edges.', saved_at: '2026-01-01T00:00:00.000Z', eli5: 'A sample read: a tiny web framework that runs on edge runtimes using web-standard requests.', @@ -24,7 +28,12 @@ export const DEMO_REPO = { { id: 'context', name: 'Context', kind: 'subsystem', purpose: 'Wraps request and response per call.' }, { id: 'middleware', name: 'middleware', kind: 'module', purpose: 'Runs before/after handlers.' }, { id: 'handler', name: 'handler', kind: 'module', purpose: 'Your route logic.' }, - { id: 'adapter', name: 'runtime adapter', kind: 'module', purpose: 'Binds to a runtime (Workers, Deno, Node).' }, + { + id: 'adapter', + name: 'runtime adapter', + kind: 'module', + purpose: 'Binds to a runtime (Workers, Deno, Node).', + }, ], lineage: { links: [ @@ -34,7 +43,8 @@ export const DEMO_REPO = { { from: 'middleware', to: 'handler', relation: 'triggers' }, { from: 'app', to: 'adapter', relation: 'depends-on' }, ], - roots: ['app'], leaves: ['handler'], + roots: ['app'], + leaves: ['handler'], }, }, }; @@ -43,7 +53,14 @@ export const DEMO_REPO = { * Tagged __demo__ so exportStores can drop it without coupling the store to this * fixture (saveScene persists the scene verbatim, so the tag survives). */ export function demoScene() { - return { ...buildBlueprintScene({ deepDive: DEMO_REPO.deepDive, repoId: DEMO_REPO.repoId, title: DEMO_REPO.repoId }), __demo__: true }; + return { + ...buildBlueprintScene({ + deepDive: DEMO_REPO.deepDive, + repoId: DEMO_REPO.repoId, + title: DEMO_REPO.repoId, + }), + __demo__: true, + }; } /** True only for the seeded demo row. */ @@ -69,5 +86,7 @@ export async function clearDemoEverywhere() { // Defense-in-depth: clear any snapshot orphan left by an already-seeded user. await deleteSnapshots(DEMO_REPO.repoId); } - } catch { /* best-effort teardown */ } + } catch { + /* best-effort teardown */ + } } diff --git a/diagram.js b/diagram.js index d088d6f..07edb00 100644 --- a/diagram.js +++ b/diagram.js @@ -4,31 +4,44 @@ import { escapeHtml as esc } from './safe-html.js'; -const NODE_W = 132, NODE_H = 38, COL_GAP = 64, ROW_GAP = 16, PAD = 14; +const NODE_W = 132, + NODE_H = 38, + COL_GAP = 64, + ROW_GAP = 16, + PAD = 14; -const truncate = (s, n) => { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; +const truncate = (s, n) => { + s = String(s); + return s.length > n ? s.slice(0, n - 1) + '…' : s; +}; /** Layered left→right DAG of atoms connected by lineage links. */ export function lineageSvg(atoms, links) { if (!atoms?.length || !links?.length) return ''; - const ids = atoms.map(a => a.id); + const ids = atoms.map((a) => a.id); const idset = new Set(ids); - const nameById = Object.fromEntries(atoms.map(a => [a.id, a.name])); - const valid = links.filter(l => idset.has(l.from) && idset.has(l.to)); + const nameById = Object.fromEntries(atoms.map((a) => [a.id, a.name])); + const valid = links.filter((l) => idset.has(l.from) && idset.has(l.to)); if (!valid.length) return ''; // depth = longest path from a root; relaxation bounded by node count (cycle-safe) - const depth = Object.fromEntries(ids.map(id => [id, 0])); + const depth = Object.fromEntries(ids.map((id) => [id, 0])); for (let i = 0; i < ids.length; i++) { let changed = false; - for (const l of valid) if (depth[l.to] < depth[l.from] + 1) { depth[l.to] = depth[l.from] + 1; changed = true; } + for (const l of valid) + if (depth[l.to] < depth[l.from] + 1) { + depth[l.to] = depth[l.from] + 1; + changed = true; + } if (!changed) break; } const cols = {}; - ids.forEach(id => { (cols[depth[id]] ||= []).push(id); }); - const maxDepth = Math.max(...ids.map(id => depth[id])); - const maxRows = Math.max(...Object.values(cols).map(c => c.length)); + ids.forEach((id) => { + (cols[depth[id]] ||= []).push(id); + }); + const maxDepth = Math.max(...ids.map((id) => depth[id])); + const maxRows = Math.max(...Object.values(cols).map((c) => c.length)); const totalH = PAD * 2 + maxRows * (NODE_H + ROW_GAP) - ROW_GAP; const pos = {}; @@ -36,20 +49,30 @@ export function lineageSvg(atoms, links) { const col = cols[d] || []; const colH = col.length * (NODE_H + ROW_GAP) - ROW_GAP; const top = PAD + (totalH - PAD * 2 - colH) / 2; - col.forEach((id, i) => { pos[id] = { x: PAD + d * (NODE_W + COL_GAP), y: top + i * (NODE_H + ROW_GAP) }; }); + col.forEach((id, i) => { + pos[id] = { x: PAD + d * (NODE_W + COL_GAP), y: top + i * (NODE_H + ROW_GAP) }; + }); } const width = PAD * 2 + (maxDepth + 1) * (NODE_W + COL_GAP) - COL_GAP; - const edges = valid.map(l => { - const a = pos[l.from], b = pos[l.to]; - const x1 = a.x + NODE_W, y1 = a.y + NODE_H / 2, x2 = b.x, y2 = b.y + NODE_H / 2; - const mx = (x1 + x2) / 2; - return ``; - }).join(''); - const nodes = ids.map(id => { - const p = pos[id]; - return `${esc(truncate(nameById[id], 18))}`; - }).join(''); + const edges = valid + .map((l) => { + const a = pos[l.from], + b = pos[l.to]; + const x1 = a.x + NODE_W, + y1 = a.y + NODE_H / 2, + x2 = b.x, + y2 = b.y + NODE_H / 2; + const mx = (x1 + x2) / 2; + return ``; + }) + .join(''); + const nodes = ids + .map((id) => { + const p = pos[id]; + return `${esc(truncate(nameById[id], 18))}`; + }) + .join(''); return `${edges}${nodes}`; } @@ -59,16 +82,25 @@ export function loopSvg(cycle, type) { const nodes = (cycle || []).filter(Boolean); if (nodes.length < 2) return ''; const cls = type === 'balancing' ? 'dg-bal' : 'dg-rein'; - const R = 78, cx = 130, cy = 110; + const R = 78, + cx = 130, + cy = 110; const pts = nodes.map((_, i) => { - const ang = -Math.PI / 2 + (i * 2 * Math.PI / nodes.length); + const ang = -Math.PI / 2 + (i * 2 * Math.PI) / nodes.length; return { x: cx + R * Math.cos(ang), y: cy + R * Math.sin(ang) }; }); - const edges = pts.map((p, i) => { - const q = pts[(i + 1) % pts.length]; - return ``; - }).join(''); - const labels = pts.map((p, i) => `${esc(truncate(nodes[i], 16))}`).join(''); + const edges = pts + .map((p, i) => { + const q = pts[(i + 1) % pts.length]; + return ``; + }) + .join(''); + const labels = pts + .map( + (p, i) => + `${esc(truncate(nodes[i], 16))}` + ) + .join(''); return `${edges}${labels}`; } diff --git a/diff-analysis.js b/diff-analysis.js index a048f20..d123dfd 100644 --- a/diff-analysis.js +++ b/diff-analysis.js @@ -13,7 +13,7 @@ export function daysSince(isoTs) { function _fitLevel(d) { if (!d) return null; const score = Number(d.health?.score ?? d.health ?? 0); - const warns = ((d.red_flags) || []).filter(f => f?.severity !== 'ok').length; + const warns = (d.red_flags || []).filter((f) => f?.severity !== 'ok').length; if (score >= 85 && warns === 0) return 'strong'; if (score >= 70 && warns <= 1) return 'solid'; if (score >= 50 && warns <= 3) return 'care'; @@ -48,8 +48,8 @@ export function diffAnalyses(prev, next) { const fitRankPrev = FIT_RANK[fitPrev] ?? 2; const fitRankNext = FIT_RANK[fitNext] ?? 2; - const flagsPrev = new Set(((prev.red_flags) || []).map(f => f?.title).filter(Boolean)); - const flagsNext = new Set(((next.red_flags) || []).map(f => f?.title).filter(Boolean)); + const flagsPrev = new Set((prev.red_flags || []).map((f) => f?.title).filter(Boolean)); + const flagsNext = new Set((next.red_flags || []).map((f) => f?.title).filter(Boolean)); return { days_since_prev: daysSince(prev.cachedAt), @@ -61,8 +61,8 @@ export function diffAnalyses(prev, next) { changed: fitPrev !== fitNext, direction: fitRankNext < fitRankPrev ? 'up' : fitRankNext > fitRankPrev ? 'down' : 'same', }, - new_flags: [...flagsNext].filter(t => !flagsPrev.has(t)), - removed_flags: [...flagsPrev].filter(t => !flagsNext.has(t)), + new_flags: [...flagsNext].filter((t) => !flagsPrev.has(t)), + removed_flags: [...flagsPrev].filter((t) => !flagsNext.has(t)), version_delta: _versionDelta(prev.version, next.version), }; } diff --git a/docs-quality.js b/docs-quality.js index 6cab093..c4f897a 100644 --- a/docs-quality.js +++ b/docs-quality.js @@ -10,8 +10,7 @@ export const DOCS_VERDICTS = ['yes', 'partially', 'no']; function docsContext(repoData, source) { const tree = source?.tree ?? []; - const hasFile = (patterns) => - patterns.some(p => tree.some(f => f.toLowerCase().includes(p))); + const hasFile = (patterns) => patterns.some((p) => tree.some((f) => f.toLowerCase().includes(p))); const signals = [ `Has CHANGELOG: ${hasFile(['changelog', 'changes', 'history', 'news', 'releases'])}`, @@ -99,7 +98,7 @@ export function parseDocsQuality(rawText) { : verdictFromScore(score); const sections = Array.isArray(d.sections) - ? d.sections.map(s => ({ + ? d.sections.map((s) => ({ name: String(s.name || ''), score: clamp(s.score), verdict: String(s.verdict || ''), diff --git a/docs/CODE_GRAPH_EVIDENCE.md b/docs/CODE_GRAPH_EVIDENCE.md new file mode 100644 index 0000000..47698fe --- /dev/null +++ b/docs/CODE_GRAPH_EVIDENCE.md @@ -0,0 +1,61 @@ +# Code Graph Evidence for Deep Scan + +RepoLens already has the right product slot: the optional deeper-scan runner provides measured facts from a real checkout. This note defines the next increment: let advanced runners attach structural code-graph evidence without changing the browser extension contract. + +## Goal + +Ground Verdict and Deep Dive claims with structural facts, not just README text and file-tree heuristics. + +Examples: + +- public API surface size +- entry points and routes +- important modules / hotspots +- dead or isolated symbols +- call/import density +- cross-service or config-to-code links + +## Optional facts schema + +The extension now accepts either `facts.codeGraph` or `facts.graph` from the runner. All fields are optional. + +```json +{ + "fileCount": 123, + "languages": [{ "name": "TypeScript", "code": 42000 }], + "codeGraph": { + "nodes": 1200, + "edges": 3400, + "symbols": { + "functions": 210, + "classes": 18, + "methods": 95, + "routes": 7 + }, + "routes": ["GET /api/repos"], + "hotspots": [{ "name": "scanRepository", "file": "src/scan.ts", "inbound": 14, "outbound": 9 }], + "deadCode": [{ "name": "legacyParser", "file": "src/legacy.ts" }] + } +} +``` + +## Product use + +- Deep Dive prompt: includes code graph evidence in `MEASURED FACTS` and tells the model to prefer it over inference. +- Deep Dive UI: shows a compact `code graph` row inside the measured-facts panel when fields are present. +- Existing runners remain compatible: no `codeGraph` means no UI/prompt change. + +## Implementation path + +1. Start with lightweight static extraction in the runner: + - manifest entry points + - route-like strings and framework annotations + - import graph by file + - top files by inbound imports +2. Then add AST/symbol extraction per high-value language: + - JS/TS first, then Python/Rust/Go + - functions/classes/methods counts + - call-ish edges when reliable +3. Later, optionally bridge to a local MCP/code-index backend when installed. + +RepoLens should stay verdict-first. The graph is evidence, not the product surface. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4faa9ac..61a8d1a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -24,40 +24,40 @@ workbench that gets more valuable the more you use it. ## 2. Impact × Effort matrix -| Bucket | Ideas | -|---|---| -| **Quick Wins** (S, high impact) | Actionable error states (route fix by `errors.js` kind) · Subtle-motion pass: `:active` press states + token set in `themes.css` · Loading microcopy fix (kills wrong "Asking Claude…" for non-Claude providers) · Keyboard-reachable lens tooltips + inline `1–9` shortcut hints | -| **Big Bets** (L, high impact) | **Collections (Boards)** — the organizing primitive everything scopes to · **Ask Across My Library** (client-side RAG over BM25) · **Tech-Stack Builder** (directed multi-repo synthesis) · **Scaffold Export** (the first true generative output) | -| **Fill-ins** (S/M, medium) | Decision Log · License Compatibility check · Diff Since I Last Looked · Maintenance & Abandonment lens · Guided empty states · Skeleton shimmer · Progressive-disclosure Verdict · Command palette (Cmd/Ctrl-K) · Saved Searches · Annotations · Comparison export · Narrow-width responsive CSS | -| **Skip / Later** | Weekly Digest (needs `chrome.alarms`) · Idea Canvas drag board (heavy) · Framework Synthesis meta-lens · API Surface / Migration / Red-Team / Security-Posture lenses (lens-fatigue risk — ship ≤2 new lenses per version) · Mascot as default-on (see §7) | +| Bucket | Ideas | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Quick Wins** (S, high impact) | Actionable error states (route fix by `errors.js` kind) · Subtle-motion pass: `:active` press states + token set in `themes.css` · Loading microcopy fix (kills wrong "Asking Claude…" for non-Claude providers) · Keyboard-reachable lens tooltips + inline `1–9` shortcut hints | +| **Big Bets** (L, high impact) | **Collections (Boards)** — the organizing primitive everything scopes to · **Ask Across My Library** (client-side RAG over BM25) · **Tech-Stack Builder** (directed multi-repo synthesis) · **Scaffold Export** (the first true generative output) | +| **Fill-ins** (S/M, medium) | Decision Log · License Compatibility check · Diff Since I Last Looked · Maintenance & Abandonment lens · Guided empty states · Skeleton shimmer · Progressive-disclosure Verdict · Command palette (Cmd/Ctrl-K) · Saved Searches · Annotations · Comparison export · Narrow-width responsive CSS | +| **Skip / Later** | Weekly Digest (needs `chrome.alarms`) · Idea Canvas drag board (heavy) · Framework Synthesis meta-lens · API Surface / Migration / Red-Team / Security-Posture lenses (lens-fatigue risk — ship ≤2 new lenses per version) · Mascot as default-on (see §7) | The strongest lever is **persistence primitives (Collections + Decision Log)**, because -they unlock the creation features and the library-grounded scans. Adding more *lenses* is +they unlock the creation features and the library-grounded scans. Adding more _lenses_ is the lowest-leverage track — RepoLens already has 8. ## 3. Recommended next 5 (in order) -| # | Build | Why it serves the "creation/idea tool" goal | Files | Effort | -|---|---|---|---|---| -| 1 | **Subtle-motion + actionable-error pass** | Foundation polish before adding surfaces. Press states make every new button/chip feel real; routing errors by kind (`categorizeError` → "Open Settings" / "Retry" / "Pick a model") removes dead-ends new features will multiply. Ships the missing `--ease-*`/`--dur-*` tokens once, globally. | `themes.css`, `output-tab.html`, `library.html`, `options.html`, `output-tab.js` (error branch), `errors.js` | **S** | -| 2 | **Collections (Boards)** | The keystone. A named, color-tagged group becomes an *input* to generation (digest, stack-builder, ask-across all scope to it). Turns the flat library into a curated workspace. | `store/idb.js` (bump `DB_VERSION` 1→2, add `'collections'`), `store.js`, new `collections.js`, `library.{js,html}`, `library-data.js`, `backup.js`, `tests/collections.test.js` | **M** | -| 3 | **Decision Log** | Records the human's call (Adopt/Trial/Hold/Reject + note + timestamp) beside the AI fit chip, then exports a Markdown/CSV table. The first *kept artifact* — a defensible evaluation trail. | `store.js` (additive `decision`), new `decision-log.js`, `output-tab.js` (header control → `SET_DECISION`), `background.js`, `library.js`, `library-data.js`, `tests/decision-log.test.js` | **M** | -| 4 | **Tech-Stack Builder** (generative) | Pick 2–6 repos (from a Collection or multi-select), get roles + wiring + glue + gaps + layout, exportable. Directed synthesis vs. Combinator's random pairings — the decisive move into creation, reusing `runCombinator` plumbing and `layouts.js`. | new `stack-prompt.js`, `background.js` (`STACK_BUILD`), `library.js`, `output-tab.js`, `exporter.js` (`toStackMarkdown`), `models.js` (PARTS `'stack'`), `routing.js`, `tests/stack-prompt.test.js` | **L** | -| 5 | **Maintenance & Abandonment lens** (new scan) | The highest-value *factual* lens: fuses `pushed_at`/`archived`/`open_issues_count` + contributor bus-factor + runner CI/test facts into one Active/Slowing/Stale/Abandoned band. Grounded in signals a README can't fake; works even with a weak model. | new `maintenance.js`, `fetcher.js` (capture `pushed_at`/`archived` + `/contributors`), `background.js` (`MAINTENANCE`), `models.js` (PARTS `'maintenance'`), `explainers.js`, `output-tab.{js,html}` | **M** | +| # | Build | Why it serves the "creation/idea tool" goal | Files | Effort | +| --- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| 1 | **Subtle-motion + actionable-error pass** | Foundation polish before adding surfaces. Press states make every new button/chip feel real; routing errors by kind (`categorizeError` → "Open Settings" / "Retry" / "Pick a model") removes dead-ends new features will multiply. Ships the missing `--ease-*`/`--dur-*` tokens once, globally. | `themes.css`, `output-tab.html`, `library.html`, `options.html`, `output-tab.js` (error branch), `errors.js` | **S** | +| 2 | **Collections (Boards)** | The keystone. A named, color-tagged group becomes an _input_ to generation (digest, stack-builder, ask-across all scope to it). Turns the flat library into a curated workspace. | `store/idb.js` (bump `DB_VERSION` 1→2, add `'collections'`), `store.js`, new `collections.js`, `library.{js,html}`, `library-data.js`, `backup.js`, `tests/collections.test.js` | **M** | +| 3 | **Decision Log** | Records the human's call (Adopt/Trial/Hold/Reject + note + timestamp) beside the AI fit chip, then exports a Markdown/CSV table. The first _kept artifact_ — a defensible evaluation trail. | `store.js` (additive `decision`), new `decision-log.js`, `output-tab.js` (header control → `SET_DECISION`), `background.js`, `library.js`, `library-data.js`, `tests/decision-log.test.js` | **M** | +| 4 | **Tech-Stack Builder** (generative) | Pick 2–6 repos (from a Collection or multi-select), get roles + wiring + glue + gaps + layout, exportable. Directed synthesis vs. Combinator's random pairings — the decisive move into creation, reusing `runCombinator` plumbing and `layouts.js`. | new `stack-prompt.js`, `background.js` (`STACK_BUILD`), `library.js`, `output-tab.js`, `exporter.js` (`toStackMarkdown`), `models.js` (PARTS `'stack'`), `routing.js`, `tests/stack-prompt.test.js` | **L** | +| 5 | **Maintenance & Abandonment lens** (new scan) | The highest-value _factual_ lens: fuses `pushed_at`/`archived`/`open_issues_count` + contributor bus-factor + runner CI/test facts into one Active/Slowing/Stale/Abandoned band. Grounded in signals a README can't fake; works even with a weak model. | new `maintenance.js`, `fetcher.js` (capture `pushed_at`/`archived` + `/contributors`), `background.js` (`MAINTENANCE`), `models.js` (PARTS `'maintenance'`), `explainers.js`, `output-tab.{js,html}` | **M** | ## 4. New scans worth adding -- **Maintenance & Abandonment** — Active/Slowing/Stale/Abandoned band + bus-factor + watch-list, from GitHub metadata + contributor share + CI/test facts. *Runner optional.* **Ship first.** -- **License Compatibility (vs MY stack)** — Mostly deterministic: a small SPDX bucket table compares the repo's license against licenses already in your library. Instant, zero-token, offline. *No runner.* Uniquely library-grounded. -- **Diff Since I Last Looked** — On re-scan, `cache.js` already holds the "before"; `diffAnalyses(prev, next)` computes factual deltas (stars/version/deps/health/fit) for free, with an optional 1-call "did the verdict move" summary. *No runner.* -- **Fits MY Stack?** — Personalized adoption verdict from `libraryStats`/`allCapabilities`/`taxonomy.layerOf` + BM25 nearest repos: slots-in / new-paradigm / conflicts-with-X. *No runner.* The best differentiator no per-page tool can match. -- **Red-Team / What Could Bite Me In Prod** — Deliberately pessimistic ops checklist (secret-scan, missing CI/tests, unpinned deps, bus-factor) with likelihood + trigger + mitigation. *Runner strongly recommended.* +- **Maintenance & Abandonment** — Active/Slowing/Stale/Abandoned band + bus-factor + watch-list, from GitHub metadata + contributor share + CI/test facts. _Runner optional._ **Ship first.** +- **License Compatibility (vs MY stack)** — Mostly deterministic: a small SPDX bucket table compares the repo's license against licenses already in your library. Instant, zero-token, offline. _No runner._ Uniquely library-grounded. +- **Diff Since I Last Looked** — On re-scan, `cache.js` already holds the "before"; `diffAnalyses(prev, next)` computes factual deltas (stars/version/deps/health/fit) for free, with an optional 1-call "did the verdict move" summary. _No runner._ +- **Fits MY Stack?** — Personalized adoption verdict from `libraryStats`/`allCapabilities`/`taxonomy.layerOf` + BM25 nearest repos: slots-in / new-paradigm / conflicts-with-X. _No runner._ The best differentiator no per-page tool can match. +- **Red-Team / What Could Bite Me In Prod** — Deliberately pessimistic ops checklist (secret-scan, missing CI/tests, unpinned deps, bus-factor) with likelihood + trigger + mitigation. _Runner strongly recommended._ Guardrail: cap at **2 new lenses per release** to avoid lens-fatigue. ## 5. Productivity & creation features -Framed around *artifacts the user keeps or shares*: +Framed around _artifacts the user keeps or shares_: - **Collections (Boards)** — Decision-scoped groups ("Our 2026 stack", "Eval: vector DBs") that double as inputs to every generative feature. The workbench primitive. - **Decision Log** — Your recorded call + rationale beside the AI fit, exported as a Markdown/CSV evaluation trail. @@ -74,7 +74,7 @@ Framed around *artifacts the user keeps or shares*: **Keyboard / command palette** — Cmd/Ctrl-K + `/` fuzzy palette: jump to any tab, run a lens, switch theme/tone, open Library/Settings, quick-capture a URL — reuse the `.guide-veil` modal CSS. Visible superscript `1–9` hints on the first nine tabs. -**Errors / loading** — Actionable errors routed by `categorizeError` kind, rendering `userMessage`. Fix the hardcoded "Asking Claude to read this…" to interpolate repo name + the *actually routed* provider. Per-lens skeleton shimmer during runs. +**Errors / loading** — Actionable errors routed by `categorizeError` kind, rendering `userMessage`. Fix the hardcoded "Asking Claude to read this…" to interpolate repo name + the _actually routed_ provider. Per-lens skeleton shimmer during runs. **Accessibility** — Keyboard-reachable tooltips (tab-order + Esc + `aria-describedby`); a `:focus` background tint via `color-mix` for dark themes; narrow-width CSS (`@media (max-width:640px)`) so the verdict survives split-screen. @@ -84,26 +84,26 @@ Framed around *artifacts the user keeps or shares*: and warms a dense dashboard — but only as opt-in, reduced-motion-safe accent, never a blocking or chatty assistant. -- **Concept** — *Vee*, a telescope/lens character: a stroked lens-disc with one expressive +- **Concept** — _Vee_, a telescope/lens character: a stroked lens-disc with one expressive aperture "eye", a pupil/catch-light, and four faint cardinal ticks (reads as an - *instrument*, not a cartoon eye). It **is** the lens metaphor, so it's on-brand, not bolted-on. + _instrument_, not a cartoon eye). It **is** the lens metaphor, so it's on-brand, not bolted-on. - **Personality** — Calm, candid, dry. The senior engineer who read the source so you don't have to; honest about risk, never hype. Speaks in observation, not encouragement. - **Expression set** — `idle`, `scanning` (loading), `verdict-strong` (wide/green), `verdict-risky` (narrowed/red/tilted), `thinking` (deep dive), `empty` (sleepy/inviting), `error` (sheepish tilt — **not** red; red is reserved for verdicts). -| Moment | State | Location | -|---|---|---| -| Scan loading | `scanning` | `#loading-state` (replaces the pulse box) | -| Verdict rendered | `verdict-strong` / `verdict-risky` | beside `.v-fit` | -| Deep dive running | `thinking` | drive while request in flight | -| Unrun lens / empty Library | `empty` | guided empty-state card / `.lib-empty` | -| Error | `error` | `#error-state` | +| Moment | State | Location | +| -------------------------- | ---------------------------------- | ----------------------------------------- | +| Scan loading | `scanning` | `#loading-state` (replaces the pulse box) | +| Verdict rendered | `verdict-strong` / `verdict-risky` | beside `.v-fit` | +| Deep dive running | `thinking` | drive while request in flight | +| Unrun lens / empty Library | `empty` | guided empty-state card / `.lib-empty` | +| Error | `error` | `#error-state` | - **Implementation** — Inline SVG + CSS only (no asset pipeline). Lens/aperture/pupil use `currentColor` + `--accent`/status tokens, so Vee re-skins across all 13 themes for free - (verified on dark *and* light). Expressions are CSS-class swaps on one SVG; a tiny + (verified on dark _and_ light). Expressions are CSS-class swaps on one SVG; a tiny `mascot.js` exposes `setMascotState(el, state)` / `setMascotFromFit(el, fit)`. - **Accessibility + guardrails** — `aria-hidden="true"` (decorative; never the sole carrier of meaning). All motion behind `@media (prefers-reduced-motion: no-preference)` → static @@ -116,18 +116,18 @@ blocking or chatty assistant. The motion pass (next-5 item #1, animation half) shipped this session. Findings fixed: -| Finding | Fix | -|---|---| -| Zero `:active` press states across all 3 surfaces (HIGH×3) | Uniform `:active { scale(0.97) }` on tabs/buttons/chips (chips 0.96, cards 0.99, swatch 0.95) — instant for everyone | -| `--dur` referenced but never defined; no `--ease-*` tokens | Added `--dur-fast/--dur/--dur-slow` + `--ease-out/--ease-in/--ease-spring` to `themes.css :root` (inherited by all 13 themes) | -| `.tab-btn { transition: all .15s }` (over-broad `all`, symmetric ease) | Enumerated props + `var(--ease-out)` | -| Health/score bars set width with no fill motion | `.health-fill`/`.score-fill` fill in via `scaleX` keyframe | -| `.tab-content.active` instant `display:block` pop | Staged `tab-in` fade + 4px rise on each switch | -| `.guide-veil` modal toggled `display` (instant) | Veil fades, dialog rises (opacity+visibility), reduced-motion falls back to display toggle | -| `.saved-badge` `fadeup .4s` (>300ms), never exits | `badge-in` spring entrance at `--dur-slow` | -| `.swatch:hover` scale 1.15 (>1.05 ceiling), no active | Tamed to 1.05 hover / 0.95 active | -| `.token-panel`/`.model-row` "slides in" comment but instant | Real `panel-in` ease-out reveal | -| Library grid pops in | Capped `:nth-child` stagger (≤270ms total), `backwards` fill so hover lift survives | +| Finding | Fix | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Zero `:active` press states across all 3 surfaces (HIGH×3) | Uniform `:active { scale(0.97) }` on tabs/buttons/chips (chips 0.96, cards 0.99, swatch 0.95) — instant for everyone | +| `--dur` referenced but never defined; no `--ease-*` tokens | Added `--dur-fast/--dur/--dur-slow` + `--ease-out/--ease-in/--ease-spring` to `themes.css :root` (inherited by all 13 themes) | +| `.tab-btn { transition: all .15s }` (over-broad `all`, symmetric ease) | Enumerated props + `var(--ease-out)` | +| Health/score bars set width with no fill motion | `.health-fill`/`.score-fill` fill in via `scaleX` keyframe | +| `.tab-content.active` instant `display:block` pop | Staged `tab-in` fade + 4px rise on each switch | +| `.guide-veil` modal toggled `display` (instant) | Veil fades, dialog rises (opacity+visibility), reduced-motion falls back to display toggle | +| `.saved-badge` `fadeup .4s` (>300ms), never exits | `badge-in` spring entrance at `--dur-slow` | +| `.swatch:hover` scale 1.15 (>1.05 ceiling), no active | Tamed to 1.05 hover / 0.95 active | +| `.token-panel`/`.model-row` "slides in" comment but instant | Real `panel-in` ease-out reveal | +| Library grid pops in | Capped `:nth-child` stagger (≤270ms total), `backwards` fill so hover lift survives | Every enhancement is wrapped in `@media (prefers-reduced-motion: no-preference)` (press states excepted — they're instant, not motion); the existing aggressive reduce-motion @@ -138,8 +138,8 @@ sit behind the reduced-motion guard. ## 9. Sequencing -| Version | Theme | Ships | -|---|---|---| -| **v1.7 — "Feels alive & honest"** | Polish + first persistence | ✅ Motion pass · Actionable error routing · Loading-microcopy fix · **Collections** · **Decision Log** · First-scan coachmark + guided empty states | -| **v1.8 — "Reason over your library"** | Library becomes a knowledge base | **Ask Across My Library** · **Maintenance** + **License Compatibility** lenses · Saved Searches · Command palette · Annotations · Progressive Verdict · narrow-width CSS | -| **v1.9 — "Build, not just browse"** | Move into creation | **Tech-Stack Builder** → **Scaffold Export** · Comparison/Decision exports · Shareable Verdict Cards · **Diff** + **Fits MY Stack?** lenses · Mascot "Vee" (optional) · Weekly Digest | +| Version | Theme | Ships | +| ------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **v1.7 — "Feels alive & honest"** | Polish + first persistence | ✅ Motion pass · Actionable error routing · Loading-microcopy fix · **Collections** · **Decision Log** · First-scan coachmark + guided empty states | +| **v1.8 — "Reason over your library"** | Library becomes a knowledge base | **Ask Across My Library** · **Maintenance** + **License Compatibility** lenses · Saved Searches · Command palette · Annotations · Progressive Verdict · narrow-width CSS | +| **v1.9 — "Build, not just browse"** | Move into creation | **Tech-Stack Builder** → **Scaffold Export** · Comparison/Decision exports · Shareable Verdict Cards · **Diff** + **Fits MY Stack?** lenses · Mascot "Vee" (optional) · Weekly Digest | diff --git a/docs/style/stop-slop/SKILL.md b/docs/style/stop-slop/SKILL.md index 83f2032..bb522f1 100644 --- a/docs/style/stop-slop/SKILL.md +++ b/docs/style/stop-slop/SKILL.md @@ -49,13 +49,13 @@ Before delivering prose: Rate 1-10 on each dimension: -| Dimension | Question | -|-----------|----------| -| Directness | Statements or announcements? | -| Rhythm | Varied or metronomic? | -| Trust | Respects reader intelligence? | -| Authenticity | Sounds human? | -| Density | Anything cuttable? | +| Dimension | Question | +| ------------ | ----------------------------- | +| Directness | Statements or announcements? | +| Rhythm | Varied or metronomic? | +| Trust | Respects reader intelligence? | +| Authenticity | Sounds human? | +| Density | Anything cuttable? | Below 35/50: revise. diff --git a/docs/style/stop-slop/references/phrases.md b/docs/style/stop-slop/references/phrases.md index f9234fe..8009149 100644 --- a/docs/style/stop-slop/references/phrases.md +++ b/docs/style/stop-slop/references/phrases.md @@ -36,19 +36,19 @@ These add no meaning. Delete them. Replace with plain language. -| Avoid | Use instead | -|-------|-------------| -| Navigate (challenges) | Handle, address | -| Unpack (analysis) | Explain, examine | -| Lean into | Accept, embrace | -| Landscape (context) | Situation, field | -| Game-changer | Significant, important | -| Double down | Commit, increase | -| Deep dive | Analysis, examination | -| Take a step back | Reconsider | -| Moving forward | Next, from now | -| Circle back | Return to, revisit | -| On the same page | Aligned, agreed | +| Avoid | Use instead | +| --------------------- | ---------------------- | +| Navigate (challenges) | Handle, address | +| Unpack (analysis) | Explain, examine | +| Lean into | Accept, embrace | +| Landscape (context) | Situation, field | +| Game-changer | Significant, important | +| Double down | Commit, increase | +| Deep dive | Analysis, examination | +| Take a step back | Reconsider | +| Moving forward | Next, from now | +| Circle back | Return to, revisit | +| On the same page | Aligned, agreed | ## Adverbs diff --git a/docs/style/stop-slop/references/structures.md b/docs/style/stop-slop/references/structures.md index bbcc359..f52384d 100644 --- a/docs/style/stop-slop/references/structures.md +++ b/docs/style/stop-slop/references/structures.md @@ -4,30 +4,30 @@ These create false drama. State the point directly. -| Pattern | Problem | -|---------|---------| -| "Not because X. Because Y." / "Not because X, but because Y." | Telegraphed reversal | -| "[X] isn't the problem. [Y] is." | Formulaic reframe | -| "The answer isn't X. It's Y." | Predictable pivot | -| "It feels like X. It's actually Y." | Setup/reveal cliche | -| "The question isn't X. It's Y." | Rhetorical misdirection | -| "Not X. But Y." / "not X, it's Y" / "isn't X, it's Y" | Mechanical contrast | -| "It's not this. It's that." | Same formula, different words | -| "stops being X and starts being Y" | False transformation arc | -| "doesn't mean X, but actually Y" | Negation-then-assertion crutch | -| "is about X but not Y" | False distinction | -| "not just X but also Y" | Additive hedge | +| Pattern | Problem | +| ------------------------------------------------------------- | ------------------------------ | +| "Not because X. Because Y." / "Not because X, but because Y." | Telegraphed reversal | +| "[X] isn't the problem. [Y] is." | Formulaic reframe | +| "The answer isn't X. It's Y." | Predictable pivot | +| "It feels like X. It's actually Y." | Setup/reveal cliche | +| "The question isn't X. It's Y." | Rhetorical misdirection | +| "Not X. But Y." / "not X, it's Y" / "isn't X, it's Y" | Mechanical contrast | +| "It's not this. It's that." | Same formula, different words | +| "stops being X and starts being Y" | False transformation arc | +| "doesn't mean X, but actually Y" | Negation-then-assertion crutch | +| "is about X but not Y" | False distinction | +| "not just X but also Y" | Additive hedge | **Instead:** State Y directly. "The problem is Y." "Y matters here." Drop the negation entirely. ## Negative Listing -Listing what something is *not* before revealing what it *is*. A rhetorical striptease. +Listing what something is _not_ before revealing what it _is_. A rhetorical striptease. -| Pattern | Problem | -|---------|---------| -| "Not a X... Not a Y... A Z." | Dramatic buildup through negation | -| "It wasn't X. It wasn't Y. It was Z." | Same structure, past tense | +| Pattern | Problem | +| ------------------------------------- | --------------------------------- | +| "Not a X... Not a Y... A Z." | Dramatic buildup through negation | +| "It wasn't X. It wasn't Y. It was Z." | Same structure, past tense | **Instead:** State Z. The reader doesn't need the runway. @@ -35,11 +35,11 @@ Listing what something is *not* before revealing what it *is*. A rhetorical stri Sentence fragments for emphasis read as manufactured profundity. -| Pattern | Problem | -|---------|---------| +| Pattern | Problem | +| ---------------------------------------- | ----------------------- | | "[Noun]. That's it. That's the [thing]." | Performative simplicity | -| "X. And Y. And Z." | Staccato drama | -| "This unlocks something. [Word]." | Artificial revelation | +| "X. And Y. And Z." | Staccato drama | +| "This unlocks something. [Word]." | Artificial revelation | **Instead:** Complete sentences. Trust content over presentation. @@ -47,35 +47,35 @@ Sentence fragments for emphasis read as manufactured profundity. These announce insight rather than deliver it. -| Pattern | Problem | -|---------|---------| -| "What if [reframe]?" | Socratic posturing | -| "Here's what I mean:" | Redundant preview | -| "Think about it:" | Condescending prompt | -| "And that's okay." | Unnecessary permission | +| Pattern | Problem | +| --------------------- | ---------------------- | +| "What if [reframe]?" | Socratic posturing | +| "Here's what I mean:" | Redundant preview | +| "Think about it:" | Condescending prompt | +| "And that's okay." | Unnecessary permission | **Instead:** Make the point. Let readers draw conclusions. ## Formulaic Constructions -| Pattern | Problem | -|---------|---------| -| "By the time X, I was Y." | Narrative template | -| "X that isn't Y" | Indirect. Say "X is broken" | +| Pattern | Problem | +| ------------------------- | --------------------------- | +| "By the time X, I was Y." | Narrative template | +| "X that isn't Y" | Indirect. Say "X is broken" | ## False Agency Giving inanimate things human verbs. Complaints don't "become" fixes. Bets don't "live or die." Decisions don't "emerge." A person does something to make those things happen. AI loves this because it avoids naming the actor. -| Pattern | Problem | -|---------|---------| -| "a complaint becomes a fix" | The complaint did nothing. Someone fixed it. | -| "a bet lives or dies in days" | Bets don't have lifespans. Someone kills the project or ships it. | -| "the decision emerges" | Decisions don't emerge. Someone decides. | -| "the culture shifts" | Cultures don't shift on their own. People change behavior. | -| "the conversation moves toward" | Conversations don't move. Someone steers. | -| "the data tells us" | Data sits there. Someone reads it and draws a conclusion. | -| "the market rewards" | Markets don't reward. Buyers pay for things. | +| Pattern | Problem | +| ------------------------------- | ----------------------------------------------------------------- | +| "a complaint becomes a fix" | The complaint did nothing. Someone fixed it. | +| "a bet lives or dies in days" | Bets don't have lifespans. Someone kills the project or ships it. | +| "the decision emerges" | Decisions don't emerge. Someone decides. | +| "the culture shifts" | Cultures don't shift on their own. People change behavior. | +| "the conversation moves toward" | Conversations don't move. Someone steers. | +| "the data tells us" | Data sits there. Someone reads it and draws a conclusion. | +| "the market rewards" | Markets don't reward. Buyers pay for things. | **Instead:** Name the human. "The team fixed it that week" beats "the complaint becomes a fix." If no specific person fits, use "you" to put the reader in the seat. @@ -83,12 +83,12 @@ Giving inanimate things human verbs. Complaints don't "become" fixes. Bets don't Floating above the scene instead of putting the reader in it. -| Pattern | Problem | -|---------|---------| -| "Nobody designed this." | Disembodied observation | -| "This happens because..." | Lecturer voice | -| "This is why..." | Same | -| "People tend to..." | Armchair sociologist | +| Pattern | Problem | +| ------------------------- | ----------------------- | +| "Nobody designed this." | Disembodied observation | +| "This happens because..." | Lecturer voice | +| "This is why..." | Same | +| "People tend to..." | Armchair sociologist | **Instead:** Put the reader in the room. "You don't sit down one day and decide to..." beats "Nobody designed this." @@ -96,39 +96,39 @@ Floating above the scene instead of putting the reader in it. Every sentence needs a subject doing something. Passive voice hides the actor and drains energy. -| Pattern | Fix | -|---------|-----| -| "X was created" | Name who created it | -| "It is believed that" | Name who believes it | -| "Mistakes were made" | Name who made them | -| "The decision was reached" | Name who decided | +| Pattern | Fix | +| -------------------------- | -------------------- | +| "X was created" | Name who created it | +| "It is believed that" | Name who believes it | +| "Mistakes were made" | Name who made them | +| "The decision was reached" | Name who decided | **Instead:** Find the actor. Put them at the front of the sentence. ## Sentence Starters to Avoid -| Pattern | Fix | -|---------|-----| +| Pattern | Fix | +| --------------------------------------------------------------- | ----------------------------------------------- | | Sentences starting with What, When, Where, Which, Who, Why, How | Restructure. Lead with the subject or the verb. | -| Paragraphs starting with "So" | Start with content | -| Sentences starting with "Look," | Remove | +| Paragraphs starting with "So" | Start with content | +| Sentences starting with "Look," | Remove | Wh- openers become a crutch. "What makes this hard is..." becomes "The constraint is..." or better, name the specific constraint. ## Rhythm Patterns -| Pattern | Fix | -|---------|-----| -| Three-item lists | Use two items or one | -| Questions answered immediately | Let questions breathe or cut them | -| Every paragraph ends punchily | Vary endings | -| Em-dashes | Remove. Use commas or periods. No em dashes at all. | -| Staccato fragmentation | Don't stack short punchy sentences | -| "Not always. Not perfectly." | Hedging disguised as reassurance | +| Pattern | Fix | +| ------------------------------ | --------------------------------------------------- | +| Three-item lists | Use two items or one | +| Questions answered immediately | Let questions breathe or cut them | +| Every paragraph ends punchily | Vary endings | +| Em-dashes | Remove. Use commas or periods. No em dashes at all. | +| Staccato fragmentation | Don't stack short punchy sentences | +| "Not always. Not perfectly." | Hedging disguised as reassurance | ## Word Patterns -| Pattern | Problem | -|---------|---------| -| Lazy extremes (every, always, never, everyone, everybody, nobody) | False authority. Use specifics instead of sweeping claims. | -| All adverbs (-ly words, "really," "just," "literally," "genuinely," "honestly," "simply," "actually") | Empty emphasis. See phrases.md for full list. | +| Pattern | Problem | +| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| Lazy extremes (every, always, never, everyone, everybody, nobody) | False authority. Use specifics instead of sweeping claims. | +| All adverbs (-ly words, "really," "just," "literally," "genuinely," "honestly," "simply," "actually") | Empty emphasis. See phrases.md for full list. | diff --git a/errors.js b/errors.js index bc16d2f..6cfc9ef 100644 --- a/errors.js +++ b/errors.js @@ -5,17 +5,17 @@ // error joined by " · "; this surfaces the single most fixable one instead. const KIND_META = { - none: { retryable: false, fixable: true, priority: 6 }, - auth: { retryable: false, fixable: true, priority: 5 }, - not_found: { retryable: false, fixable: true, priority: 4 }, - bad_request: { retryable: false, fixable: true, priority: 3 }, - rate_limit: { retryable: true, fixable: false, priority: 2 }, - server: { retryable: true, fixable: false, priority: 1 }, - network: { retryable: true, fixable: false, priority: 1 }, + none: { retryable: false, fixable: true, priority: 6 }, + auth: { retryable: false, fixable: true, priority: 5 }, + not_found: { retryable: false, fixable: true, priority: 4 }, + bad_request: { retryable: false, fixable: true, priority: 3 }, + rate_limit: { retryable: true, fixable: false, priority: 2 }, + server: { retryable: true, fixable: false, priority: 1 }, + network: { retryable: true, fixable: false, priority: 1 }, // A client-side request timeout: NOT retryable, so the attempt plan falls // straight to the next provider rather than re-hammering a stalled one. - timeout: { retryable: false, fixable: false, priority: 1 }, - unknown: { retryable: false, fixable: false, priority: 0 }, + timeout: { retryable: false, fixable: false, priority: 1 }, + unknown: { retryable: false, fixable: false, priority: 0 }, }; function messageOf(err) { @@ -35,15 +35,24 @@ function statusOf(err) { function humanize(kind, provider, fallback) { const who = provider || 'This provider'; switch (kind) { - case 'none': return 'No AI provider connected — open Settings to add a key.'; - case 'auth': return `${who}’s credential was rejected — check or reconnect it in Settings.`; - case 'rate_limit': return `${who} is rate-limited — wait a moment, or route this part to another provider.`; - case 'not_found': return `${who} didn’t recognize that model — pick a valid model in Settings.`; - case 'server': return `${who} is temporarily unavailable — retried and still failing. Try again shortly.`; - case 'timeout': return `${who} took too long to respond. Try again, or route this part to a faster provider in Settings.`; - case 'network': return `Couldn’t reach ${provider || 'the provider'} — check your connection and retry.`; - case 'bad_request': return fallback || `${who} rejected the request as malformed.`; - default: return fallback || 'Something went wrong with the AI request.'; + case 'none': + return 'No AI provider connected — open Settings to add a key.'; + case 'auth': + return `${who}’s credential was rejected — check or reconnect it in Settings.`; + case 'rate_limit': + return `${who} is rate-limited — wait a moment, or route this part to another provider.`; + case 'not_found': + return `${who} didn’t recognize that model — pick a valid model in Settings.`; + case 'server': + return `${who} is temporarily unavailable — retried and still failing. Try again shortly.`; + case 'timeout': + return `${who} took too long to respond. Try again, or route this part to a faster provider in Settings.`; + case 'network': + return `Couldn’t reach ${provider || 'the provider'} — check your connection and retry.`; + case 'bad_request': + return fallback || `${who} rejected the request as malformed.`; + default: + return fallback || 'Something went wrong with the AI request.'; } } @@ -60,16 +69,30 @@ export function categorizeError(err, provider = '') { let kind = 'unknown'; if (/no ai provider configured|open settings to connect one/i.test(msg)) kind = 'none'; - else if (status === 401 || status === 403 || /\bexpired\b|invalid .*key|unauthor|forbidden|reconnect|no .* credential|add a key/i.test(low)) kind = 'auth'; + else if ( + status === 401 || + status === 403 || + /\bexpired\b|invalid .*key|unauthor|forbidden|reconnect|no .* credential|add a key/i.test(low) + ) + kind = 'auth'; else if (status === 429 || /rate.?limit|too many requests|quota/i.test(low)) kind = 'rate_limit'; - else if (status === 404 || /not found|unknown model|no such model|does not exist/i.test(low)) kind = 'not_found'; - else if (status >= 500 || /server error|unavailable|bad gateway|gateway timeout|overloaded/i.test(low)) kind = 'server'; + else if (status === 404 || /not found|unknown model|no such model|does not exist/i.test(low)) + kind = 'not_found'; + else if (status >= 500 || /server error|unavailable|bad gateway|gateway timeout|overloaded/i.test(low)) + kind = 'server'; else if (status === 400 || /bad request|invalid request/i.test(low)) kind = 'bad_request'; else if (/timed out after \d+\s*s\b/i.test(low)) kind = 'timeout'; - else if (/network|failed to fetch|fetch failed|timeout|timed out|connection refused/i.test(low)) kind = 'network'; + else if (/network|failed to fetch|fetch failed|timeout|timed out|connection refused/i.test(low)) + kind = 'network'; const meta = KIND_META[kind]; - return { kind, retryable: meta.retryable, fixable: meta.fixable, priority: meta.priority, userMessage: humanize(kind, provider, msg) }; + return { + kind, + retryable: meta.retryable, + fixable: meta.fixable, + priority: meta.priority, + userMessage: humanize(kind, provider, msg), + }; } // Kinds the user can fix in Settings (a key, a model, a connection). The rest are @@ -100,7 +123,8 @@ export function errorActions(kind, canRetry) { */ export function rankErrors(items) { const list = items || []; - if (!list.length) return { kind: 'none', userMessage: 'No AI provider connected — open Settings to add a key.' }; + if (!list.length) + return { kind: 'none', userMessage: 'No AI provider connected — open Settings to add a key.' }; const infos = list.map((it) => { const provider = (it && it.provider) || ''; const err = it && (it.error ?? it.message) !== undefined ? (it.error ?? it.message) : it; diff --git a/eslint.config.js b/eslint.config.js index ab1cfc8..835ba37 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import globals from 'globals'; // intentionally light — advisory warnings, not a wall of errors — so the gate // is useful without demanding a rewrite of working code. export default [ - { ignores: ['node_modules/**', 'coverage/**', 'website/**', '.vitest/**', 'vendor/**'] }, + { ignores: ['node_modules/**', 'coverage/**', 'website/**', '.vitest/**', 'vendor/**', '.verify/**'] }, js.configs.recommended, { files: ['**/*.js', '**/*.mjs'], @@ -16,7 +16,7 @@ export default [ globals: { ...globals.browser, ...globals.webextensions, ...globals.node }, }, rules: { - 'no-console': 'warn', + 'no-console': ['warn', { allow: ['warn', 'error'] }], 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], 'no-empty': ['warn', { allowEmptyCatch: true }], 'prefer-const': 'warn', diff --git a/evaluations.js b/evaluations.js index e60769c..4b0ac30 100644 --- a/evaluations.js +++ b/evaluations.js @@ -7,9 +7,9 @@ const RUBRIC_KEY = 'repolens_rubric'; const EVALS_KEY = 'repolens_evaluations'; export const DEFAULT_RUBRIC = [ - { id: 'docs', name: 'Documentation', weight: 1 }, - { id: 'types', name: 'Type safety', weight: 1 }, - { id: 'maint', name: 'Maintenance', weight: 1 }, + { id: 'docs', name: 'Documentation', weight: 1 }, + { id: 'types', name: 'Type safety', weight: 1 }, + { id: 'maint', name: 'Maintenance', weight: 1 }, ]; /** Load the current rubric (falls back to DEFAULT_RUBRIC). */ @@ -64,7 +64,8 @@ export async function listEvals() { */ export function computeScore(evaluation, rubric) { if (!evaluation?.scores || !rubric?.length) return null; - let sum = 0, totalWeight = 0; + let sum = 0, + totalWeight = 0; for (const crit of rubric) { const score = evaluation.scores[crit.id]; if (score >= 1 && score <= 5) { diff --git a/explainers.js b/explainers.js index a1765cc..63ca62b 100644 --- a/explainers.js +++ b/explainers.js @@ -2,21 +2,105 @@ // keyed by the data-tab id on its button. Pure data + lookup — no DOM. export const SCAN_EXPLAINERS = { - 10: { title: 'Deep Dive', bestFor: 'Understanding HOW it works inside — semantic units, causal lineage, and a from-scratch explanation that self-tests.', skipIf: 'You only need a quick adopt/skip verdict, or the repo is tiny.', cost: '3 chained AI calls · GitHub source' }, - 11: { title: 'Systems', bestFor: 'Seeing the repo as a system in motion — its bottleneck, feedback loops, or improvement cycle.', skipIf: 'A static feature read is enough and dynamics won\'t change your decision.', cost: '1 AI call per framework' }, - 12: { title: 'Ideate', bestFor: 'Generating new directions — TRIZ / SCAMPER / lateral prompts to spark extensions.', skipIf: 'You want an assessment of what exists, not new ideas.', cost: '1 AI call per framework' }, - 13: { title: 'Prioritize', bestFor: 'Deciding what matters most — Pareto 80/20 or an Eisenhower urgent/important split.', skipIf: 'There is nothing to triage yet, or scope is already clear.', cost: '1 AI call per framework' }, - 14: { title: 'SKTPG', bestFor: 'A one-tap directional read — what to know, the pitfalls, and the growth path.', skipIf: 'You already know this space well.', cost: '1 AI call' }, - 16: { title: 'Similar', bestFor: 'Finding repos already in your library that are close to this one.', skipIf: 'Your library is empty or this is your first scan.', cost: 'Instant · local lookup' }, - 18: { title: 'Synergies', bestFor: 'Finding complementary repos that pair well with this one.', skipIf: 'You only care about this repo in isolation.', cost: '1 AI call · grounded in your library' }, - 17: { title: 'Versus', bestFor: 'A head-to-head comparison against a specific other repo.', skipIf: 'You have no concrete alternative in mind to compare.', cost: '1 AI call' }, - 19: { title: 'Connections', bestFor: 'Walking the semantic map your scans build — alternatives, synergies, versus links, and pinned ideas, one hop at a time.', skipIf: 'Your library is nearly empty — the map needs a few scans first.', cost: 'Instant · local graph' }, - 20: { title: 'Combine', bestFor: 'Fusing this repo with complementary library repos into concrete new project ideas, scored on novelty and feasibility.', skipIf: 'You haven\'t analyzed the ingredients yet — it builds on your library.', cost: 'Several AI calls — one per combo' }, - 21: { title: 'Docs Quality', bestFor: 'Answering "can I use this without reading the source?" — scores README completeness, quickstart, code examples, API reference, changelog, and contributing guide.', skipIf: 'The docs are clearly excellent or clearly absent — most useful in the grey zone where you\'re unsure.', cost: '1 AI call · README + file tree' }, - 22: { title: 'Maintenance', bestFor: 'Quickly auditing commit recency, contributor bus-factor, CI presence, and open-issue health — signals a README can\'t fake.', skipIf: 'You already know this is actively maintained or that it\'s abandonware.', cost: '1 AI call · GitHub metadata + file tree' }, - 23: { title: 'License Compat', bestFor: 'Checking whether this repo\'s license conflicts with what\'s already in your library — flags GPL/AGPL friction with permissive stacks.', skipIf: 'Your library is empty or you\'re working purely open-source.', cost: 'Instant · no AI · library lookup' }, - 24: { title: 'Since Last Scan', bestFor: 'Seeing exactly what changed between this scan and the last one — star growth, health shift, new or removed red flags, version bump.', skipIf: 'This is your first scan of the repo.', cost: 'Instant · no AI · cached snapshot diff' }, - 25: { title: 'Fits MY Stack?', bestFor: 'Answering "does this slot in, introduce a paradigm shift, or conflict with what I already use?" — grounded in your actual library.', skipIf: 'Your library is empty or this is a completely isolated experiment.', cost: '1 AI call · grounded in your library' }, + 10: { + title: 'Deep Dive', + bestFor: + 'Understanding HOW it works inside — semantic units, causal lineage, and a from-scratch explanation that self-tests.', + skipIf: 'You only need a quick adopt/skip verdict, or the repo is tiny.', + cost: '3 chained AI calls · GitHub source', + }, + 11: { + title: 'Systems', + bestFor: 'Seeing the repo as a system in motion — its bottleneck, feedback loops, or improvement cycle.', + skipIf: "A static feature read is enough and dynamics won't change your decision.", + cost: '1 AI call per framework', + }, + 12: { + title: 'Ideate', + bestFor: 'Generating new directions — TRIZ / SCAMPER / lateral prompts to spark extensions.', + skipIf: 'You want an assessment of what exists, not new ideas.', + cost: '1 AI call per framework', + }, + 13: { + title: 'Prioritize', + bestFor: 'Deciding what matters most — Pareto 80/20 or an Eisenhower urgent/important split.', + skipIf: 'There is nothing to triage yet, or scope is already clear.', + cost: '1 AI call per framework', + }, + 14: { + title: 'SKTPG', + bestFor: 'A one-tap directional read — what to know, the pitfalls, and the growth path.', + skipIf: 'You already know this space well.', + cost: '1 AI call', + }, + 16: { + title: 'Similar', + bestFor: 'Finding repos already in your library that are close to this one.', + skipIf: 'Your library is empty or this is your first scan.', + cost: 'Instant · local lookup', + }, + 18: { + title: 'Synergies', + bestFor: 'Finding complementary repos that pair well with this one.', + skipIf: 'You only care about this repo in isolation.', + cost: '1 AI call · grounded in your library', + }, + 17: { + title: 'Versus', + bestFor: 'A head-to-head comparison against a specific other repo.', + skipIf: 'You have no concrete alternative in mind to compare.', + cost: '1 AI call', + }, + 19: { + title: 'Connections', + bestFor: + 'Walking the semantic map your scans build — alternatives, synergies, versus links, and pinned ideas, one hop at a time.', + skipIf: 'Your library is nearly empty — the map needs a few scans first.', + cost: 'Instant · local graph', + }, + 20: { + title: 'Combine', + bestFor: + 'Fusing this repo with complementary library repos into concrete new project ideas, scored on novelty and feasibility.', + skipIf: "You haven't analyzed the ingredients yet — it builds on your library.", + cost: 'Several AI calls — one per combo', + }, + 21: { + title: 'Docs Quality', + bestFor: + 'Answering "can I use this without reading the source?" — scores README completeness, quickstart, code examples, API reference, changelog, and contributing guide.', + skipIf: + "The docs are clearly excellent or clearly absent — most useful in the grey zone where you're unsure.", + cost: '1 AI call · README + file tree', + }, + 22: { + title: 'Maintenance', + bestFor: + "Quickly auditing commit recency, contributor bus-factor, CI presence, and open-issue health — signals a README can't fake.", + skipIf: "You already know this is actively maintained or that it's abandonware.", + cost: '1 AI call · GitHub metadata + file tree', + }, + 23: { + title: 'License Compat', + bestFor: + "Checking whether this repo's license conflicts with what's already in your library — flags GPL/AGPL friction with permissive stacks.", + skipIf: "Your library is empty or you're working purely open-source.", + cost: 'Instant · no AI · library lookup', + }, + 24: { + title: 'Since Last Scan', + bestFor: + 'Seeing exactly what changed between this scan and the last one — star growth, health shift, new or removed red flags, version bump.', + skipIf: 'This is your first scan of the repo.', + cost: 'Instant · no AI · cached snapshot diff', + }, + 25: { + title: 'Fits MY Stack?', + bestFor: + 'Answering "does this slot in, introduce a paradigm shift, or conflict with what I already use?" — grounded in your actual library.', + skipIf: 'Your library is empty or this is a completely isolated experiment.', + cost: '1 AI call · grounded in your library', + }, }; export function explainerFor(tabId) { diff --git a/exporter.js b/exporter.js index 6785eea..204ef50 100644 --- a/exporter.js +++ b/exporter.js @@ -11,11 +11,16 @@ export function toMarkdown(d) { push(`# ${d.repoId || 'Repository'}`, ''); if (d.description) push(`> ${d.description}`, ''); - const meta = [d.language, d.license, d.stars ? `${d.stars}★` : null, d.platform].filter(x => x && x !== 'Unknown'); + const meta = [d.language, d.license, d.stars ? `${d.stars}★` : null, d.platform].filter( + (x) => x && x !== 'Unknown' + ); if (meta.length) push(meta.join(' · '), ''); - if (d.health && d.health.score != null) push(`**Health:** ${d.health.score}/100${d.health.summary ? ` — ${d.health.summary}` : ''}`, ''); + if (d.health && d.health.score != null) + push(`**Health:** ${d.health.score}/100${d.health.summary ? ` — ${d.health.summary}` : ''}`, ''); - const para = (title, body) => { if (body) push(`## ${title}`, '', body, ''); }; + const para = (title, body) => { + if (body) push(`## ${title}`, '', body, ''); + }; const kvList = (title, obj) => { if (obj && Object.values(obj).some(Boolean)) { push(`## ${title}`, ''); @@ -32,17 +37,41 @@ export function toMarkdown(d) { if (d.pros?.length || d.cons?.length) { push('## Pros & Cons', ''); - if (d.pros?.length) { push('**Pros**'); d.pros.forEach(p => push(`- ${p}`)); push(''); } - if (d.cons?.length) { push('**Cons**'); d.cons.forEach(c => push(`- ${c}`)); push(''); } + if (d.pros?.length) { + push('**Pros**'); + d.pros.forEach((p) => push(`- ${p}`)); + push(''); + } + if (d.cons?.length) { + push('**Cons**'); + d.cons.forEach((c) => push(`- ${c}`)); + push(''); + } + } + if (d.alternatives?.length) { + push('## Alternatives', ''); + d.alternatives.forEach((a) => push(`- **${a.name}** — ${a.when}`)); + push(''); } - if (d.alternatives?.length) { push('## Alternatives', ''); d.alternatives.forEach(a => push(`- **${a.name}** — ${a.when}`)); push(''); } if (d.tech_stack?.built_with?.length || d.tech_stack?.key_dependencies?.length) { push('## Tech Stack', ''); if (d.tech_stack.built_with?.length) push(`Built with: ${d.tech_stack.built_with.join(', ')}`, ''); - if (d.tech_stack.key_dependencies?.length) { push('**Key dependencies**'); d.tech_stack.key_dependencies.forEach(k => push(`- \`${k.name}\` — ${k.purpose}`)); push(''); } + if (d.tech_stack.key_dependencies?.length) { + push('**Key dependencies**'); + d.tech_stack.key_dependencies.forEach((k) => push(`- \`${k.name}\` — ${k.purpose}`)); + push(''); + } + } + if (d.red_flags?.length) { + push('## Red Flags', ''); + d.red_flags.forEach((f) => push(`- ${f.severity === 'ok' ? '✅' : '⚠️'} **${f.title}** — ${f.text}`)); + push(''); + } + if (d.start_here?.length) { + push('## Start Here', ''); + d.start_here.forEach((e) => push(`- ${e.icon || ''} **${e.title}** (${e.tag}) — ${e.desc}`)); + push(''); } - if (d.red_flags?.length) { push('## Red Flags', ''); d.red_flags.forEach(f => push(`- ${f.severity === 'ok' ? '✅' : '⚠️'} **${f.title}** — ${f.text}`)); push(''); } - if (d.start_here?.length) { push('## Start Here', ''); d.start_here.forEach(e => push(`- ${e.icon || ''} **${e.title}** (${e.tag}) — ${e.desc}`)); push(''); } push('---', '_Generated by RepoLens._'); return out.join('\n'); @@ -51,20 +80,47 @@ export function toMarkdown(d) { // Minimal Markdown → HTML for our structured export (headings, lists, quote, // hr, **bold**, `code`). Not a general-purpose converter. function mdToHtml(md) { - const inline = (s) => escapeHtml(s).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1'); + const inline = (s) => + escapeHtml(s) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`(.+?)`/g, '$1'); const out = []; let inList = false; - const closeList = () => { if (inList) { out.push(''); inList = false; } }; + const closeList = () => { + if (inList) { + out.push(''); + inList = false; + } + }; for (const raw of md.split('\n')) { const line = raw.trimEnd(); - if (/^### /.test(line)) { closeList(); out.push(`

${inline(line.slice(4))}

`); } - else if (/^## /.test(line)) { closeList(); out.push(`

${inline(line.slice(3))}

`); } - else if (/^# /.test(line)) { closeList(); out.push(`

${inline(line.slice(2))}

`); } - else if (/^> /.test(line)) { closeList(); out.push(`
${inline(line.slice(2))}
`); } - else if (/^- /.test(line)) { if (!inList) { out.push('
    '); inList = true; } out.push(`
  • ${inline(line.slice(2))}
  • `); } - else if (line === '---') { closeList(); out.push('
    '); } - else if (line === '') { closeList(); } - else { closeList(); out.push(`

    ${inline(line)}

    `); } + if (/^### /.test(line)) { + closeList(); + out.push(`

    ${inline(line.slice(4))}

    `); + } else if (/^## /.test(line)) { + closeList(); + out.push(`

    ${inline(line.slice(3))}

    `); + } else if (/^# /.test(line)) { + closeList(); + out.push(`

    ${inline(line.slice(2))}

    `); + } else if (/^> /.test(line)) { + closeList(); + out.push(`
    ${inline(line.slice(2))}
    `); + } else if (/^- /.test(line)) { + if (!inList) { + out.push('
      '); + inList = true; + } + out.push(`
    • ${inline(line.slice(2))}
    • `); + } else if (line === '---') { + closeList(); + out.push('
      '); + } else if (line === '') { + closeList(); + } else { + closeList(); + out.push(`

      ${inline(line)}

      `); + } } closeList(); return out.join('\n'); @@ -92,7 +148,12 @@ export function toHtml(d) { } export function slugify(s) { - return String(s || 'repo').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'repo'; + return ( + String(s || 'repo') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'repo' + ); } /** @@ -132,7 +193,8 @@ export function toScaffold(d, decision) { // Fit verdict + decision const score = d.health?.score ?? 0; - const fitLabel = score >= 85 ? 'Strong Fit' : score >= 70 ? 'Solid Fit' : score > 0 ? 'Needs Care' : 'Unrated'; + const fitLabel = + score >= 85 ? 'Strong Fit' : score >= 70 ? 'Solid Fit' : score > 0 ? 'Needs Care' : 'Unrated'; push(`**Fit:** ${fitLabel}`); if (decision?.decision) { const LABELS = { adopt: 'Adopt', trial: 'Trial', hold: 'Hold', reject: 'Reject' }; @@ -145,7 +207,7 @@ export function toScaffold(d, decision) { const caps = Array.isArray(d.capabilities) ? d.capabilities : []; if (caps.length) { push('## Capabilities', ''); - caps.forEach(c => push(`- ${c}`)); + caps.forEach((c) => push(`- ${c}`)); push(''); } @@ -153,7 +215,7 @@ export function toScaffold(d, decision) { push('## Integration Checklist', ''); push('- [ ] Read the README and quickstart guide'); if (d.start_here?.length) { - d.start_here.forEach(e => push(`- [ ] ${e.title}${e.desc ? ` — ${e.desc}` : ''}`)); + d.start_here.forEach((e) => push(`- [ ] ${e.title}${e.desc ? ` — ${e.desc}` : ''}`)); } else { push(`- [ ] Install \`${repoName}\``); push('- [ ] Run the examples / tests locally'); @@ -162,17 +224,17 @@ export function toScaffold(d, decision) { push('- [ ] Add to project dependencies', ''); // Key caveats from red flags - const warns = (d.red_flags || []).filter(f => f && f.severity !== 'ok'); + const warns = (d.red_flags || []).filter((f) => f && f.severity !== 'ok'); if (warns.length) { push('## Caveats', ''); - warns.forEach(f => push(`- ⚠️ **${f.title}** — ${f.text}`)); + warns.forEach((f) => push(`- ⚠️ **${f.title}** — ${f.text}`)); push(''); } // Alternatives considered if (d.alternatives?.length) { push('## Alternatives Considered', ''); - d.alternatives.forEach(a => push(`- **${a.name}** — ${a.when}`)); + d.alternatives.forEach((a) => push(`- **${a.name}** — ${a.when}`)); push(''); } diff --git a/fetcher.js b/fetcher.js index 78be1d5..d24577d 100644 --- a/fetcher.js +++ b/fetcher.js @@ -13,8 +13,8 @@ function ghHeaders(opts) { export async function fetchRepoData(platform, repoId, opts = {}) { if (platform === 'github') return fetchGitHub(repoId, opts); if (platform === 'gitlab') return fetchGitLab(repoId); - if (platform === 'npm') return fetchNpm(repoId); - if (platform === 'pypi') return fetchPyPI(repoId); + if (platform === 'npm') return fetchNpm(repoId); + if (platform === 'pypi') return fetchPyPI(repoId); throw new Error(`Unsupported platform: ${platform}`); } @@ -46,13 +46,21 @@ async function fetchGitHub(repoId, opts = {}) { let languages = []; try { if (langRes?.ok) languages = bytesToComposition(await langRes.json()); - } catch { /* leave empty; bar falls back to single language */ } + } catch { + /* leave empty; bar falls back to single language */ + } if (!languages.length && meta.language) languages = [{ name: meta.language, pct: 100 }]; return { - platform: 'github', repoId, description: meta.description || '', - language: meta.language || 'Unknown', license: meta.license?.spdx_id || 'Unknown', - stars: meta.stargazers_count || 0, readme, languages, dependencies: [], + platform: 'github', + repoId, + description: meta.description || '', + language: meta.language || 'Unknown', + license: meta.license?.spdx_id || 'Unknown', + stars: meta.stargazers_count || 0, + readme, + languages, + dependencies: [], }; } @@ -60,7 +68,9 @@ async function fetchGitLab(repoId) { const encoded = encodeURIComponent(repoId); const [meta, readmeRes, langRes] = await Promise.all([ fetchJson(`https://gitlab.com/api/v4/projects/${encoded}`), - fetch(`https://gitlab.com/api/v4/projects/${encoded}/repository/files/README.md/raw?ref=HEAD`).catch(() => null), + fetch(`https://gitlab.com/api/v4/projects/${encoded}/repository/files/README.md/raw?ref=HEAD`).catch( + () => null + ), fetch(`https://gitlab.com/api/v4/projects/${encoded}/languages`).catch(() => ({ ok: false })), ]); let readme = ''; @@ -69,16 +79,26 @@ async function fetchGitLab(repoId) { try { if (langRes?.ok) { const langs = await langRes.json(); - languages = Object.entries(langs).sort((a, b) => b[1] - a[1]).slice(0, 5) + languages = Object.entries(langs) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) .map(([name, pct]) => ({ name, pct: Math.round(pct) })); } - } catch { /* best effort */ } + } catch { + /* best effort */ + } if (!languages.length && meta.language) languages = [{ name: meta.language, pct: 100 }]; return { - platform: 'gitlab', repoId, description: meta.description || '', - language: meta.language || 'Unknown', license: 'Unknown', - stars: meta.star_count || 0, readme, languages, dependencies: [], + platform: 'gitlab', + repoId, + description: meta.description || '', + language: meta.language || 'Unknown', + license: 'Unknown', + stars: meta.star_count || 0, + readme, + languages, + dependencies: [], }; } @@ -86,12 +106,19 @@ async function fetchNpm(repoId) { const data = await fetchJson(`https://registry.npmjs.org/${repoId}`); const latest = data['dist-tags']?.latest; const deps = data.versions?.[latest]?.dependencies || {}; - const dependencies = Object.entries(deps).slice(0, 30).map(([name, version]) => ({ name, version: String(version) })); + const dependencies = Object.entries(deps) + .slice(0, 30) + .map(([name, version]) => ({ name, version: String(version) })); return { - platform: 'npm', repoId, description: data.description || '', language: 'JavaScript', - license: data.versions?.[latest]?.license || 'Unknown', stars: 0, + platform: 'npm', + repoId, + description: data.description || '', + language: 'JavaScript', + license: data.versions?.[latest]?.license || 'Unknown', + stars: 0, readme: (data.readme || '').slice(0, 8000), - languages: [{ name: 'JavaScript', pct: 100 }], dependencies, + languages: [{ name: 'JavaScript', pct: 100 }], + dependencies, }; } @@ -114,13 +141,17 @@ export async function fetchMaintenanceSignals(platform, repoId) { try { const [meta, contribRes] = await Promise.all([ fetchJson(`https://api.github.com/repos/${repoId}`), - fetch(`https://api.github.com/repos/${repoId}/contributors?per_page=5&anon=0`).catch(() => ({ ok: false })), + fetch(`https://api.github.com/repos/${repoId}/contributors?per_page=5&anon=0`).catch(() => ({ + ok: false, + })), ]); let topContributors = []; if (contribRes.ok) { const data = await contribRes.json().catch(() => []); if (Array.isArray(data)) { - topContributors = data.slice(0, 5).map(c => ({ login: String(c.login || ''), contributions: Number(c.contributions) || 0 })); + topContributors = data + .slice(0, 5) + .map((c) => ({ login: String(c.login || ''), contributions: Number(c.contributions) || 0 })); } } return { @@ -141,9 +172,14 @@ async function fetchPyPI(repoId) { const info = data.info; const dependencies = (info.requires_dist || []).map(parsePyDep).filter(Boolean).slice(0, 30); return { - platform: 'pypi', repoId, description: info.summary || '', language: 'Python', - license: info.license || 'Unknown', stars: 0, + platform: 'pypi', + repoId, + description: info.summary || '', + language: 'Python', + license: info.license || 'Unknown', + stars: 0, readme: (info.description || '').slice(0, 8000), - languages: [{ name: 'Python', pct: 100 }], dependencies, + languages: [{ name: 'Python', pct: 100 }], + dependencies, }; } diff --git a/fits-stack.js b/fits-stack.js index bbf1ceb..b105e98 100644 --- a/fits-stack.js +++ b/fits-stack.js @@ -16,11 +16,16 @@ export function buildFitsStackPrompt(repoData, nearestRepos) { repoData.language ? `Language: ${repoData.language}` : '', repoData.category ? `Category: ${repoData.category}` : '', repoData.capabilities?.length ? `Capabilities: ${repoData.capabilities.join(', ')}` : '', - ].filter(Boolean).join('\n'); + ] + .filter(Boolean) + .join('\n'); - const libBlock = nearestRepos.map(r => - `- ${r.repoId}${r.eli5 ? ': ' + r.eli5.slice(0, 100) : ''}${r.capabilities?.length ? ' [' + r.capabilities.slice(0, 4).join(', ') + ']' : ''}` - ).join('\n'); + const libBlock = nearestRepos + .map( + (r) => + `- ${r.repoId}${r.eli5 ? ': ' + r.eli5.slice(0, 100) : ''}${r.capabilities?.length ? ' [' + r.capabilities.slice(0, 4).join(', ') + ']' : ''}` + ) + .join('\n'); return `You are a senior software architect helping a developer decide whether a new repo fits their existing tech stack. diff --git a/format.js b/format.js index 614af90..36a8740 100644 --- a/format.js +++ b/format.js @@ -16,8 +16,12 @@ export function esc(str) { * a paragraph become
      . */ export function paras(text, cls) { - const blocks = String(text ?? '').trim().split(/\n{2,}/).map(b => b.trim()).filter(Boolean); - return blocks.map(b => `

      ${esc(b).replace(/\n/g, '
      ')}

      `).join(''); + const blocks = String(text ?? '') + .trim() + .split(/\n{2,}/) + .map((b) => b.trim()) + .filter(Boolean); + return blocks.map((b) => `

      ${esc(b).replace(/\n/g, '
      ')}

      `).join(''); } /** Compact, accurate star count: 850 → "850", 1234 → "1.2k", 15000 → "15k", 1.2M. */ diff --git a/graph.js b/graph.js index a3c1180..494cf8c 100644 --- a/graph.js +++ b/graph.js @@ -5,7 +5,10 @@ import { hashRepoId } from './store.js'; import { escapeHtml as esc } from './safe-html.js'; -const truncate = (s, n) => { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; +const truncate = (s, n) => { + s = String(s); + return s.length > n ? s.slice(0, n - 1) + '…' : s; +}; /** * Numeric node id for a name or canonical repoId. A canonical "owner/repo" @@ -24,7 +27,7 @@ export function edgeIdFor(source, label, target) { const key = `${source}|${label}|${target}`; let hash = 5381; for (let i = 0; i < key.length; i++) { - hash = ((hash << 5) + hash) + key.charCodeAt(i); + hash = (hash << 5) + hash + key.charCodeAt(i); hash = hash & hash; } return Math.abs(hash) || 1; @@ -35,13 +38,15 @@ export function ideaIdFor(sources) { const key = (sources || []).map(String).slice().sort().join('+'); let hash = 5381; for (let i = 0; i < key.length; i++) { - hash = ((hash << 5) + hash) + key.charCodeAt(i); + hash = (hash << 5) + hash + key.charCodeAt(i); hash = hash & hash; } return Math.abs(hash) || 1; } -const CX = 200, CY = 150, RADIUS = 110; +const CX = 200, + CY = 150, + RADIUS = 110; /** * Radial layout: center at (CX,CY) ring 0; neighbors evenly spaced on a circle @@ -53,7 +58,7 @@ export function egoLayout(centerId, neighbors) { const out = [{ id: String(centerId), x: CX, y: CY, ring: 0 }]; const n = list.length; list.forEach((nb, i) => { - const angle = -Math.PI / 2 + (i * 2 * Math.PI / n); + const angle = -Math.PI / 2 + (i * 2 * Math.PI) / n; out.push({ id: String(nb.id), x: +(CX + RADIUS * Math.cos(angle)).toFixed(1), @@ -78,25 +83,33 @@ const EDGE_CLASS = { */ export function egoGraphSvg(center, neighbors, edges) { if (!center || !neighbors?.length) return ''; - const pos = Object.fromEntries(egoLayout(center.id, neighbors).map(p => [p.id, p])); + const pos = Object.fromEntries(egoLayout(center.id, neighbors).map((p) => [p.id, p])); const c = pos[String(center.id)]; - const lines = (edges || []).map((e) => { - const a = pos[String(e.source)], b = pos[String(e.target)]; - if (!a || !b) return ''; - const cls = EDGE_CLASS[e.label] || 'cn-alt'; - return ``; - }).join(''); + const lines = (edges || []) + .map((e) => { + const a = pos[String(e.source)], + b = pos[String(e.target)]; + if (!a || !b) return ''; + const cls = EDGE_CLASS[e.label] || 'cn-alt'; + return ``; + }) + .join(''); - const ring = neighbors.map((nb) => { - const p = pos[String(nb.id)]; - const cls = nb.kind === 'idea' ? 'cn-idea' : (nb.analyzed ? 'cn-analyzed' : 'cn-stub'); - return `` + - `` + - `${esc(truncate(nb.name, 12))}`; - }).join(''); + const ring = neighbors + .map((nb) => { + const p = pos[String(nb.id)]; + const cls = nb.kind === 'idea' ? 'cn-idea' : nb.analyzed ? 'cn-analyzed' : 'cn-stub'; + return ( + `` + + `` + + `${esc(truncate(nb.name, 12))}` + ); + }) + .join(''); - const centerNode = `` + + const centerNode = + `` + `` + `${esc(truncate(center.name, 14))}`; diff --git a/heuristics.js b/heuristics.js index 6ad1c3c..420e033 100644 --- a/heuristics.js +++ b/heuristics.js @@ -5,12 +5,12 @@ import { extractJsonObject } from './deepdive.js'; export const HEURISTICS_FRAMEWORKS = [ - { key: 'pareto', label: 'Pareto (80/20)', blurb: 'The 20% causing 80% of the friction.' }, - { key: 'eisenhower', label: 'Eisenhower Matrix', blurb: 'Urgent × Important — do, plan, delegate, drop.' }, + { key: 'pareto', label: 'Pareto (80/20)', blurb: 'The 20% causing 80% of the friction.' }, + { key: 'eisenhower', label: 'Eisenhower Matrix', blurb: 'Urgent × Important — do, plan, delegate, drop.' }, ]; export function isHeuristicFramework(key) { - return HEURISTICS_FRAMEWORKS.some(f => f.key === key); + return HEURISTICS_FRAMEWORKS.some((f) => f.key === key); } function sourceContext(repoData, source) { @@ -18,7 +18,7 @@ function sourceContext(repoData, source) { ? `File tree (truncated):\n${source.tree.join('\n')}` : '(no file tree — work from the README + description)'; const files = source?.files?.length - ? source.files.map(f => `=== ${f.path} ===\n${f.content}`).join('\n\n') + ? source.files.map((f) => `=== ${f.path} ===\n${f.content}`).join('\n\n') : '(no source files available)'; return `Repository: ${repoData.repoId} Description: ${repoData.description || '—'} @@ -64,7 +64,11 @@ const arr = (v) => (Array.isArray(v) ? v : []); const FRAMEWORK_PARSERS = { pareto(d) { return { - vital_few: arr(d.vital_few).map(v => ({ factor: v.factor || '', impact: v.impact || '', share: v.share || '' })), + vital_few: arr(d.vital_few).map((v) => ({ + factor: v.factor || '', + impact: v.impact || '', + share: v.share || '', + })), trivial_many: d.trivial_many || '', }; }, diff --git a/icon-anim.js b/icon-anim.js index afe5a3d..8ecdd76 100644 --- a/icon-anim.js +++ b/icon-anim.js @@ -16,10 +16,15 @@ import { drawVeeIcon } from './icon-draw.js'; /** Sizes Chrome needs for the action icon imageData map. */ export const ANIM_SIZES = [16, 32, 48]; -const TICK_MS = 90; // frame interval (worker-friendly) -const GROW_MS = 600; // aperture grow-in duration -const MAX_RUN_MS = 90_000; // safety cap: never animate longer than this -const STATIC_PATH = { 16: 'icons/icon16.png', 32: 'icons/icon32.png', 48: 'icons/icon48.png', 128: 'icons/icon128.png' }; +const TICK_MS = 90; // frame interval (worker-friendly) +const GROW_MS = 600; // aperture grow-in duration +const MAX_RUN_MS = 90_000; // safety cap: never animate longer than this +const STATIC_PATH = { + 16: 'icons/icon16.png', + 32: 'icons/icon32.png', + 48: 'icons/icon48.png', + 128: 'icons/icon128.png', +}; export const RING_GREY = '#cbd5e1'; export const RING_BLUE = '#3b82f6'; @@ -46,20 +51,20 @@ export function scanFrameParams(elapsedMs) { // Aperture grow-in with a slight overshoot, then settle at 1.0. let apertureScale; if (t < GROW_MS) { - const p = t / GROW_MS; // 0 → 1 - const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic + const p = t / GROW_MS; // 0 → 1 + const eased = 1 - Math.pow(1 - p, 3); // easeOutCubic apertureScale = 0.5 + (1.1 - 0.5) * eased; // 0.5 → 1.1 (overshoot) } else { const settle = clamp((t - GROW_MS) / 200, 0, 1); - apertureScale = 1.1 - 0.1 * settle; // 1.1 → 1.0 + apertureScale = 1.1 - 0.1 * settle; // 1.1 → 1.0 } // Spin accelerates: angle grows with the square of time-after-grow. const spinT = Math.max(0, t - GROW_MS) / 1000; // seconds spinning - const apertureRotation = 0.6 * spinT * spinT; // rad; quadratic = slow → fast + const apertureRotation = 0.6 * spinT * spinT; // rad; quadratic = slow → fast // Ring breathe: gentle sinusoid for both scale and grey→blue blend. - const phase = (t % 2400) / 2400; // 2.4s loop + const phase = (t % 2400) / 2400; // 2.4s loop const wave = (1 - Math.cos(phase * Math.PI * 2)) / 2; // 0 → 1 → 0 const ringScale = 1.0 + 0.08 * wave; const ringColor = mixHex(RING_GREY, RING_BLUE, wave); @@ -74,7 +79,7 @@ const timers = new Map(); // tabId → { id, started } async function shouldAnimate() { try { const { animateIcon, reduceMotion } = await chrome.storage.local.get(['animateIcon', 'reduceMotion']); - if (animateIcon === false) return false; // default ON + if (animateIcon === false) return false; // default ON if (reduceMotion === true) return false; return true; } catch { @@ -101,19 +106,28 @@ function renderImageData(elapsedMs) { */ export async function startScanAnim(tabId) { if (typeof tabId !== 'number') return; - if (timers.has(tabId)) return; // already animating this tab + if (timers.has(tabId)) return; // already animating this tab if (!(await shouldAnimate())) return; const started = Date.now(); const tick = () => { const elapsed = Date.now() - started; - if (elapsed > MAX_RUN_MS) { stopScanAnim(tabId); return; } + if (elapsed > MAX_RUN_MS) { + stopScanAnim(tabId); + return; + } try { chrome.action.setIcon({ tabId, imageData: renderImageData(elapsed) }).catch(() => {}); - } catch { /* tab gone / OffscreenCanvas unavailable — stop quietly */ stopScanAnim(tabId); return; } + } catch { + /* tab gone / OffscreenCanvas unavailable — stop quietly */ stopScanAnim(tabId); + return; + } const id = setTimeout(tick, TICK_MS); const entry = timers.get(tabId); - if (entry) entry.id = id; else { clearTimeout(id); } + if (entry) entry.id = id; + else { + clearTimeout(id); + } }; timers.set(tabId, { id: 0, started }); @@ -128,6 +142,13 @@ export async function startScanAnim(tabId) { export function stopScanAnim(tabId) { if (typeof tabId !== 'number') return; const entry = timers.get(tabId); - if (entry) { clearTimeout(entry.id); timers.delete(tabId); } - try { chrome.action.setIcon({ tabId, path: STATIC_PATH }).catch(() => {}); } catch { /* tab gone */ } + if (entry) { + clearTimeout(entry.id); + timers.delete(tabId); + } + try { + chrome.action.setIcon({ tabId, path: STATIC_PATH }).catch(() => {}); + } catch { + /* tab gone */ + } } diff --git a/icon-draw.js b/icon-draw.js index 6d364e7..540f61c 100644 --- a/icon-draw.js +++ b/icon-draw.js @@ -13,10 +13,10 @@ export const BASE_GRID = 48; /** Mono Ink icon palette. Light marks on a near-black tile. */ export const ICON_COLORS = Object.freeze({ - tile: '#0f1115', // --rl-ink - ring: '#cbd5e1', // light barrel ring + tile: '#0f1115', // --rl-ink + ring: '#cbd5e1', // light barrel ring aperture: '#3b82f6', // electric-blue aperture - pupil: '#e5edff', // --rl-on-dark light pupil + pupil: '#e5edff', // --rl-on-dark light pupil }); /** diff --git a/ideate.js b/ideate.js index d735907..bce62ed 100644 --- a/ideate.js +++ b/ideate.js @@ -6,14 +6,18 @@ import { extractJsonObject } from './deepdive.js'; export const IDEATE_FRAMEWORKS = [ - { key: 'triz', label: 'TRIZ', blurb: 'Resolve a contradiction with inventive principles.' }, - { key: 'scamper', label: 'SCAMPER', blurb: 'Substitute · Combine · Adapt · Modify · Put · Eliminate · Reverse.' }, + { key: 'triz', label: 'TRIZ', blurb: 'Resolve a contradiction with inventive principles.' }, + { + key: 'scamper', + label: 'SCAMPER', + blurb: 'Substitute · Combine · Adapt · Modify · Put · Eliminate · Reverse.', + }, { key: 'lateral', label: 'Lateral Thinking', blurb: 'A random provocation → a radical angle.' }, - { key: 'morph', label: 'Morphological', blurb: 'Cross every variable to find novel combos.' }, + { key: 'morph', label: 'Morphological', blurb: 'Cross every variable to find novel combos.' }, ]; export function isIdeateFramework(key) { - return IDEATE_FRAMEWORKS.some(f => f.key === key); + return IDEATE_FRAMEWORKS.some((f) => f.key === key); } function sourceContext(repoData, source) { @@ -21,7 +25,7 @@ function sourceContext(repoData, source) { ? `File tree (truncated):\n${source.tree.join('\n')}` : '(no file tree — work from the README + description)'; const files = source?.files?.length - ? source.files.map(f => `=== ${f.path} ===\n${f.content}`).join('\n\n') + ? source.files.map((f) => `=== ${f.path} ===\n${f.content}`).join('\n\n') : '(no source files available)'; return `Repository: ${repoData.repoId} Description: ${repoData.description || '—'} @@ -97,7 +101,7 @@ const FRAMEWORK_PARSERS = { const c = obj(d.contradiction); return { contradiction: { improving: c.improving || '', worsening: c.worsening || '' }, - principles: arr(d.principles).map(p => ({ + principles: arr(d.principles).map((p) => ({ number: p.number ?? '', name: p.name || '', application: p.application || '', @@ -106,15 +110,21 @@ const FRAMEWORK_PARSERS = { }; }, scamper(d) { - return { items: arr(d.items).map(i => ({ lens: i.lens || '', idea: i.idea || '' })) }; + return { items: arr(d.items).map((i) => ({ lens: i.lens || '', idea: i.idea || '' })) }; }, lateral(d) { return { provocation: d.provocation || '', leap: d.leap || '', ideas: arr(d.ideas).map(String) }; }, morph(d) { return { - dimensions: arr(d.dimensions).map(dim => ({ axis: dim.axis || '', options: arr(dim.options).map(String) })), - combinations: arr(d.combinations).map(c => ({ picks: arr(c.picks).map(String), concept: c.concept || '' })), + dimensions: arr(d.dimensions).map((dim) => ({ + axis: dim.axis || '', + options: arr(dim.options).map(String), + })), + combinations: arr(d.combinations).map((c) => ({ + picks: arr(c.picks).map(String), + concept: c.concept || '', + })), }; }, }; diff --git a/layouts.js b/layouts.js index 75101dd..6d62be6 100644 --- a/layouts.js +++ b/layouts.js @@ -6,45 +6,57 @@ import { esc } from './format.js'; // spine(items): items = [{ marker, label, body, kind? }] — a connected vertical sequence. export function spine(items) { - const rows = (items || []).filter(i => i && (i.label || i.body || i.marker)); + const rows = (items || []).filter((i) => i && (i.label || i.body || i.marker)); if (!rows.length) return ''; - return `
      ${rows.map(i => ` + return `
      ${rows + .map( + (i) => `
      ${esc(i.marker ?? '')}
      ${i.label ? `
      ${esc(i.label)}
      ` : ''} ${i.body ? `
      ${esc(i.body)}
      ` : ''}
      -
      `).join('')}
      `; +
      ` + ) + .join('')}
`; } // flow(nodes): nodes = [{ label, body, kind?, note? }] — node → node → node with arrows. export function flow(nodes) { - const ns = (nodes || []).filter(n => n && (n.label || n.body)); + const ns = (nodes || []).filter((n) => n && (n.label || n.body)); if (!ns.length) return ''; - return `
${ns.map((n, idx) => ` + return `
${ns + .map( + (n, idx) => ` ${idx ? '
' : ''}
${n.label ? `
${esc(n.label)}
` : ''} ${n.body ? `
${esc(n.body)}
` : ''} ${n.note ? `
${esc(n.note)}
` : ''} -
`).join('')}
`; +
` + ) + .join('')}
`; } // ranked(rows): rows = [{ label, weight (0..100), body }] — scored rows with bars. export function ranked(rows) { - const rs = (rows || []).filter(r => r && (r.label || r.body)); + const rs = (rows || []).filter((r) => r && (r.label || r.body)); if (!rs.length) return ''; - return `
${rs.map(r => { - const w = Math.max(0, Math.min(100, Number(r.weight) || 0)); - const wlabel = (r.weight !== undefined && r.weight !== null && r.weight !== '') - ? `${esc(String(r.weight))}` : ''; - return `
+ return `
${rs + .map((r) => { + const w = Math.max(0, Math.min(100, Number(r.weight) || 0)); + const wlabel = + r.weight !== undefined && r.weight !== null && r.weight !== '' + ? `${esc(String(r.weight))}` + : ''; + return `
${esc(r.label ?? '')}${wlabel}
${r.body ? `
${esc(r.body)}
` : ''}
`; - }).join('')}
`; + }) + .join('')}
`; } // matrix2x2(spec): spec = { axes?: {x,y}, cells: [{label,sub?,items?}]×4 } — a true 2×2 grid. @@ -55,7 +67,7 @@ export function matrix2x2(spec) { const cell = (c) => `
${esc(c.label ?? '')}
${c.sub ? `
${esc(c.sub)}
` : ''} - ${(c.items && c.items.length) ? `
    ${c.items.map(x => `
  • ${esc(x)}
  • `).join('')}
` : ''} + ${c.items && c.items.length ? `
    ${c.items.map((x) => `
  • ${esc(x)}
  • `).join('')}
` : ''}
`; return `
${ax.x ? `
${esc(ax.x)} →
` : ''} @@ -65,17 +77,24 @@ export function matrix2x2(spec) { // optionMatrix(axes, combos): axes = [{axis, options[]}], combos = [{picks[], concept}]. export function optionMatrix(axes, combos) { - const ax = (axes || []).filter(a => a && a.axis); + const ax = (axes || []).filter((a) => a && a.axis); if (!ax.length) return ''; - const axRows = ax.map(a => `
+ const axRows = ax + .map( + (a) => `
${esc(a.axis)}
-
${(a.options || []).map(o => `${esc(o)}`).join('')}
-
`).join(''); +
${(a.options || []).map((o) => `${esc(o)}`).join('')}
+
` + ) + .join(''); const comboCards = (combos || []) - .filter(c => c && (c.concept || (c.picks || []).length)) - .map(c => `
+ .filter((c) => c && (c.concept || (c.picks || []).length)) + .map( + (c) => `
${(c.picks || []).map(esc).join(' + ')}
${esc(c.concept ?? '')}
-
`).join(''); +
` + ) + .join(''); return `
${axRows}
${comboCards ? `
${comboCards}
` : ''}`; } diff --git a/lens-guide.js b/lens-guide.js index d579694..1f2ec4f 100644 --- a/lens-guide.js +++ b/lens-guide.js @@ -6,14 +6,16 @@ export const LENS_GUIDE = { // ─ Ideate (creative) ─ triz: { - howToUse: 'Reach for it when two goals fight (speed vs richness). Name the contradiction, then use the principles as a menu of escapes, not a verdict.', + howToUse: + 'Reach for it when two goals fight (speed vs richness). Name the contradiction, then use the principles as a menu of escapes, not a verdict.', misconceptions: [ "It's not a ranking of options — it resolves a trade-off without compromising either side.", 'The principles are prompts to adapt, not patterns to copy literally.', ], }, scamper: { - howToUse: 'Each letter forces a specific transformation — read them as prompts, not finished answers. Take the 1–2 that spark something and push them further yourself.', + howToUse: + 'Each letter forces a specific transformation — read them as prompts, not finished answers. Take the 1–2 that spark something and push them further yourself.', misconceptions: [ "It's not free-form brainstorming — each lens is a constraint that forces a different angle.", '"Put to another use" ≠ "Modify" — keep them distinct or you get duplicate ideas.', @@ -21,14 +23,16 @@ export const LENS_GUIDE = { ], }, lateral: { - howToUse: 'Use it when straight logic keeps landing on the obvious. The random provocation is bait — judge the leap it triggers, not the provocation itself.', + howToUse: + 'Use it when straight logic keeps landing on the obvious. The random provocation is bait — judge the leap it triggers, not the provocation itself.', misconceptions: [ 'A weird provocation is not the idea — the value is the bridge from it back to your project.', "If nothing leaps, that's fine; lateral thinking misses more than it hits, by design.", ], }, morph: { - howToUse: 'Use it to escape one-dimensional thinking. Read the axes, then chase the combinations no one would naturally pick.', + howToUse: + 'Use it to escape one-dimensional thinking. Read the axes, then chase the combinations no one would naturally pick.', misconceptions: [ 'The value is in the off-diagonal combos, not the obvious one-per-axis defaults.', 'More axes is not better — 2–4 sharp variables beat ten fuzzy ones.', @@ -36,28 +40,32 @@ export const LENS_GUIDE = { }, // ─ Systems ─ toc: { - howToUse: 'Use it to stop optimizing things that do not matter. Fix only the named bottleneck; everything upstream of it is wasted effort until it moves.', + howToUse: + 'Use it to stop optimizing things that do not matter. Fix only the named bottleneck; everything upstream of it is wasted effort until it moves.', misconceptions: [ 'There is only ever one binding constraint at a time — improving anything else is noise.', 'When you relieve it, the constraint moves; the report names where, so expect a new bottleneck, not "done".', ], }, loops: { - howToUse: 'Use it to see why the system accelerates or stalls. Trace each cycle back to its start; reinforcing loops compound, balancing loops resist.', + howToUse: + 'Use it to see why the system accelerates or stalls. Trace each cycle back to its start; reinforcing loops compound, balancing loops resist.', misconceptions: [ "A loop is a cycle that returns to itself — a one-way chain of effects isn't a loop.", 'Reinforcing is not "good" and balancing is not "bad" — runaway reinforcement also means collapse.', ], }, pdca: { - howToUse: "Use it to judge whether the project actually learns. Look for a real Check step — that's the one teams skip.", + howToUse: + "Use it to judge whether the project actually learns. Look for a real Check step — that's the one teams skip.", misconceptions: [ "It's a loop, not a launch checklist — without Act feeding the next Plan it's just waterfall.", 'Shipping (Do) is the easy phase; the value is in Check and Act.', ], }, dmaic: { - howToUse: 'Use it when the problem is variance/defects, not features. Insist on a measurable baseline before any improvement.', + howToUse: + 'Use it when the problem is variance/defects, not features. Insist on a measurable baseline before any improvement.', misconceptions: [ "Without Measure it's just opinion — a number before and after is the whole point.", 'Control is not optional — un-held gains regress.', @@ -65,14 +73,16 @@ export const LENS_GUIDE = { }, // ─ Prioritize ─ pareto: { - howToUse: 'Use it to find the few factors worth your time. Act on the vital few; consciously defer the long tail.', + howToUse: + 'Use it to find the few factors worth your time. Act on the vital few; consciously defer the long tail.', misconceptions: [ "80/20 is a heuristic, not a law — don't treat the exact split as measured.", 'The trivial many are deferred, not deleted — some become vital later.', ], }, eisenhower: { - howToUse: 'Sort honestly by urgency vs importance, then act on the quadrant, not the task: Do, Schedule, Delegate/automate, or Drop.', + howToUse: + 'Sort honestly by urgency vs importance, then act on the quadrant, not the task: Do, Schedule, Delegate/automate, or Drop.', misconceptions: [ 'Urgent ≠ important — most urgent work lands in Delegate or Eliminate.', 'The high-value work (architecture, hardening) is almost never urgent — it lives in Schedule and gets skipped.', @@ -80,17 +90,19 @@ export const LENS_GUIDE = { }, // ─ single-shot lenses ─ deepdive: { - howToUse: "Run it when you need to actually understand the code, not just decide on it. Read the atoms first, then how they depend on each other.", + howToUse: + 'Run it when you need to actually understand the code, not just decide on it. Read the atoms first, then how they depend on each other.', misconceptions: [ - "It reads real source on GitHub; elsewhere it falls back to the README, so depth varies.", + 'It reads real source on GitHub; elsewhere it falls back to the README, so depth varies.', "The Feynman gaps are the point — they flag what even the analysis isn't sure of.", ], }, sktpg: { - howToUse: "Use it to judge trajectory, not present state — where this is heading in 6–18 months and what to do before consensus.", + howToUse: + 'Use it to judge trajectory, not present state — where this is heading in 6–18 months and what to do before consensus.', misconceptions: [ "It's a directional bet, not a forecast — the band is confidence, not a guarantee.", - "Weak signals are weak on purpose; treat them as hypotheses to track, not facts.", + 'Weak signals are weak on purpose; treat them as hypotheses to track, not facts.', ], }, }; diff --git a/library-data.js b/library-data.js index 009fada..76f1a4f 100644 --- a/library-data.js +++ b/library-data.js @@ -16,10 +16,16 @@ export function libraryRow(payload) { (p.pros && p.pros.length) || (p.cons && p.cons.length) ); - const fit = hasTriage ? deriveFit(p) : { level: 'unrated', label: 'Unrated', why: 'Re-scan for a fit verdict' }; + const fit = hasTriage + ? deriveFit(p) + : { level: 'unrated', label: 'Unrated', why: 'Re-scan for a fit verdict' }; const prevFitLevel = p.prevFitLevel ?? null; const fitDelta = - fit.level && prevFitLevel && fit.level !== prevFitLevel && fit.level !== 'unrated' && prevFitLevel !== 'unrated' + fit.level && + prevFitLevel && + fit.level !== prevFitLevel && + fit.level !== 'unrated' && + prevFitLevel !== 'unrated' ? { from: prevFitLevel, to: fit.level } : null; return { @@ -41,7 +47,11 @@ export function libraryRow(payload) { }; } -const MIN = 60_000, HOUR = 60 * MIN, DAY = 24 * HOUR, MONTH = 30 * DAY, YEAR = 365 * DAY; +const MIN = 60_000, + HOUR = 60 * MIN, + DAY = 24 * HOUR, + MONTH = 30 * DAY, + YEAR = 365 * DAY; /** * Compact "scanned N ago" label from an ISO timestamp. `now` is injectable for @@ -72,7 +82,9 @@ export function sortRows(rows, by) { return r.sort((a, b) => a.repoId.localeCompare(b.repoId)); } if (by === 'recent') { - return r.sort((a, b) => (Date.parse(b.savedAt) || 0) - (Date.parse(a.savedAt) || 0) || a.name.localeCompare(b.name)); + return r.sort( + (a, b) => (Date.parse(b.savedAt) || 0) - (Date.parse(a.savedAt) || 0) || a.name.localeCompare(b.name) + ); } if (by === 'stars') { return r.sort((a, b) => (b.stars || 0) - (a.stars || 0) || a.name.localeCompare(b.name)); @@ -139,12 +151,16 @@ const FIT_LEVELS = ['strong', 'solid', 'care', 'risky', 'unrated']; export function libraryStats(rows) { const list = rows || []; const byFit = { strong: 0, solid: 0, care: 0, risky: 0, unrated: 0 }; - let healthSum = 0, healthCount = 0; + let healthSum = 0, + healthCount = 0; for (const r of list) { const level = r.fit?.level; const key = FIT_LEVELS.includes(level) ? level : 'unrated'; byFit[key] += 1; - if (r.health > 0) { healthSum += r.health; healthCount += 1; } + if (r.health > 0) { + healthSum += r.health; + healthCount += 1; + } } return { total: list.length, diff --git a/library-filters.js b/library-filters.js index 7da73c3..f6851b2 100644 --- a/library-filters.js +++ b/library-filters.js @@ -30,7 +30,8 @@ export function applyFilters(allRows, state, ctx = {}) { // 'delta' sort: repos with a fitDelta float up; improved before regressed. if (state.sort === 'delta') { rows = [...rows].sort((a, b) => { - const ad = a.fitDelta, bd = b.fitDelta; + const ad = a.fitDelta, + bd = b.fitDelta; if (ad && !bd) return -1; if (!ad && bd) return 1; if (ad && bd) { @@ -71,7 +72,9 @@ export function applyFilters(allRows, state, ctx = {}) { // NL filter: restrict to the AI-ranked id list, preserving the AI order. if (nlFilter?.ids?.length) { const idOrder = new Map(nlFilter.ids.map((id, i) => [id, i])); - rows = rows.filter((r) => idOrder.has(r.repoId)).sort((a, b) => idOrder.get(a.repoId) - idOrder.get(b.repoId)); + rows = rows + .filter((r) => idOrder.has(r.repoId)) + .sort((a, b) => idOrder.get(a.repoId) - idOrder.get(b.repoId)); } else if (nlFilter && !nlFilter.ids?.length && !nlFilter.error) { rows = []; // AI ran but found nothing } diff --git a/library-preview.html b/library-preview.html new file mode 100644 index 0000000..5e297f7 --- /dev/null +++ b/library-preview.html @@ -0,0 +1,738 @@ + + + + + RepoLens Library — polish preview + + + + +
+
+
+
+ + + + + +
+
+

Library

+
Everything you've analyzed — your evaluations, compounding.
+
+
+
+ +
+ + +
+
+ + + + +
+
+ + + +
+ + + + + +
+ + + ⌘K +
+ + +
+ + + +
+ + +
+ + +
+ + +
+
+
Repos analyzed
+
24
+
+
+
Fit distribution
+
+ + +
+
+
+
Avg health
+
78
+
+
+
Going stale
+
3 repos
+
+
+ + +
+ +
+ JK navigate + open + N note + C compare + D decide + E evaluate + R re-scan + / search + ⌘K palette +
+ +
+ Polish preview — same theme colors, restructured UI + micro-interactions. (Sample data.) +
+
+ + + + diff --git a/library-scene.js b/library-scene.js index cf22e55..4a22dea 100644 --- a/library-scene.js +++ b/library-scene.js @@ -18,7 +18,10 @@ export function buildLibraryScene({ graph, repos = [], only = null }) { const rawNodes = (graph?.nodes || []).filter((n) => { const id = idOf(n); if (!id) return false; - if (keep && n.kind === 'idea') { const src = n.sources || []; return src.length > 0 && src.every((s) => keep.has(s)); } + if (keep && n.kind === 'idea') { + const src = n.sources || []; + return src.length > 0 && src.every((s) => keep.has(s)); + } if (keep) return keep.has(id); return true; }); @@ -31,12 +34,14 @@ export function buildLibraryScene({ graph, repos = [], only = null }) { label: n.kind === 'idea' ? String(n.title || 'idea') : String(n.name || id.split('/').pop() || id), kind: n.kind === 'idea' ? 'idea' : 'repo', layer: null, - x: 0, y: 0, pinned: false, + x: 0, + y: 0, + pinned: false, ref: { repoId: n.repoId || null, analyzed: !!n.analyzed, fit: m.fit || null, - health: (m.health && Number.isFinite(m.health.score)) ? m.health.score : null, + health: m.health && Number.isFinite(m.health.score) ? m.health.score : null, decision: m.decision || null, pitch: n.pitch || null, sources: n.sources || null, @@ -53,7 +58,14 @@ export function buildLibraryScene({ graph, repos = [], only = null }) { byNodeId.set(sid, sid); } const edges = (graph?.edges || []) - .map((e) => ({ id: String(e.id), from: byNodeId.get(String(e.source)), to: byNodeId.get(String(e.target)), rel: String(e.label || 'ALTERNATIVE_TO'), note: null, userDrawn: false })) + .map((e) => ({ + id: String(e.id), + from: byNodeId.get(String(e.source)), + to: byNodeId.get(String(e.target)), + rel: String(e.label || 'ALTERNATIVE_TO'), + note: null, + userDrawn: false, + })) .filter((e) => e.from && e.to); const scene = createScene({ scope: 'corkboard', repoId: null, title: 'Library' }); diff --git a/library.html b/library.html index 5b42f65..345e0aa 100644 --- a/library.html +++ b/library.html @@ -1,682 +1,2580 @@ - + - - -RepoLens — Library - - - - - - -
-
-

Library

-
- -
- - - +
+ + + +
+ + + + + + + + + + + + + + + + + + +
- - - - - - - - - - - - - - - - - - -
-
-

Every repo you've analyzed, at a glance — click a card to reopen its analysis, hover it to re-scan, open the source, or remove it.

- - - - - - - - - -
- - - - -
-
- - -
- - -
- - - - - -
- - - - - - + + + diff --git a/library.js b/library.js index 8907db8..a38d6cc 100644 --- a/library.js +++ b/library.js @@ -3,7 +3,26 @@ // show), and each card manages its repo: click to reopen the saved analysis, hover for // re-scan / source / remove actions. -import { scrollPoints, deleteRepo, deleteSnapshots, exportStores, importStores, clearLibrary, listCollections, saveCollection, deleteCollection, listDecisions, saveDecision, listAllSnapshots, getLibraryGraph, getScene, saveScene, saveRepo, deleteScene, getAllMastery } from './store.js'; +import { + scrollPoints, + deleteRepo, + deleteSnapshots, + exportStores, + importStores, + clearLibrary, + listCollections, + saveCollection, + deleteCollection, + listDecisions, + saveDecision, + listAllSnapshots, + getLibraryGraph, + getScene, + saveScene, + saveRepo, + deleteScene, + getAllMastery, +} from './store.js'; import { levelLabel, aggregateMastery } from './mastery.js'; import { introStageA, shouldOfferMilestone, milestoneSteps, COPY } from './onboarding.js'; import { startCoachmark } from './coachmark.js'; @@ -13,9 +32,29 @@ import { layoutCorkboard } from './canvas-layout.js'; import { mountCanvas } from './canvas-engine.js'; import { rankRepos } from './store/search.js'; import { DECISION_META } from './decision-log.js'; -import { makeCollection, validateCollectionName, addRepoToCollection, toggleRepoInCollection, collectionContains, sortedCollections, repoCollections, removeRepoFromCollection, nextColor, COLLECTION_COLORS } from './collections.js'; +import { + makeCollection, + validateCollectionName, + addRepoToCollection, + toggleRepoInCollection, + collectionContains, + sortedCollections, + repoCollections, + removeRepoFromCollection, + nextColor, + COLLECTION_COLORS, +} from './collections.js'; import { listCached, removeCached, openCachedAnalysis, importCache, clearCache } from './cache.js'; -import { libraryRow, sortRows, filterRows, allCapabilities, relativeTime, sourceUrl, mergeRows, libraryStats } from './library-data.js'; +import { + libraryRow, + sortRows, + filterRows, + allCapabilities, + relativeTime, + sourceUrl, + mergeRows, + libraryStats, +} from './library-data.js'; import { snapshotTrend, sparkline } from './snapshots.js'; import { buildBackup, validateBackup, summarizeBackup, backupFilename } from './backup.js'; import { detectPlatform } from './url-detector.js'; @@ -23,7 +62,15 @@ import { html, escapeHtml as esc } from './safe-html.js'; import { initTheme } from './theme.js'; import { veeSvg } from './mascot.js'; import { initPalette } from './palette.js'; -import { loadRubric, saveRubric, saveEval, clearEval, listEvals, computeScore, DEFAULT_RUBRIC } from './evaluations.js'; +import { + loadRubric, + saveRubric, + saveEval, + clearEval, + listEvals, + computeScore, + DEFAULT_RUBRIC, +} from './evaluations.js'; import { applyFilters } from './library-filters.js'; // Vendored animation libs (local ES modules — never CDN; the MV3 CSP forbids remote scripts). import confetti from './vendor/confetti.mjs'; @@ -33,7 +80,9 @@ import { CountUp } from './vendor/countup.mjs'; // Honour the user's chosen theme on this standalone page (sets ). initTheme(); // Mirror the OS reduced-motion preference into storage for the service worker. -chrome.storage.local.set({ reduceMotion: typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches }); +chrome.storage.local.set({ + reduceMotion: typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches, +}); // Respect the OS "reduce motion" setting — used to skip count-up / confetti / etc. const prefersReducedMotion = () => @@ -54,7 +103,9 @@ function celebrateAdopt(origin) { origin: origin || { x: 0.5, y: 0.35 }, disableForReducedMotion: true, }); - } catch { /* confetti is decorative — never let it break a decision save */ } + } catch { + /* confetti is decorative — never let it break a decision save */ + } } // Translate a DOM element's centre into confetti's normalized {x,y} origin. @@ -71,9 +122,24 @@ function originFromEl(el) { const MAX_BACKUP_BYTES = 50 * 1024 * 1024; // refuse absurd import files before parsing const LANG_COLORS = { - JavaScript: '#f1e05a', TypeScript: '#3178c6', Python: '#3572A5', Rust: '#dea584', Go: '#00ADD8', - Java: '#b07219', Ruby: '#701516', 'C++': '#f34b7d', C: '#555555', 'C#': '#178600', PHP: '#4F5D95', - Swift: '#F05138', Kotlin: '#A97BFF', Shell: '#89e051', HTML: '#e34c26', CSS: '#563d7c', Vue: '#41b883', Dart: '#00B4AB', + JavaScript: '#f1e05a', + TypeScript: '#3178c6', + Python: '#3572A5', + Rust: '#dea584', + Go: '#00ADD8', + Java: '#b07219', + Ruby: '#701516', + 'C++': '#f34b7d', + C: '#555555', + 'C#': '#178600', + PHP: '#4F5D95', + Swift: '#F05138', + Kotlin: '#A97BFF', + Shell: '#89e051', + HTML: '#e34c26', + CSS: '#563d7c', + Vue: '#41b883', + Dart: '#00B4AB', }; const langColor = (n) => LANG_COLORS[n] || '#64748b'; @@ -93,14 +159,25 @@ function hilite(text, q) { try { const re = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); return safe.replace(re, '$1'); - } catch { return safe; } + } catch { + return safe; + } } let allRows = []; let snapsByRepo = new Map(); // repoId → snaps[] (batch-loaded once in init) let cacheByRepo = new Map(); // repoId → full cached analysis (instant reopen) let decisionMap = new Map(); // repoId → decision payload -const state = { query: '', sort: 'fit', capability: '', collection: '', decision: '', lang: '', mastery: '', view: 'list' }; +const state = { + query: '', + sort: 'fit', + capability: '', + collection: '', + decision: '', + lang: '', + mastery: '', + view: 'list', +}; // Mastery records (repoId → { level, ... }), loaded once in init. Drives the // header aggregate line and the level filter. @@ -136,7 +213,7 @@ const compareSet = new Set(); let pinned = new Set(); // User notes: freeform text annotations keyed by repoId, persisted to local storage. -let notesMap = new Map(); +const notesMap = new Map(); const noteKey = (repoId) => `repolens_note_${repoId}`; // Saved filters: named snapshots of {query,sort,capability,collection,decision,lang} state. @@ -163,10 +240,15 @@ function card(r, i = 0) { const dots = r.languages .map((l) => ``) .join(''); - const tags = r.capabilities.slice(0, 4).map((c) => `${hilite(c, hq)}`).join(''); + const tags = r.capabilities + .slice(0, 4) + .map( + (c) => `${hilite(c, hq)}` + ) + .join(''); const when = relativeTime(r.savedAt); - const isToday = r.savedAt && (Date.now() - Date.parse(r.savedAt)) < 86_400_000; - const isStale = r.savedAt && (Date.now() - Date.parse(r.savedAt)) > 30 * 86_400_000; + const isToday = r.savedAt && Date.now() - Date.parse(r.savedAt) < 86_400_000; + const isStale = r.savedAt && Date.now() - Date.parse(r.savedAt) > 30 * 86_400_000; const sel = selected.has(r.repoId); const boards = repoCollections(collections, r.repoId); const boardDots = boards.length @@ -186,14 +268,16 @@ function card(r, i = 0) { })() : ''; const isPinned = pinned.has(r.repoId); - const platformBadge = r.platform && r.platform !== 'github' - ? `${r.platform === 'npm' ? 'npm' : r.platform === 'pypi' ? 'PyPI' : r.platform === 'gitlab' ? 'GL' : esc(r.platform)}` - : ''; + const platformBadge = + r.platform && r.platform !== 'github' + ? `${r.platform === 'npm' ? 'npm' : r.platform === 'pypi' ? 'PyPI' : r.platform === 'gitlab' ? 'GL' : esc(r.platform)}` + : ''; const evalEntry = evalMap.get(r.repoId); const evalScore = evalEntry ? computeScore(evalEntry, rubric) : null; - const evalBadge = evalScore !== null - ? `` - : ``; + const evalBadge = + evalScore !== null + ? `` + : ``; const M_GLYPH = { new: '○', explored: '◐', understood: '●' }; const mLevel = r.masteryLevel || 'new'; const masteryDot = `${M_GLYPH[mLevel]}`; @@ -391,7 +475,10 @@ function openQuickAsk(repoId, btn) { const panel = document.getElementById(`lc-qa-${safeId}`); if (!panel) return; const isOpen = !panel.classList.contains('hidden'); - if (isOpen) { panel.classList.add('hidden'); return; } + if (isOpen) { + panel.classList.add('hidden'); + return; + } panel.classList.remove('hidden'); const input = panel.querySelector('.lc-qa-input'); input?.focus(); @@ -407,7 +494,7 @@ function openQuickAsk(repoId, btn) { if (btn) btn.disabled = true; try { const resp = await chrome.runtime.sendMessage({ type: 'ASK_CACHED', question: q, analysis }); - if (answerEl) answerEl.textContent = resp?.ok ? resp.answer : (resp?.error || 'Something went wrong.'); + if (answerEl) answerEl.textContent = resp?.ok ? resp.answer : resp?.error || 'Something went wrong.'; } catch { if (answerEl) answerEl.textContent = 'Could not reach the extension.'; } finally { @@ -415,7 +502,12 @@ function openQuickAsk(repoId, btn) { } }; - input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.stopPropagation(); doAsk(); } }); + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + doAsk(); + } + }); // Clear the event after first use by removing and re-adding the panel hidden on next render } @@ -440,12 +532,20 @@ function showHoverPreview(repoId, cardEl) { const dec = decisionMap.get(repoId); const note = notesMap.get(repoId); const eli5 = full.eli5 ? `

${esc(full.eli5.slice(0, 200))}

` : ''; - const pros = Array.isArray(full.pros) && full.pros.length - ? `

Strengths

    ${full.pros.slice(0, 3).map((p) => `
  • ${esc(p)}
  • `).join('')}
` - : ''; - const cons = Array.isArray(full.cons) && full.cons.length - ? `

Weaknesses

    ${full.cons.slice(0, 2).map((c) => `
  • ${esc(c)}
  • `).join('')}
` - : ''; + const pros = + Array.isArray(full.pros) && full.pros.length + ? `

Strengths

    ${full.pros + .slice(0, 3) + .map((p) => `
  • ${esc(p)}
  • `) + .join('')}
` + : ''; + const cons = + Array.isArray(full.cons) && full.cons.length + ? `

Weaknesses

    ${full.cons + .slice(0, 2) + .map((c) => `
  • ${esc(c)}
  • `) + .join('')}
` + : ''; const decHtml = dec ? `

${esc(DECISION_META[dec.decision]?.label || dec.decision)}${dec.savedAt ? ` ${esc(relativeTime(dec.savedAt))}` : ''}

` : ''; @@ -455,7 +555,9 @@ function showHoverPreview(repoId, cardEl) { return `

${imp ? '↑' : '↓'} fit: ${esc(row.fitDelta.from)} → ${esc(row.fitDelta.to)}

`; })() : ''; - const noteHtml = note ? `

"${esc(note.slice(0, 100))}${note.length > 100 ? '…' : ''}"

` : ''; + const noteHtml = note + ? `

"${esc(note.slice(0, 100))}${note.length > 100 ? '…' : ''}"

` + : ''; panel.innerHTML = decHtml + deltaHtml + noteHtml + eli5 + pros + cons; @@ -490,29 +592,46 @@ async function copyCardMd(repoId, btn) { '', [ full?.health?.score ? `**Health:** ${full.health.score}/100` : '', - row?.stars >= 1 ? `**Stars:** ${row.stars >= 1000 ? (row.stars / 1000).toFixed(1) + 'k' : row.stars}` : '', + row?.stars >= 1 + ? `**Stars:** ${row.stars >= 1000 ? (row.stars / 1000).toFixed(1) + 'k' : row.stars}` + : '', row?.fit?.label ? `**Fit:** ${row.fit.label}` : '', dec ? `**Decision:** ${DECISION_META[dec.decision]?.label || dec.decision}` : '', - ].filter(Boolean).join(' · '), + ] + .filter(Boolean) + .join(' · '), full?.pros?.length ? `\n**Pros:** ${full.pros.slice(0, 3).join('; ')}` : '', full?.cons?.length ? `**Cons:** ${full.cons.slice(0, 2).join('; ')}` : '', note ? `\n> ${note}` : '', '', `_via RepoLens_`, - ].filter((l) => l !== undefined).join('\n').trim(); + ] + .filter((l) => l !== undefined) + .join('\n') + .trim(); try { await navigator.clipboard.writeText(lines); const orig = btn?.textContent; - if (btn) { btn.textContent = '✓'; setTimeout(() => { btn.textContent = orig; }, 1200); } - } catch { /* clipboard denied — silent */ } + if (btn) { + btn.textContent = '✓'; + setTimeout(() => { + btn.textContent = orig; + }, 1200); + } + } catch { + /* clipboard denied — silent */ + } } -function openNote(repoId, btn) { +function openNote(repoId) { const safeId = repoId.replace(/[^a-z0-9]/gi, '-'); const panel = document.getElementById(`lc-np-${safeId}`); if (!panel) return; const isOpen = !panel.classList.contains('hidden'); - if (isOpen) { panel.classList.add('hidden'); return; } + if (isOpen) { + panel.classList.add('hidden'); + return; + } panel.classList.remove('hidden'); const textarea = panel.querySelector('.lc-note-input'); if (textarea) { @@ -528,14 +647,22 @@ function openNote(repoId, btn) { try { if (text) await chrome.storage.local.set({ [noteKey(repoId)]: text }); else await chrome.storage.local.remove(noteKey(repoId)); - } catch { /* best-effort */ } + } catch { + /* best-effort */ + } panel.classList.add('hidden'); render(); }; textarea?.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { panel.classList.add('hidden'); return; } - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); saveNote(); } + if (e.key === 'Escape') { + panel.classList.add('hidden'); + return; + } + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + saveNote(); + } }); panel.querySelector('.lc-note-save')?.addEventListener('click', saveNote); } @@ -548,7 +675,9 @@ function openNote(repoId, btn) { async function focusExistingOutputTab(repoId) { try { const tabs = await chrome.tabs.query({ url: chrome.runtime.getURL('output-tab.html') + '*' }); - const existing = tabs.find((t) => typeof t.title === 'string' && t.title.includes(`${repoId} — RepoLens`)); + const existing = tabs.find( + (t) => typeof t.title === 'string' && t.title.includes(`${repoId} — RepoLens`) + ); if (!existing) return false; await chrome.tabs.update(existing.id, { active: true }); await chrome.windows.update(existing.windowId, { focused: true }); @@ -560,7 +689,10 @@ async function focusExistingOutputTab(repoId) { async function openRow(repoId) { const cached = cacheByRepo.get(repoId); - if (!cached) { openSource(repoId); return; } + if (!cached) { + openSource(repoId); + return; + } if (await focusExistingOutputTab(repoId)) return; openCachedAnalysis(cached); } @@ -573,10 +705,14 @@ async function rescan(repoId) { const key = 'repolens_' + crypto.randomUUID(); try { await chrome.runtime.sendMessage({ - type: 'RERUN', sessionKey: key, - platform: rowFor(repoId)?.platform || 'github', repoId, + type: 'RERUN', + sessionKey: key, + platform: rowFor(repoId)?.platform || 'github', + repoId, }); - } catch { /* background asleep — the output tab will surface any failure */ } + } catch { + /* background asleep — the output tab will surface any failure */ + } chrome.tabs.create({ url: chrome.runtime.getURL(`output-tab.html?key=${key}`) }); } @@ -585,12 +721,19 @@ async function removeRepo(repoId, btn) { if (!btn.dataset.armed) { btn.dataset.armed = '1'; btn.textContent = 'Remove?'; - setTimeout(() => { btn.dataset.armed = ''; btn.textContent = '✕'; }, 2500); + setTimeout(() => { + btn.dataset.armed = ''; + btn.textContent = '✕'; + }, 2500); return; } const cached = cacheByRepo.get(repoId); if (cached) { - try { await removeCached(cached.platform, cached.repoId); } catch { /* already gone */ } + try { + await removeCached(cached.platform, cached.repoId); + } catch { + /* already gone */ + } } await deleteRepo(repoId); // best-effort; never throws cacheByRepo.delete(repoId); @@ -606,9 +749,15 @@ async function removeRepo(repoId, btn) { async function pruneRepoFromCollections(repoId) { const touched = collections.filter((c) => collectionContains(c, repoId)); if (!touched.length) return; - collections = collections.map((c) => removeRepoFromCollection(c, repoId, { now: new Date().toISOString() })); + collections = collections.map((c) => + removeRepoFromCollection(c, repoId, { now: new Date().toISOString() }) + ); for (const c of touched) { - try { await saveCollection(collections.find((x) => x.id === c.id)); } catch { /* best-effort */ } + try { + await saveCollection(collections.find((x) => x.id === c.id)); + } catch { + /* best-effort */ + } } } @@ -634,7 +783,12 @@ function updateSelectionBar() { const del = document.getElementById('sel-del'); if (del) { del.disabled = !n; - if (delArmed) { delArmed = false; clearTimeout(delTimer); delTimer = null; resetDelBtn(del); } // a changed selection cancels a pending confirm + if (delArmed) { + delArmed = false; + clearTimeout(delTimer); + delTimer = null; + resetDelBtn(del); + } // a changed selection cancels a pending confirm } const all = document.getElementById('sel-all'); if (all) { @@ -648,7 +802,9 @@ function updateSelectionBar() { stack.disabled = !ok; stack.title = ok ? `Build a wiring diagram from ${n} repos` - : n < 2 ? 'Select 2–6 repos to build a stack' : 'Select at most 6 repos'; + : n < 2 + ? 'Select 2–6 repos to build a stack' + : 'Select at most 6 repos'; } const compare = document.getElementById('sel-compare'); if (compare) { @@ -674,8 +830,11 @@ function selectAllToggle() { // ─── N-way compare modal ────────────────────────────────────────────────────── function compareSelected() { - const repos = [...selected].map(id => allRows.find(r => r.repoId === id)).filter(Boolean); - if (repos.length < 2) { setStatus('Select at least 2 repos to compare.'); return; } + const repos = [...selected].map((id) => allRows.find((r) => r.repoId === id)).filter(Boolean); + if (repos.length < 2) { + setStatus('Select at least 2 repos to compare.'); + return; + } document.getElementById('rl-cmp-modal')?.remove(); const modal = document.createElement('div'); @@ -684,28 +843,72 @@ function compareSelected() { modal.setAttribute('aria-label', `Compare ${repos.length} repos`); modal.setAttribute('aria-modal', 'true'); - const thCols = repos.map(r => - `${esc(r.name)}
${esc(r.repoId)}` - ).join(''); + const thCols = repos + .map( + (r) => + `${esc(r.name)}
${esc(r.repoId)}` + ) + .join(''); const TABLE_ROWS = [ - { label: 'Fit', fn: r => `${esc(r.fit?.label ?? '—')}` }, - { label: 'Fit delta', fn: r => r.fitDelta ? `${r.fitDelta.from} → ${r.fitDelta.to}` : '—' }, - { label: 'Health', fn: r => r.health != null ? `${r.health}%` : '—' }, - { label: 'Stars', fn: r => r.stars != null ? (r.stars >= 1000 ? (r.stars / 1000).toFixed(1) + 'k' : String(r.stars)) : '—' }, - { label: 'Language', fn: r => esc(r.languages?.[0]?.name ?? '—') }, - { label: 'Decision', fn: r => { const d = decisionMap.get(r.repoId); return d ? `${esc(DECISION_META[d.decision]?.label || d.decision)}` : '—'; } }, - { label: 'Eval score', fn: r => { const ev = evalMap.get(r.repoId); const s = ev ? computeScore(ev, rubric) : null; return s !== null ? `${s.toFixed(1)}/5` : '—'; } }, - ...rubric.map(crit => ({ + { + label: 'Fit', + fn: (r) => + `${esc(r.fit?.label ?? '—')}`, + }, + { + label: 'Fit delta', + fn: (r) => + r.fitDelta + ? `${r.fitDelta.from} → ${r.fitDelta.to}` + : '—', + }, + { label: 'Health', fn: (r) => (r.health != null ? `${r.health}%` : '—') }, + { + label: 'Stars', + fn: (r) => + r.stars != null ? (r.stars >= 1000 ? (r.stars / 1000).toFixed(1) + 'k' : String(r.stars)) : '—', + }, + { label: 'Language', fn: (r) => esc(r.languages?.[0]?.name ?? '—') }, + { + label: 'Decision', + fn: (r) => { + const d = decisionMap.get(r.repoId); + return d + ? `${esc(DECISION_META[d.decision]?.label || d.decision)}` + : '—'; + }, + }, + { + label: 'Eval score', + fn: (r) => { + const ev = evalMap.get(r.repoId); + const s = ev ? computeScore(ev, rubric) : null; + return s !== null ? `${s.toFixed(1)}/5` : '—'; + }, + }, + ...rubric.map((crit) => ({ label: crit.name, - fn: r => { const ev = evalMap.get(r.repoId); const v = ev?.scores?.[crit.id]; return v != null ? '★'.repeat(v) + '☆'.repeat(5 - v) : '—'; }, + fn: (r) => { + const ev = evalMap.get(r.repoId); + const v = ev?.scores?.[crit.id]; + return v != null ? '★'.repeat(v) + '☆'.repeat(5 - v) : '—'; + }, })), - { label: 'Capabilities', fn: r => r.capabilities?.slice(0, 5).map(c => `${esc(c)}`).join(' ') || '—' }, - { label: 'Note', fn: r => esc((notesMap.get(r.repoId) || '').slice(0, 80)) || '—' }, + { + label: 'Capabilities', + fn: (r) => + r.capabilities + ?.slice(0, 5) + .map((c) => `${esc(c)}`) + .join(' ') || '—', + }, + { label: 'Note', fn: (r) => esc((notesMap.get(r.repoId) || '').slice(0, 80)) || '—' }, ]; - const tBody = TABLE_ROWS.map(row => - `${esc(row.label)}${repos.map(r => `${row.fn(r)}`).join('')}` + const tBody = TABLE_ROWS.map( + (row) => + `${esc(row.label)}${repos.map((r) => `${row.fn(r)}`).join('')}` ).join(''); modal.innerHTML = ` @@ -729,10 +932,20 @@ function compareSelected() { document.body.appendChild(modal); requestAnimationFrame(() => modal.classList.add('visible')); - const close = () => { modal.classList.remove('visible'); setTimeout(() => modal.remove(), 200); }; + const close = () => { + modal.classList.remove('visible'); + setTimeout(() => modal.remove(), 200); + }; modal.querySelector('#cmp2-close')?.addEventListener('click', close); - modal.addEventListener('click', e => { if (e.target === modal) close(); }); - const escFn = e => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escFn); } }; + modal.addEventListener('click', (e) => { + if (e.target === modal) close(); + }); + const escFn = (e) => { + if (e.key === 'Escape') { + close(); + document.removeEventListener('keydown', escFn); + } + }; document.addEventListener('keydown', escFn); modal.querySelector('#cmp2-md')?.addEventListener('click', () => exportCompareMatrix(repos, 'md')); modal.querySelector('#cmp2-csv')?.addEventListener('click', () => exportCompareMatrix(repos, 'csv')); @@ -740,34 +953,74 @@ function compareSelected() { function exportCompareMatrix(repos, format) { const date = new Date().toISOString().slice(0, 10); - const dl = (blob, name) => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); URL.revokeObjectURL(a.href); }; - const getRow = r => { + const dl = (blob, name) => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name; + a.click(); + URL.revokeObjectURL(a.href); + }; + const getRow = (r) => { const dec = decisionMap.get(r.repoId); const ev = evalMap.get(r.repoId); const score = ev ? computeScore(ev, rubric) : null; return { - repoId: r.repoId, fit: r.fit?.label ?? '—', health: r.health ?? '—', stars: r.stars ?? '—', + repoId: r.repoId, + fit: r.fit?.label ?? '—', + health: r.health ?? '—', + stars: r.stars ?? '—', language: r.languages?.[0]?.name ?? '—', - decision: dec ? (DECISION_META[dec.decision]?.label || dec.decision) : '—', + decision: dec ? DECISION_META[dec.decision]?.label || dec.decision : '—', evalScore: score !== null ? score.toFixed(1) : '—', - critScores: rubric.map(c => ev?.scores?.[c.id] ?? '—'), + critScores: rubric.map((c) => ev?.scores?.[c.id] ?? '—'), note: (notesMap.get(r.repoId) || '').slice(0, 80), }; }; if (format === 'csv') { - const qv = v => `"${String(v ?? '').replace(/"/g, '""')}"`; - const hdr = ['repoId', 'fit', 'health', 'stars', 'language', 'decision', 'evalScore', ...rubric.map(c => c.name), 'note'].join(','); - const rows = repos.map(r => { const d = getRow(r); return [qv(d.repoId), qv(d.fit), d.health, d.stars, qv(d.language), qv(d.decision), d.evalScore, ...d.critScores, qv(d.note)].join(','); }); + const qv = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; + const hdr = [ + 'repoId', + 'fit', + 'health', + 'stars', + 'language', + 'decision', + 'evalScore', + ...rubric.map((c) => c.name), + 'note', + ].join(','); + const rows = repos.map((r) => { + const d = getRow(r); + return [ + qv(d.repoId), + qv(d.fit), + d.health, + d.stars, + qv(d.language), + qv(d.decision), + d.evalScore, + ...d.critScores, + qv(d.note), + ].join(','); + }); dl(new Blob([[hdr, ...rows].join('\n')], { type: 'text/csv' }), `repolens-compare-${date}.csv`); } else { - const critCols = rubric.map(c => ` ${c.name} |`).join(''); + const critCols = rubric.map((c) => ` ${c.name} |`).join(''); const hdr = `| Repo | Fit | Health | Stars | Language | Decision | Eval |${critCols} Note |`; const sep = `|---|---|---|---|---|---|---|${rubric.map(() => '---|').join('')}---|`; - const rows = repos.map(r => { + const rows = repos.map((r) => { const d = getRow(r); - return `| [${d.repoId}](https://github.com/${d.repoId}) | ${d.fit} | ${d.health} | ${d.stars} | ${d.language} | ${d.decision} | ${d.evalScore} |${d.critScores.map(s => ` ${s} |`).join('')} ${d.note.replace(/\|/g, '\\|')} |`; + return `| [${d.repoId}](https://github.com/${d.repoId}) | ${d.fit} | ${d.health} | ${d.stars} | ${d.language} | ${d.decision} | ${d.evalScore} |${d.critScores.map((s) => ` ${s} |`).join('')} ${d.note.replace(/\|/g, '\\|')} |`; }); - dl(new Blob([`# Compare: ${repos.map(r => r.name).join(' · ')} — ${date}\n\n${hdr}\n${sep}\n${rows.join('\n')}\n`], { type: 'text/markdown' }), `repolens-compare-${date}.md`); + dl( + new Blob( + [ + `# Compare: ${repos.map((r) => r.name).join(' · ')} — ${date}\n\n${hdr}\n${sep}\n${rows.join('\n')}\n`, + ], + { type: 'text/markdown' } + ), + `repolens-compare-${date}.md` + ); } } @@ -810,7 +1063,10 @@ function deleteSelectedFlow(btn) { delArmed = true; btn.classList.add('armed'); btn.textContent = `Delete ${selected.size}? Confirm`; - delTimer = setTimeout(() => { delArmed = false; resetDelBtn(btn); }, 3000); + delTimer = setTimeout(() => { + delArmed = false; + resetDelBtn(btn); + }, 3000); return; } clearTimeout(delTimer); @@ -827,7 +1083,11 @@ async function deleteSelected() { for (const repoId of ids) { const cached = cacheByRepo.get(repoId); if (cached) { - try { await removeCached(cached.platform, cached.repoId); } catch { /* already gone */ } + try { + await removeCached(cached.platform, cached.repoId); + } catch { + /* already gone */ + } } await deleteRepo(repoId); // best-effort; never throws cacheByRepo.delete(repoId); @@ -835,7 +1095,10 @@ async function deleteSelected() { const idSet = new Set(ids); allRows = allRows.filter((r) => !idSet.has(r.repoId)); selected.clear(); - if (!allRows.length) { location.reload(); return; } // fall back to the clean empty state + if (!allRows.length) { + location.reload(); + return; + } // fall back to the clean empty state setSelectionMode(false, false); // tear down the mode; we render once below renderCaps(); render(); @@ -847,7 +1110,7 @@ async function deleteSelected() { async function bulkDecide(decision) { const ids = [...selected]; if (!ids.length) return; - const label = decision ? (DECISION_META[decision]?.label || decision) : 'cleared'; + const label = decision ? DECISION_META[decision]?.label || decision : 'cleared'; setStatus(`Setting decision to ${label} for ${ids.length} repo${ids.length === 1 ? '' : 's'}…`); const now = new Date().toISOString(); const { clearDecision } = await import('./store.js'); @@ -876,39 +1139,59 @@ function renderStats() { // The seeded demo repo is a tour prop, not part of the user's real library. const realRows = allRows.filter((r) => !isDemo(r)); const s = libraryStats(realRows); - if (!s.total) { host.classList.add('hidden'); host.innerHTML = ''; return; } + if (!s.total) { + host.classList.add('hidden'); + host.innerHTML = ''; + return; + } host.classList.remove('hidden'); const pill = (level, n) => (n ? html`${n} ${level}` : ''); const staleCount = realRows.filter((r) => { if (!r.savedAt) return false; - return (Date.now() - new Date(r.savedAt).getTime()) > 30 * 86_400_000; + return Date.now() - new Date(r.savedAt).getTime() > 30 * 86_400_000; }).length; const stalePill = staleCount - ? html`` + ? html`` : ''; const FIT_ORDER_ALL = ['strong', 'solid', 'care', 'risky', 'unrated']; const barSegments = FIT_ORDER_ALL.filter((lvl) => s.byFit[lvl] > 0) - .map((lvl) => ``) + .map( + (lvl) => + `` + ) .join(''); const decCounts = { adopt: 0, trial: 0, hold: 0, reject: 0 }; for (const d of decisionMap.values()) if (decCounts[d.decision] != null) decCounts[d.decision]++; const totalDecided = decCounts.adopt + decCounts.trial + decCounts.hold + decCounts.reject; const undecided = s.total - totalDecided; const decSummary = totalDecided - ? `${ - [ - decCounts.adopt ? `` : '', - decCounts.trial ? `` : '', - decCounts.hold ? `` : '', - decCounts.reject ? `` : '', - undecided > 0 ? `` : '', - ].filter(Boolean).join('·') - }` + ? `${[ + decCounts.adopt + ? `` + : '', + decCounts.trial + ? `` + : '', + decCounts.hold + ? `` + : '', + decCounts.reject + ? `` + : '', + undecided > 0 + ? `` + : '', + ] + .filter(Boolean) + .join('·')}` : ''; const triagePct = s.total ? Math.round((totalDecided / s.total) * 100) : 0; - const triagePill = s.total > 0 - ? `${triagePct}% triaged` - : ''; + const triagePill = + s.total > 0 + ? `${triagePct}% triaged` + : ''; // Honest mastery coverage — counts, never a percentage. const agg = aggregateMastery(masteryMap); const masteryLine = agg.total @@ -918,14 +1201,19 @@ function renderStats() { ? `${masteryLine}` : ''; host.innerHTML = String(html` - ${s.total} repo${s.total === 1 ? '' : 's'} - ${triagePill} - ${barSegments ? `${barSegments}` : ''} + ${s.total} repo${s.total === 1 + ? '' + : 's'} + ${triagePill} ${barSegments ? `${barSegments}` : ''} ${FIT_ORDER_ALL.map((lvl) => pill(lvl, s.byFit[lvl]))} - ${s.avgHealth != null ? html`avg health ${s.avgHealth}` : ''} - ${stalePill} - ${decSummary} - ${masterySummary} + ${s.avgHealth != null + ? html`avg health ${s.avgHealth}` + : ''} + ${stalePill} ${decSummary} ${masterySummary} `); countUpStat(host.querySelector('.ls-total-n')); countUpStat(host.querySelector('.ls-health-n')); @@ -945,16 +1233,26 @@ function countUpStat(el) { if (!el) return; const target = Number(el.dataset.count); if (!Number.isFinite(target)) return; - if (prefersReducedMotion()) { el.textContent = String(target); return; } + if (prefersReducedMotion()) { + el.textContent = String(target); + return; + } const cu = new CountUp(el, target, { duration: 0.9, useGrouping: true }); - if (cu.error) { el.textContent = String(target); return; } + if (cu.error) { + el.textContent = String(target); + return; + } cu.start(); } function renderNlFilterBanner() { const host = document.getElementById('nl-filter-banner'); if (!host) return; - if (!nlFilter) { host.classList.add('hidden'); host.innerHTML = ''; return; } + if (!nlFilter) { + host.classList.add('hidden'); + host.innerHTML = ''; + return; + } host.classList.remove('hidden'); if (!nlFilter.ids) { host.innerHTML = ` AI filtering for ${esc(nlFilter.question)}`; @@ -963,7 +1261,9 @@ function renderNlFilterBanner() { const count = nlFilter.ids.length; const errSpan = nlFilter.error ? `${esc(nlFilter.error)}` : ''; const result = !nlFilter.error - ? (count ? `${count} match${count === 1 ? '' : 'es'}` : 'No matches found') + ? count + ? `${count} match${count === 1 ? '' : 'es'}` + : 'No matches found' : errSpan; host.innerHTML = `✦ AI: ${esc(nlFilter.question)}${result}`; host.querySelector('#nlf-clear')?.addEventListener('click', () => { @@ -976,13 +1276,15 @@ function renderNlFilterBanner() { async function refreshStale() { const stale = allRows.filter((r) => { if (!r.savedAt) return false; - return (Date.now() - new Date(r.savedAt).getTime()) > 30 * 86_400_000; + return Date.now() - new Date(r.savedAt).getTime() > 30 * 86_400_000; }); if (!stale.length) return; const urls = stale.map((r) => sourceUrl(r.platform || '', r.repoId)); try { await chrome.storage.session.set({ repolens_batch_prefill: urls }); - } catch { /* session storage unavailable — open batch anyway */ } + } catch { + /* session storage unavailable — open batch anyway */ + } chrome.tabs.create({ url: chrome.runtime.getURL('batch.html') }); } @@ -1000,7 +1302,16 @@ async function exportLibrary() { try { setStatus('Preparing backup…'); const [stores, cached] = await Promise.all([exportStores(), listCached().catch(() => [])]); - const backup = buildBackup({ repos: stores.repos, nodes: stores.nodes, edges: stores.edges, cache: cached, collections: stores.collections, decisions: stores.decisions, snapshots: stores.snapshots, scenes: stores.scenes }); + const backup = buildBackup({ + repos: stores.repos, + nodes: stores.nodes, + edges: stores.edges, + cache: cached, + collections: stores.collections, + decisions: stores.decisions, + snapshots: stores.snapshots, + scenes: stores.scenes, + }); const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -1011,7 +1322,9 @@ async function exportLibrary() { a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); const c = backup.counts; - setStatus(`Exported ${c.repos} repo${c.repos === 1 ? '' : 's'}, ${c.cache} cached, ${c.edges} connection${c.edges === 1 ? '' : 's'}.`); + setStatus( + `Exported ${c.repos} repo${c.repos === 1 ? '' : 's'}, ${c.cache} cached, ${c.edges} connection${c.edges === 1 ? '' : 's'}.` + ); } catch (e) { setStatus('Export failed: ' + (e?.message || e), true); } @@ -1049,13 +1362,31 @@ function showImportConfirm(value, counts, warnings = []) { if (!host) return; setStatus(''); host.classList.remove('hidden'); - const warn = warnings.length ? html`

${warnings[0]}

` : ''; + const warn = warnings.length + ? html`

${warnings[0]}

` + : ''; host.innerHTML = String(html` ${warn} -

This backup has ${counts.repos} repo${counts.repos === 1 ? '' : 's'}, ${counts.cache} cached scan${counts.cache === 1 ? '' : 's'} and ${counts.edges} connection${counts.edges === 1 ? '' : 's'}. How should it be applied?

+

+ This backup has ${counts.repos} repo${counts.repos === 1 ? '' : 's'}, + ${counts.cache} cached scan${counts.cache === 1 ? '' : 's'} and + ${counts.edges} connection${counts.edges === 1 ? '' : 's'}. How should it be applied? +

- - + +
`); @@ -1122,10 +1453,16 @@ function wireToolbar() { densityCompact = !densityCompact; document.getElementById('grid')?.classList.toggle('density-compact', densityCompact); const btn = document.getElementById('density-toggle'); - if (btn) { btn.textContent = densityCompact ? '⊞' : '⊟'; btn.classList.toggle('on', densityCompact); btn.title = densityCompact ? 'Switch to comfortable view' : 'Switch to compact view'; } + if (btn) { + btn.textContent = densityCompact ? '⊞' : '⊟'; + btn.classList.toggle('on', densityCompact); + btn.title = densityCompact ? 'Switch to comfortable view' : 'Switch to compact view'; + } chrome.storage.local.set({ libraryDensity: densityCompact ? 'compact' : 'comfortable' }); }); - document.getElementById('batch-scan-link')?.addEventListener('click', () => chrome.tabs.create({ url: chrome.runtime.getURL('batch.html') })); + document + .getElementById('batch-scan-link') + ?.addEventListener('click', () => chrome.tabs.create({ url: chrome.runtime.getURL('batch.html') })); document.getElementById('compare-btn')?.addEventListener('click', () => { compareSet.clear(); updateCompareToolbar(); @@ -1144,7 +1481,7 @@ function wireToolbar() { document.getElementById('sel-compare')?.addEventListener('click', compareSelected); document.getElementById('sel-done')?.addEventListener('click', () => setSelectionMode(false)); document.getElementById('discover-btn')?.addEventListener('click', openDiscovery); - document.getElementById('discover-form')?.addEventListener('submit', async e => { + document.getElementById('discover-form')?.addEventListener('submit', async (e) => { e.preventDefault(); const q = document.getElementById('discover-input')?.value.trim(); if (!q) return; @@ -1158,7 +1495,7 @@ function wireToolbar() { resultsEl.innerHTML = `

Search failed: ${esc(err.message)}

`; } }); - document.getElementById('discover-panel')?.addEventListener('click', e => { + document.getElementById('discover-panel')?.addEventListener('click', (e) => { const btn = e.target.closest('.dc-open'); if (btn?.dataset.url) chrome.tabs.create({ url: btn.dataset.url }); }); @@ -1195,22 +1532,25 @@ const CAPS_VISIBLE = 10; function renderCaps() { const host = document.getElementById('caps'); const caps = allCapabilities(allRows); - if (!caps.length) { host.innerHTML = ''; return; } + if (!caps.length) { + host.innerHTML = ''; + return; + } const visibleCaps = caps.slice(0, CAPS_VISIBLE); const hiddenCaps = caps.slice(CAPS_VISIBLE); const showAll = host.dataset.expanded === '1'; - const renderCap = (c) => ``; + const renderCap = (c) => + ``; const moreBtn = hiddenCaps.length - ? (showAll - ? `` - : ``) + ? showAll + ? `` + : `` : ''; - host.innerHTML = visibleCaps.map(renderCap).join('') - + (showAll ? hiddenCaps.map(renderCap).join('') : '') - + moreBtn; + host.innerHTML = + visibleCaps.map(renderCap).join('') + (showAll ? hiddenCaps.map(renderCap).join('') : '') + moreBtn; if (!host._capsDelegated) { host._capsDelegated = true; @@ -1260,7 +1600,8 @@ function toggleRadarView() { // Ensure the Corkboard view is closed when the Radar opens. document.getElementById('corkboard-panel')?.classList.add('hidden'); syncViewSwitcher(); - if (state.view === 'radar') renderRadar(); else render(); + if (state.view === 'radar') renderRadar(); + else render(); } function renderRadar() { @@ -1275,7 +1616,8 @@ function renderRadar() { } const hasAny = Object.values(byDecision).some((arr) => arr.length > 0); if (!hasAny) { - host.innerHTML = '

No decisions recorded yet.
Open a repo analysis and use the Decision Log on the Verdict tab to record Adopt, Trial, Hold, or Reject.

'; + host.innerHTML = + '

No decisions recorded yet.
Open a repo analysis and use the Decision Log on the Verdict tab to record Adopt, Trial, Hold, or Reject.

'; return; } const cols = ['adopt', 'trial', 'hold', 'reject'].map((key) => { @@ -1283,10 +1625,12 @@ function renderRadar() { const items = byDecision[key]; const icon = RADAR_ICONS[key]; const chips = items.length - ? items.map(({ row }) => { - const label = row.repoId.includes('/') ? row.repoId.split('/')[1] : row.repoId; - return ``; - }).join('') + ? items + .map(({ row }) => { + const label = row.repoId.includes('/') ? row.repoId.split('/')[1] : row.repoId; + return ``; + }) + .join('') : ``; return `
@@ -1309,7 +1653,9 @@ function renderRadar() { await navigator.clipboard.writeText(md).catch(() => {}); const orig = btn.textContent; btn.textContent = '✓ Copied!'; - setTimeout(() => { btn.textContent = orig; }, 1600); + setTimeout(() => { + btn.textContent = orig; + }, 1600); }); } @@ -1320,7 +1666,10 @@ function radarToMarkdown(byDecision) { const icon = RADAR_ICONS[key]; const items = byDecision[key]; lines.push(`## ${icon} ${meta.label} (${items.length})`); - if (!items.length) { lines.push('_None_', ''); continue; } + if (!items.length) { + lines.push('_None_', ''); + continue; + } lines.push('| Repo | Note |', '|------|------|'); for (const { row, note } of items) { const url = sourceUrl(row.platform || '', row.repoId); @@ -1344,7 +1693,8 @@ function toggleCorkboardView() { // Ensure the Radar view is closed when the Corkboard opens. document.getElementById('radar-panel')?.classList.add('hidden'); syncViewSwitcher(); - if (on) renderCorkboard(); else render(); + if (on) renderCorkboard(); + else render(); } let cbApi = null; @@ -1353,8 +1703,12 @@ async function renderCorkboard() { if (!panel) return; const graph = await getLibraryGraph(); if (!graph.nodes.length) { - if (cbApi) { cbApi.destroy(); cbApi = null; } - panel.innerHTML = '
Scan a few repos — and run Alternatives / Synergies / Versus — to grow your board.
'; + if (cbApi) { + cbApi.destroy(); + cbApi = null; + } + panel.innerHTML = + '
Scan a few repos — and run Alternatives / Synergies / Versus — to grow your board.
'; return; } // Repo metadata (fit level + health) from the loaded rows. `r.fit.level` is the @@ -1375,9 +1729,9 @@ async function renderCorkboard() { const saved = await getScene('library'); const savedPos = saved ? Object.fromEntries((saved.nodes || []).map((n) => [n.id, n])) : {}; const seeded = layoutCorkboard(built.nodes, built.edges); - built.nodes = seeded.map((n) => (savedPos[n.id] - ? { ...n, x: savedPos[n.id].x, y: savedPos[n.id].y, pinned: !!savedPos[n.id].pinned } - : n)); + built.nodes = seeded.map((n) => + savedPos[n.id] ? { ...n, x: savedPos[n.id].x, y: savedPos[n.id].y, pinned: !!savedPos[n.id].pinned } : n + ); panel.innerHTML = ''; if (cbApi) cbApi.destroy(); cbApi = mountCanvas(panel, built, { onChange: (s) => saveScene(s).catch(() => {}) }); @@ -1401,7 +1755,11 @@ function renderDecisionFilter() { if (counts[dec.decision] != null) counts[dec.decision]++; } const total = Object.values(counts).reduce((a, b) => a + b, 0); - if (!total) { host.classList.add('hidden'); host.innerHTML = ''; return; } + if (!total) { + host.classList.add('hidden'); + host.innerHTML = ''; + return; + } host.classList.remove('hidden'); const undecidedCount = allRows.filter((r) => !decisionMap.has(r.repoId)).length; const chip = (id, label, n) => @@ -1410,7 +1768,7 @@ function renderDecisionFilter() { chip('', 'All decisions', total), counts.adopt ? chip('adopt', 'Adopt', counts.adopt) : '', counts.trial ? chip('trial', 'Trial', counts.trial) : '', - counts.hold ? chip('hold', 'Hold', counts.hold) : '', + counts.hold ? chip('hold', 'Hold', counts.hold) : '', counts.reject ? chip('reject', 'Reject', counts.reject) : '', undecidedCount ? chip('undecided', 'Undecided', undecidedCount) : '', ].join(''); @@ -1441,15 +1799,23 @@ function renderCollections() { chip('', 'All', allRows.length, ''), ...cols.map((c) => chip(c.id, c.name, c.repoIds.length, c.color)), ``, - state.collection ? `` : '', + state.collection + ? `` + : '', ].join(''); host.innerHTML = chips; if (!host._collDelegated) { host._collDelegated = true; host.addEventListener('click', (e) => { - if (e.target.closest('[data-coll-new]')) { showCollectionInput(host); return; } - if (e.target.closest('[data-coll-del]')) { confirmDeleteCollection(e.target.closest('[data-coll-del]')); return; } + if (e.target.closest('[data-coll-new]')) { + showCollectionInput(host); + return; + } + if (e.target.closest('[data-coll-del]')) { + confirmDeleteCollection(e.target.closest('[data-coll-del]')); + return; + } const btn = e.target.closest('[data-coll]'); if (!btn) return; state.collection = btn.dataset.coll; @@ -1461,7 +1827,10 @@ function renderCollections() { function showCollectionInput(host, addRepoId) { const existing = host.querySelector('.coll-inline-input'); - if (existing) { existing.focus(); return; } + if (existing) { + existing.focus(); + return; + } const wrap = document.createElement('div'); wrap.className = 'coll-inline-wrap'; wrap.innerHTML = ``; @@ -1475,10 +1844,18 @@ function showCollectionInput(host, addRepoId) { await createCollection(addRepoId, name); }; input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); finish(); } - if (e.key === 'Escape') { e.preventDefault(); wrap.remove(); } + if (e.key === 'Enter') { + e.preventDefault(); + finish(); + } + if (e.key === 'Escape') { + e.preventDefault(); + wrap.remove(); + } + }); + input.addEventListener('blur', () => { + setTimeout(() => wrap.isConnected && wrap.remove(), 150); }); - input.addEventListener('blur', () => { setTimeout(() => wrap.isConnected && wrap.remove(), 150); }); } // Create a collection (optionally adding a repo to it straight away). @@ -1489,12 +1866,23 @@ async function createCollection(addRepoId, name) { } if (!name) return null; const check = validateCollectionName(name, collections); - if (!check.ok) { setStatus(check.error, true); return null; } - let col = makeCollection(name, { id: crypto.randomUUID(), color: nextColor(collections.length), now: new Date().toISOString() }); + if (!check.ok) { + setStatus(check.error, true); + return null; + } + let col = makeCollection(name, { + id: crypto.randomUUID(), + color: nextColor(collections.length), + now: new Date().toISOString(), + }); if (addRepoId) col = addRepoToCollection(col, addRepoId, { now: col.createdAt }); collections = [...collections, col]; - try { await saveCollection(col); setStatus(`Created “${col.name}”`); } - catch { setStatus('Could not save the collection.', true); } + try { + await saveCollection(col); + setStatus(`Created “${col.name}”`); + } catch { + setStatus('Could not save the collection.', true); + } renderCollections(); render(); return col; @@ -1505,7 +1893,12 @@ function confirmDeleteCollection(btn) { if (!btn.dataset.armed) { btn.dataset.armed = '1'; btn.textContent = 'Delete — sure?'; - setTimeout(() => { if (btn.isConnected) { btn.dataset.armed = ''; btn.textContent = 'Delete collection'; } }, 2500); + setTimeout(() => { + if (btn.isConnected) { + btn.dataset.armed = ''; + btn.textContent = 'Delete collection'; + } + }, 2500); return; } const id = state.collection; @@ -1525,17 +1918,26 @@ function closeBoardsPopover() { document.removeEventListener('click', onPopoverDocClick, true); document.removeEventListener('keydown', onPopoverKey, true); } -function onPopoverDocClick(e) { if (openPopover && !openPopover.contains(e.target)) closeBoardsPopover(); } -function onPopoverKey(e) { if (e.key === 'Escape') closeBoardsPopover(); } +function onPopoverDocClick(e) { + if (openPopover && !openPopover.contains(e.target)) closeBoardsPopover(); +} +function onPopoverKey(e) { + if (e.key === 'Escape') closeBoardsPopover(); +} function openBoardsPopover(repoId, anchor) { closeBoardsPopover(); const cols = sortedCollections(collections); const list = cols.length - ? cols.map((c) => ``).join('') + ? cols + .map( + (c) => + `` + ) + .join('') : `
No collections yet.
`; const pop = document.createElement('div'); pop.className = 'boards-pop'; @@ -1573,9 +1975,13 @@ async function toggleMembership(collectionId, repoId) { if (idx < 0) return; const updated = toggleRepoInCollection(collections[idx], repoId, { now: new Date().toISOString() }); collections = collections.map((c, i) => (i === idx ? updated : c)); - try { await saveCollection(updated); } catch { setStatus('Could not update the collection.', true); } + try { + await saveCollection(updated); + } catch { + setStatus('Could not update the collection.', true); + } renderCollections(); // counts changed - render(); // card dots + (if filtering this collection) membership + render(); // card dots + (if filtering this collection) membership } // ─── Compare mode ───────────────────────────────────────────────────────────── @@ -1615,40 +2021,62 @@ function comparePanelHtml(a, b) { const maxHealth = Math.max(a.health, b.health, 1); const maxStars = Math.max(a.stars, b.stars, 1); - const fitChip = (r) => `${esc(r.fit.label)}`; - const decChip = (dec) => dec - ? `${esc(DECISION_META[dec.decision]?.label || dec.decision)}` - : ''; - const bar = (v, max) => `
`; - const langPips = (r) => r.languages.map((l) => ``).join(''); + const fitChip = (r) => + `${esc(r.fit.label)}`; + const decChip = (dec) => + dec + ? `${esc(DECISION_META[dec.decision]?.label || dec.decision)}` + : ''; + const bar = (v, max) => + `
`; + const langPips = (r) => + r.languages + .map( + (l) => `` + ) + .join(''); const cell = (v, fallback = '') => v || fallback; const rows = [ - ['Fit', fitChip(a), fitChip(b)], - ['Health', a.health ? `${a.health}% ${bar(a.health, maxHealth)}` : '', b.health ? `${b.health}% ${bar(b.health, maxHealth)}` : ''], - ['Stars', a.stars ? `${fmtStars(a.stars)} ${bar(a.stars, maxStars)}` : '', b.stars ? `${fmtStars(b.stars)} ${bar(b.stars, maxStars)}` : ''], - ['Category', a.category ? esc(a.category) : '', b.category ? esc(b.category) : ''], - ['Languages', langPips(a) || '', langPips(b) || ''], - ['Decision', decChip(decA), decChip(decB)], + ['Fit', fitChip(a), fitChip(b)], + [ + 'Health', + a.health ? `${a.health}% ${bar(a.health, maxHealth)}` : '', + b.health ? `${b.health}% ${bar(b.health, maxHealth)}` : '', + ], + [ + 'Stars', + a.stars ? `${fmtStars(a.stars)} ${bar(a.stars, maxStars)}` : '', + b.stars ? `${fmtStars(b.stars)} ${bar(b.stars, maxStars)}` : '', + ], + ['Category', a.category ? esc(a.category) : '', b.category ? esc(b.category) : ''], + ['Languages', langPips(a) || '', langPips(b) || ''], + ['Decision', decChip(decA), decChip(decB)], ]; - const metaRows = rows.map(([label, va, vb]) => - `
+ const metaRows = rows + .map( + ([label, va, vb]) => + `
${label}
${cell(va)}
${cell(vb)}
` - ).join(''); + ) + .join(''); const capRows = caps.length ? `
Capabilities
` + - caps.map((cap) => - `
+ caps + .map( + (cap) => + `
${esc(cap)}
${a.capabilities.includes(cap) ? '✓' : '✗'}
${b.capabilities.includes(cap) ? '✓' : '✗'}
` - ).join('') + ) + .join('') : ''; return ` @@ -1670,11 +2098,14 @@ function renderVerdictHtml(result, nameA, nameB) { const winnerBadge = winnerName ? `✓ ${esc(winnerName)}` : `⇄ Tie`; - const picks = (result.pickA || result.pickB) ? ` + const picks = + result.pickA || result.pickB + ? `
${result.pickA ? `
${esc(nameA)}${esc(result.pickA)}
` : ''} ${result.pickB ? `
${esc(nameB)}${esc(result.pickB)}
` : ''} -
` : ''; +
` + : ''; const tradeoffs = result.tradeoffs?.length ? `
    ${result.tradeoffs.map((t) => `
  • ${esc(t)}
  • `).join('')}
` : ''; @@ -1690,11 +2121,18 @@ function renderVerdictHtml(result, nameA, nameB) { function renderComparePanel() { const host = document.getElementById('compare-panel'); if (!host) return; - if (compareSet.size !== 2) { host.classList.add('hidden'); host.innerHTML = ''; return; } + if (compareSet.size !== 2) { + host.classList.add('hidden'); + host.innerHTML = ''; + return; + } const [idA, idB] = [...compareSet]; const a = allRows.find((r) => r.repoId === idA); const b = allRows.find((r) => r.repoId === idB); - if (!a || !b) { host.classList.add('hidden'); return; } + if (!a || !b) { + host.classList.add('hidden'); + return; + } host.classList.remove('hidden'); host.innerHTML = comparePanelHtml(a, b); @@ -1719,10 +2157,12 @@ function renderComparePanel() { if (resp?.ok && resp.result) { if (verdictEl) verdictEl.innerHTML = renderVerdictHtml(resp.result, a.name || idA, b.name || idB); } else { - if (verdictEl) verdictEl.innerHTML = `
${esc(resp?.error || 'Comparison failed')}
`; + if (verdictEl) + verdictEl.innerHTML = `
${esc(resp?.error || 'Comparison failed')}
`; } } catch (err) { - if (verdictEl) verdictEl.innerHTML = `
${esc(err?.message || 'Comparison failed')}
`; + if (verdictEl) + verdictEl.innerHTML = `
${esc(err?.message || 'Comparison failed')}
`; } btn.disabled = false; btn.textContent = '✦ Ask AI'; @@ -1760,7 +2200,11 @@ function buildAskDocs() { function renderAskResult({ loading = false, answer = '', error = '' } = {}) { const host = document.getElementById('ask-answer'); if (!host) return; - if (!loading && !answer && !error) { host.classList.add('hidden'); host.innerHTML = ''; return; } + if (!loading && !answer && !error) { + host.classList.add('hidden'); + host.innerHTML = ''; + return; + } host.classList.remove('hidden'); if (loading) { host.innerHTML = 'Asking your library…'; @@ -1780,7 +2224,10 @@ async function submitAsk(question) { if (!q) return; const allDocs = buildAskDocs(); - if (!allDocs.length) { renderAskResult({ error: 'No repos in your library yet.' }); return; } + if (!allDocs.length) { + renderAskResult({ error: 'No repos in your library yet.' }); + return; + } // BM25-rank the docs against the question; fall back to the first 6 if ranking finds nothing. const ranked = rankRepos(allDocs, q, { topK: 6 }); @@ -1793,7 +2240,7 @@ async function submitAsk(question) { let resp; try { resp = await chrome.runtime.sendMessage({ type: 'ASK_LIBRARY', question: q, docs: contextDocs }); - } catch (e) { + } catch { renderAskResult({ error: 'Could not reach the extension. Try again.' }); if (btn) btn.disabled = false; return; @@ -1834,7 +2281,13 @@ function exportDigest(format) { } else if (format === 'md') { const date = new Date().toISOString().slice(0, 10); const groups = { strong: [], solid: [], care: [], risky: [], unrated: [] }; - const labels = { strong: 'Strong fit', solid: 'Solid fit', care: 'Needs care', risky: 'Risky', unrated: 'Unrated' }; + const labels = { + strong: 'Strong fit', + solid: 'Solid fit', + care: 'Needs care', + risky: 'Risky', + unrated: 'Unrated', + }; for (const r of rows) { const k = r.fit?.level in groups ? r.fit.level : 'unrated'; groups[k].push(r); @@ -1852,7 +2305,9 @@ function exportDigest(format) { r.health ? `♥ ${r.health}` : '', r.stars >= 1 ? `${r.stars >= 1000 ? (r.stars / 1000).toFixed(1) + 'k' : r.stars}★` : '', r.languages[0]?.name || '', - ].filter(Boolean).join(' · '); + ] + .filter(Boolean) + .join(' · '); return `- **[${r.repoId}](https://github.com/${r.repoId})**${decLabel(r.repoId)}${meta ? ` — ${meta}` : ''}\n ${r.blurb ? r.blurb.slice(0, 120) : ''}${noteText(r.repoId)}`; }; const sections = Object.entries(groups) @@ -1868,11 +2323,16 @@ function exportDigest(format) { URL.revokeObjectURL(a.href); } else { const header = 'repoId,fit,stars,language,license,blurb,savedAt'; - const csvRow = (r) => [ - r.repoId, r.fit?.level ?? '', r.stars ?? '', r.language ?? '', r.license ?? '', - `"${(r.blurb ?? '').replace(/"/g, '""').slice(0, 200)}"`, - r.savedAt ? new Date(r.savedAt).toISOString() : '', - ].join(','); + const csvRow = (r) => + [ + r.repoId, + r.fit?.level ?? '', + r.stars ?? '', + r.language ?? '', + r.license ?? '', + `"${(r.blurb ?? '').replace(/"/g, '""').slice(0, 200)}"`, + r.savedAt ? new Date(r.savedAt).toISOString() : '', + ].join(','); const blob = new Blob([[header, ...rows.map(csvRow)].join('\n')], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); @@ -1886,42 +2346,65 @@ function exportDigest(format) { function exportDecisionMatrix(format = 'csv') { const rows = getVisibleRows(); - if (!rows.length) { setStatus('No repos to export.'); return; } + if (!rows.length) { + setStatus('No repos to export.'); + return; + } const date = new Date().toISOString().slice(0, 10); - const dl = (blob, name) => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); URL.revokeObjectURL(a.href); }; + const dl = (blob, name) => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name; + a.click(); + URL.revokeObjectURL(a.href); + }; if (format === 'csv') { - const qv = v => `"${String(v ?? '').replace(/"/g, '""')}"`; - const critHeaders = rubric.map(c => qv(c.name)).join(','); + const qv = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; + const critHeaders = rubric.map((c) => qv(c.name)).join(','); const hdr = `repoId,name,platform,fit,health,stars,language,decision,decisionDate,evalScore,${critHeaders},evalNote,note,savedAt`; - const csvRows = rows.map(r => { + const csvRows = rows.map((r) => { const dec = decisionMap.get(r.repoId); const ev = evalMap.get(r.repoId); const score = ev ? computeScore(ev, rubric) : null; return [ - qv(r.repoId), qv(r.name), qv(r.platform ?? ''), - r.fit?.level ?? '', r.health ?? '', r.stars ?? '', + qv(r.repoId), + qv(r.name), + qv(r.platform ?? ''), + r.fit?.level ?? '', + r.health ?? '', + r.stars ?? '', qv(r.languages?.[0]?.name ?? ''), - dec?.decision ?? '', dec?.savedAt ? new Date(dec.savedAt).toISOString().slice(0, 10) : '', + dec?.decision ?? '', + dec?.savedAt ? new Date(dec.savedAt).toISOString().slice(0, 10) : '', score !== null ? score.toFixed(2) : '', - rubric.map(c => ev?.scores?.[c.id] ?? '').join(','), - qv(ev?.note ?? ''), qv(notesMap.get(r.repoId) ?? ''), + rubric.map((c) => ev?.scores?.[c.id] ?? '').join(','), + qv(ev?.note ?? ''), + qv(notesMap.get(r.repoId) ?? ''), r.savedAt ? new Date(r.savedAt).toISOString().slice(0, 10) : '', ].join(','); }); dl(new Blob([[hdr, ...csvRows].join('\n')], { type: 'text/csv' }), `repolens-matrix-${date}.csv`); } else { - const critCols = rubric.map(c => ` ${c.name} |`).join(''); + const critCols = rubric.map((c) => ` ${c.name} |`).join(''); const hdr = `| Repo | Fit | Health | Stars | Language | Decision | Eval |${critCols} Note |`; const sep = `|---|---|---|---|---|---|---|${rubric.map(() => '---|').join('')}---|`; - const mdRows = rows.map(r => { + const mdRows = rows.map((r) => { const dec = decisionMap.get(r.repoId); const ev = evalMap.get(r.repoId); const score = ev ? computeScore(ev, rubric) : null; const note = (notesMap.get(r.repoId) || ev?.note || '').slice(0, 60).replace(/\|/g, '\\|'); - return `| [${r.repoId}](https://github.com/${r.repoId}) | ${r.fit?.label ?? '—'} | ${r.health ?? '—'} | ${r.stars ?? '—'} | ${r.languages?.[0]?.name ?? '—'} | ${dec ? (DECISION_META[dec.decision]?.label || dec.decision) : '—'} | ${score !== null ? score.toFixed(1) : '—'} |${rubric.map(c => ` ${ev?.scores?.[c.id] ?? '—'} |`).join('')} ${note} |`; + return `| [${r.repoId}](https://github.com/${r.repoId}) | ${r.fit?.label ?? '—'} | ${r.health ?? '—'} | ${r.stars ?? '—'} | ${r.languages?.[0]?.name ?? '—'} | ${dec ? DECISION_META[dec.decision]?.label || dec.decision : '—'} | ${score !== null ? score.toFixed(1) : '—'} |${rubric.map((c) => ` ${ev?.scores?.[c.id] ?? '—'} |`).join('')} ${note} |`; }); - dl(new Blob([`# RepoLens Decision Matrix — ${date}\n\n_${rows.length} repos · Generated by RepoLens_\n\n${hdr}\n${sep}\n${mdRows.join('\n')}\n`], { type: 'text/markdown' }), `repolens-matrix-${date}.md`); + dl( + new Blob( + [ + `# RepoLens Decision Matrix — ${date}\n\n_${rows.length} repos · Generated by RepoLens_\n\n${hdr}\n${sep}\n${mdRows.join('\n')}\n`, + ], + { type: 'text/markdown' } + ), + `repolens-matrix-${date}.md` + ); } setStatus(`Exported ${rows.length} repos to decision matrix.`); } @@ -1953,11 +2436,17 @@ async function searchGitHub(query) { } function showDiscoveryResults(items, resultsEl, heading) { - const existing = new Set(allRows.map(r => r.repoId)); - const fresh = items.filter(item => !existing.has(item.full_name)); - if (!fresh.length) { resultsEl.innerHTML = '

No new repos found — all results are already in your library.

'; return; } - const stars = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k★' : `${n || 0}★`; - const cards = fresh.map(item => ` + const existing = new Set(allRows.map((r) => r.repoId)); + const fresh = items.filter((item) => !existing.has(item.full_name)); + if (!fresh.length) { + resultsEl.innerHTML = + '

No new repos found — all results are already in your library.

'; + return; + } + const stars = (n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k★' : `${n || 0}★`); + const cards = fresh + .map( + (item) => `
${esc(item.name)} @@ -1969,7 +2458,9 @@ function showDiscoveryResults(items, resultsEl, heading) { ${esc(item.full_name)}
-
`).join(''); +
` + ) + .join(''); resultsEl.innerHTML = heading ? `
${esc(heading)}
${cards}` : cards; } @@ -1977,7 +2468,10 @@ async function recommendFromLibrary(resultsEl) { if (!resultsEl) resultsEl = document.querySelector('#discover-panel .dc-results'); if (!resultsEl) return; - const adopted = allRows.filter(r => { const d = decisionMap.get(r.repoId); return d && (d.decision === 'adopt' || d.decision === 'trial'); }); + const adopted = allRows.filter((r) => { + const d = decisionMap.get(r.repoId); + return d && (d.decision === 'adopt' || d.decision === 'trial'); + }); if (!adopted.length) { resultsEl.innerHTML = '

Adopt or trial some repos to unlock recommendations.

'; return; @@ -1985,13 +2479,20 @@ async function recommendFromLibrary(resultsEl) { const capFreq = {}; for (const r of adopted) for (const c of r.capabilities || []) capFreq[c] = (capFreq[c] || 0) + 1; - const topCaps = Object.entries(capFreq).sort((a, b) => b[1] - a[1]).slice(0, 2).map(([c]) => c); + const topCaps = Object.entries(capFreq) + .sort((a, b) => b[1] - a[1]) + .slice(0, 2) + .map(([c]) => c); const langFreq = {}; - for (const r of adopted) { const l = r.languages?.[0]?.name; if (l) langFreq[l] = (langFreq[l] || 0) + 1; } + for (const r of adopted) { + const l = r.languages?.[0]?.name; + if (l) langFreq[l] = (langFreq[l] || 0) + 1; + } const topLang = Object.entries(langFreq).sort((a, b) => b[1] - a[1])[0]?.[0]; - const query = [topCaps.join(' '), topLang ? `language:${topLang}` : ''].filter(Boolean).join(' ') || 'developer tools'; + const query = + [topCaps.join(' '), topLang ? `language:${topLang}` : ''].filter(Boolean).join(' ') || 'developer tools'; resultsEl.innerHTML = '
Finding recommendations…
'; try { const items = await searchGitHub(query); @@ -2008,20 +2509,31 @@ function getVisibleRows() { function showQuickWins() { const HIGH_FIT = new Set(['strong', 'solid']); const wins = allRows.filter((r) => HIGH_FIT.has(r.fit?.level) && !decisionMap.has(r.repoId)); - if (!wins.length) { setStatus('No quick wins found — all strong/solid repos already have a decision.'); return; } + if (!wins.length) { + setStatus('No quick wins found — all strong/solid repos already have a decision.'); + return; + } // Apply as an NL-style filter without a real AI call: reuse the nlFilter infra with a synthetic result. - nlFilter = { question: `✦ Quick wins (${wins.length} strong/solid, undecided)`, ids: wins.map((r) => r.repoId) }; + nlFilter = { + question: `✦ Quick wins (${wins.length} strong/solid, undecided)`, + ids: wins.map((r) => r.repoId), + }; state.query = ''; state.decision = ''; renderDecisionFilter(); renderNlFilterBanner(); render(); - setStatus(`Showing ${wins.length} quick-win repo${wins.length === 1 ? '' : 's'} — strong or solid fit with no decision yet.`); + setStatus( + `Showing ${wins.length} quick-win repo${wins.length === 1 ? '' : 's'} — strong or solid fit with no decision yet.` + ); } -function exportVisible(format) { +function exportVisible(_format) { const rows = getVisibleRows(); - if (!rows.length) { setStatus('No visible repos to export.'); return; } + if (!rows.length) { + setStatus('No visible repos to export.'); + return; + } const date = new Date().toISOString().slice(0, 10); const decLabel = (repoId) => { const d = decisionMap.get(repoId); @@ -2036,16 +2548,21 @@ function exportVisible(format) { r.health ? `♥ ${r.health}` : '', r.stars >= 1 ? `${r.stars >= 1000 ? (r.stars / 1000).toFixed(1) + 'k' : r.stars}★` : '', r.languages[0]?.name || '', - ].filter(Boolean).join(' · '); + ] + .filter(Boolean) + .join(' · '); return `- **[${r.repoId}](https://github.com/${r.repoId})**${decLabel(r.repoId)}${meta ? ` — ${meta}` : ''}\n ${r.blurb ? r.blurb.slice(0, 120) : ''}${noteText(r.repoId)}`; }; const filter = [ state.query && `query: "${state.query}"`, state.capability && `capability: ${state.capability}`, - state.collection && `collection: ${collections.find((c) => c.id === state.collection)?.name || state.collection}`, + state.collection && + `collection: ${collections.find((c) => c.id === state.collection)?.name || state.collection}`, state.decision && `decision: ${state.decision}`, nlFilter?.question && `AI filter: "${nlFilter.question}"`, - ].filter(Boolean).join(', '); + ] + .filter(Boolean) + .join(', '); const md = `# RepoLens — Filtered Export (${date})\n\n_${rows.length} repos${filter ? ` · Filters: ${filter}` : ''} · Generated by RepoLens_\n\n---\n\n${rows.map(repoLine).join('\n\n')}\n`; const blob = new Blob([md], { type: 'text/markdown' }); const a = document.createElement('a'); @@ -2063,7 +2580,7 @@ async function autoOrganize() { const byLang = new Map(); for (const r of allRows) { - const lang = r.language || (r.languages?.[0]?.name); + const lang = r.language || r.languages?.[0]?.name; if (!lang) continue; if (!byLang.has(lang)) byLang.set(lang, []); byLang.get(lang).push(r.repoId); @@ -2093,7 +2610,9 @@ async function autoOrganize() { renderCollections(); render(); - setStatus(`Auto-organized: ${created} new group${created !== 1 ? 's' : ''}, ${updated} repo${updated !== 1 ? 's' : ''} assigned.`); + setStatus( + `Auto-organized: ${created} new group${created !== 1 ? 's' : ''}, ${updated} repo${updated !== 1 ? 's' : ''} assigned.` + ); } // ─── Quick-decision popover (d key) ────────────────────────────────────────── @@ -2113,20 +2632,23 @@ function showQuickDecision(repoId, anchorEl) { pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', 'Quick decision'); const choices = [ - { key: 'adopt', label: 'Adopt', color: '#22c55e' }, - { key: 'trial', label: 'Trial', color: '#3b82f6' }, - { key: 'hold', label: 'Hold', color: '#f59e0b' }, + { key: 'adopt', label: 'Adopt', color: '#22c55e' }, + { key: 'trial', label: 'Trial', color: '#3b82f6' }, + { key: 'hold', label: 'Hold', color: '#f59e0b' }, { key: 'reject', label: 'Reject', color: '#ef4444' }, ]; const veeHint = suggested ? `` : ''; - pop.innerHTML = `

${esc(repoId.replace(/^[^/]+\//, ''))}

` + + pop.innerHTML = + `

${esc(repoId.replace(/^[^/]+\//, ''))}

` + veeHint + - choices.map((c) => { - const isSuggested = suggested === c.key && current !== c.key; - return ``; - }).join('') + + choices + .map((c) => { + const isSuggested = suggested === c.key && current !== c.key; + return ``; + }) + .join('') + (current ? `` : ''); async function pick(d) { @@ -2155,12 +2677,25 @@ function showQuickDecision(repoId, anchorEl) { }); function onKey(e) { - if (e.key === 'Escape') { pop.remove(); document.removeEventListener('keydown', onKey, true); document.removeEventListener('mousedown', onOutside, true); e.stopPropagation(); } + if (e.key === 'Escape') { + pop.remove(); + document.removeEventListener('keydown', onKey, true); + document.removeEventListener('mousedown', onOutside, true); + e.stopPropagation(); + } const map = { a: 'adopt', t: 'trial', h: 'hold', r: 'reject', c: '' }; - if (e.key in map) { e.preventDefault(); e.stopPropagation(); pick(map[e.key]); } + if (e.key in map) { + e.preventDefault(); + e.stopPropagation(); + pick(map[e.key]); + } } function onOutside(e) { - if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener('keydown', onKey, true); document.removeEventListener('mousedown', onOutside, true); } + if (!pop.contains(e.target)) { + pop.remove(); + document.removeEventListener('keydown', onKey, true); + document.removeEventListener('mousedown', onOutside, true); + } } document.addEventListener('keydown', onKey, true); @@ -2168,7 +2703,8 @@ function showQuickDecision(repoId, anchorEl) { document.body.appendChild(pop); const rect = anchorEl.getBoundingClientRect(); - const pw = pop.offsetWidth || 180, ph = pop.offsetHeight || 140; + const pw = pop.offsetWidth || 180, + ph = pop.offsetHeight || 140; let left = rect.left; let top = rect.bottom + 6; if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8; @@ -2196,14 +2732,18 @@ function setJkFocus(idx) { document.addEventListener('keydown', (e) => { const t = e.target; - const inField = t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable; + const inField = + t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable; if (inField) return; if (e.key === 'j' || e.key === 'k') { e.preventDefault(); const cards = getVisibleCards(); if (!cards.length) return; - if (jkIdx === -1) { setJkFocus(0); return; } + if (jkIdx === -1) { + setJkFocus(0); + return; + } setJkFocus(jkIdx + (e.key === 'j' ? 1 : -1)); return; } @@ -2271,13 +2811,18 @@ function showEvalPanel(repoId, anchorEl) { pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', `Evaluate ${name}`); - const criteriaHtml = rubric.map((crit) => { - const score = entry.scores[crit.id] ?? 0; - const stars = [1, 2, 3, 4, 5].map((v) => - `` - ).join(''); - return `
${esc(crit.name)}${stars}
`; - }).join(''); + const criteriaHtml = rubric + .map((crit) => { + const score = entry.scores[crit.id] ?? 0; + const stars = [1, 2, 3, 4, 5] + .map( + (v) => + `` + ) + .join(''); + return `
${esc(crit.name)}${stars}
`; + }) + .join(''); const scoreAvg = computeScore(entry, rubric); @@ -2325,7 +2870,9 @@ function showEvalPanel(repoId, anchorEl) { const avgEl = pop.querySelector('.ep-avg'); if (avgEl) avgEl.textContent = newAvg !== null ? `${newAvg.toFixed(1)}/5` : ''; else if (newAvg !== null) { - pop.querySelector('.ep-header').insertAdjacentHTML('beforeend', `${newAvg.toFixed(1)}/5`); + pop + .querySelector('.ep-header') + .insertAdjacentHTML('beforeend', `${newAvg.toFixed(1)}/5`); } } }); @@ -2346,23 +2893,46 @@ function showEvalPanel(repoId, anchorEl) { }); // Dismiss on outside click - const dismiss = (e) => { if (!pop.contains(e.target) && e.target !== anchorEl) { pop.remove(); document.removeEventListener('mousedown', dismiss); } }; + const dismiss = (e) => { + if (!pop.contains(e.target) && e.target !== anchorEl) { + pop.remove(); + document.removeEventListener('mousedown', dismiss); + } + }; setTimeout(() => document.addEventListener('mousedown', dismiss), 50); // Esc key - pop.addEventListener('keydown', (e) => { if (e.key === 'Escape') { pop.remove(); document.removeEventListener('mousedown', dismiss); } }); + pop.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + pop.remove(); + document.removeEventListener('mousedown', dismiss); + } + }); } async function editRubric() { const current = rubric.map((c) => c.name).join('\n'); const input = prompt(`Rubric criteria (one per line, max 6):\n\n${current}`); if (input === null) return; - const names = input.split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 6); + const names = input + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) + .slice(0, 6); if (!names.length) return; // Preserve existing ids by name match; new names get generated ids. const newRubric = names.map((name) => { const existing = rubric.find((c) => c.name === name); - return existing ?? { id: name.toLowerCase().replace(/[^a-z0-9]/g, '_').slice(0, 20), name, weight: 1 }; + return ( + existing ?? { + id: name + .toLowerCase() + .replace(/[^a-z0-9]/g, '_') + .slice(0, 20), + name, + weight: 1, + } + ); }); rubric = newRubric; await saveRubric(rubric); @@ -2373,7 +2943,10 @@ async function editRubric() { async function applyVeeSuggestions() { const undecided = allRows.filter((r) => r.fit.level !== 'unrated' && !decisionMap.has(r.repoId)); - if (!undecided.length) { setStatus('All rated repos already have a decision.'); return; } + if (!undecided.length) { + setStatus('All rated repos already have a decision.'); + return; + } const now = new Date().toISOString(); setStatus(`Applying Vee's suggestions to ${undecided.length} repos…`); let adopted = 0; @@ -2395,7 +2968,14 @@ async function applyVeeSuggestions() { // ─── Saved filters ──────────────────────────────────────────────────────────── function currentFilterSnapshot() { - return { query: state.query, sort: state.sort, capability: state.capability, collection: state.collection, decision: state.decision, lang: state.lang }; + return { + query: state.query, + sort: state.sort, + capability: state.capability, + collection: state.collection, + decision: state.decision, + lang: state.lang, + }; } async function saveCurrentFilter(name) { @@ -2425,80 +3005,324 @@ function applySavedFilter(f) { function initLibraryPalette() { const commands = [ - { section: 'Filter by decision', name: 'Show: All decisions', action: () => { state.decision = ''; renderDecisionFilter(); render(); } }, - { name: 'Show: Adopt only', action: () => { state.decision = 'adopt'; renderDecisionFilter(); render(); } }, - { name: 'Show: Trial only', action: () => { state.decision = 'trial'; renderDecisionFilter(); render(); } }, - { name: 'Show: Hold only', action: () => { state.decision = 'hold'; renderDecisionFilter(); render(); } }, - { name: 'Show: Rejected only', action: () => { state.decision = 'reject'; renderDecisionFilter(); render(); } }, - { name: 'Show: Undecided only', action: () => { state.decision = 'undecided'; renderDecisionFilter(); render(); } }, - { name: '✦ Quick wins — strong/solid fit, no decision', description: 'Surface your easiest triage calls first', action: () => { showQuickWins(); } }, - { name: '⚠ Needs attention — risky/care, no decision', description: 'Repos with poor fit that still need a Hold or Reject decision', action: () => { - const ids = allRows.filter((r) => (r.fit.level === 'risky' || r.fit.level === 'care') && !decisionMap.has(r.repoId)).map((r) => r.repoId); - nlFilter = ids.length - ? { question: `Needs attention (${ids.length} risky/care, undecided)`, ids } - : { question: 'Needs attention', ids: [], error: 'All risky/care repos already have a decision — great triage!' }; - render(); - } }, - { name: '↕ Show: Fit changed since last scan', description: 'Repos whose fit verdict improved or regressed after a re-scan', action: () => { - const ids = allRows.filter((r) => r.fitDelta).map((r) => r.repoId); - nlFilter = ids.length - ? { question: 'Fit changed since last scan', ids } - : { question: 'Fit changed since last scan', ids: [], error: 'No fit changes yet — re-scan repos to track deltas' }; - render(); - } }, - { section: 'Sort', name: 'Sort: Best fit', action: () => { state.sort = 'fit'; document.getElementById('sort').value = 'fit'; chrome.storage.local.set({ librarySort: 'fit' }); render(); } }, - { name: 'Sort: Health', action: () => { state.sort = 'health'; document.getElementById('sort').value = 'health'; chrome.storage.local.set({ librarySort: 'health' }); render(); } }, - { name: 'Sort: Recently scanned', action: () => { state.sort = 'recent'; document.getElementById('sort').value = 'recent'; chrome.storage.local.set({ librarySort: 'recent' }); render(); } }, - { name: 'Sort: Stars', action: () => { state.sort = 'stars'; document.getElementById('sort').value = 'stars'; chrome.storage.local.set({ librarySort: 'stars' }); render(); } }, - { name: 'Sort: Name', action: () => { state.sort = 'name'; document.getElementById('sort').value = 'name'; chrome.storage.local.set({ librarySort: 'name' }); render(); } }, - { name: 'Sort: Recently decided', action: () => { state.sort = 'decided'; document.getElementById('sort').value = 'decided'; chrome.storage.local.set({ librarySort: 'decided' }); render(); } }, - { name: 'Sort: Fit changed', description: 'Repos with fit delta (improved or regressed) at the top', action: () => { state.sort = 'delta'; document.getElementById('sort').value = 'delta'; chrome.storage.local.set({ librarySort: 'delta' }); render(); } }, - { name: 'Sort: Eval score', description: 'Repos with highest evaluation score at the top', action: () => { state.sort = 'eval'; document.getElementById('sort').value = 'eval'; chrome.storage.local.set({ librarySort: 'eval' }); render(); } }, - { section: 'Evaluations', name: '▣ Evaluate focused repo', description: 'Open the scoring panel for the focused card (or press e)', action: () => { - const cards = getVisibleCards(); - if (jkIdx < 0 || !cards[jkIdx]) { setStatus('Focus a card first (j/k) then press e, or use the ▣ button on a card.'); return; } - const repoId = cards[jkIdx].dataset.repo; - showEvalPanel(repoId, cards[jkIdx].querySelector('[data-act="eval"]') || cards[jkIdx]); - } }, - { name: '▣ Edit rubric criteria', description: 'Change the scoring criteria used by the Evaluations Workbench', action: () => editRubric() }, - { name: '▣ Show: Evaluated repos only', description: 'Filter to repos with at least one eval score', action: () => { - const ids = allRows.filter((r) => evalMap.has(r.repoId)).map((r) => r.repoId); - nlFilter = ids.length - ? { question: `Evaluated (${ids.length} repos)`, ids } - : { question: 'Evaluated repos', ids: [], error: 'No evaluations yet — press e on a focused card to score a repo' }; - render(); - } }, - { section: 'View', name: 'Tech Radar', description: 'Organize repos by Adopt/Trial/Hold/Reject decision', action: () => { if (state.view !== 'radar') toggleRadarView(); } }, - { name: 'Corkboard', description: 'A red-string board of your library', action: () => { if (state.view !== 'corkboard') toggleCorkboardView(); } }, - { name: 'List view', description: 'Default card grid', action: () => { if (state.view === 'radar') toggleRadarView(); else if (state.view === 'corkboard') toggleCorkboardView(); } }, - { section: 'Pins', name: 'Unpin all', description: 'Remove all pinned repos from the top section', action: async () => { pinned.clear(); await chrome.storage.local.set({ repolens_pinned: [] }); render(); } }, - { section: 'Actions', name: 'Auto-organize by language', description: 'Group repos into language collections', action: () => autoOrganize() }, - { name: 'Re-scan all stale (30+ days)', description: 'Open Batch Scan pre-filled with repos not scanned in 30 days', action: () => refreshStale() }, - { name: '⟳ Show: Stale only', description: 'Filter to repos not scanned in 30 days', action: () => { - const ids = allRows.filter((r) => r.savedAt && (Date.now() - Date.parse(r.savedAt)) > 30 * 86_400_000).map((r) => r.repoId); - nlFilter = ids.length - ? { question: 'Stale (not scanned in 30 days)', ids } - : { question: 'Stale repos', ids: [], error: 'No stale repos — all scans are under 30 days old' }; - render(); - } }, - { name: 'Batch Scan', description: 'Scan multiple repos at once', action: () => chrome.tabs.create({ url: chrome.runtime.getURL('batch.html') }) }, - { name: 'Export visible repos (Markdown)', description: 'Download only the currently filtered repos as Markdown', action: () => exportVisible('md') }, - { name: 'Export Library (Markdown)', description: 'Download library as a readable Markdown report', action: () => exportDigest('md') }, - { name: 'Export Digest (JSON)', description: 'Download library as JSON', action: () => exportDigest('json') }, - { name: 'Export Digest (CSV)', description: 'Download library as CSV', action: () => exportDigest('csv') }, - { name: '⊞ Export Decision Matrix (CSV)', description: 'Full matrix: fit, health, decision, eval score, rubric criteria, notes', action: () => exportDecisionMatrix('csv') }, - { name: '⊞ Export Decision Matrix (Markdown)', description: 'Same as CSV but formatted as a Markdown table', action: () => exportDecisionMatrix('md') }, + { + section: 'Filter by decision', + name: 'Show: All decisions', + action: () => { + state.decision = ''; + renderDecisionFilter(); + render(); + }, + }, + { + name: 'Show: Adopt only', + action: () => { + state.decision = 'adopt'; + renderDecisionFilter(); + render(); + }, + }, + { + name: 'Show: Trial only', + action: () => { + state.decision = 'trial'; + renderDecisionFilter(); + render(); + }, + }, + { + name: 'Show: Hold only', + action: () => { + state.decision = 'hold'; + renderDecisionFilter(); + render(); + }, + }, + { + name: 'Show: Rejected only', + action: () => { + state.decision = 'reject'; + renderDecisionFilter(); + render(); + }, + }, + { + name: 'Show: Undecided only', + action: () => { + state.decision = 'undecided'; + renderDecisionFilter(); + render(); + }, + }, + { + name: '✦ Quick wins — strong/solid fit, no decision', + description: 'Surface your easiest triage calls first', + action: () => { + showQuickWins(); + }, + }, + { + name: '⚠ Needs attention — risky/care, no decision', + description: 'Repos with poor fit that still need a Hold or Reject decision', + action: () => { + const ids = allRows + .filter((r) => (r.fit.level === 'risky' || r.fit.level === 'care') && !decisionMap.has(r.repoId)) + .map((r) => r.repoId); + nlFilter = ids.length + ? { question: `Needs attention (${ids.length} risky/care, undecided)`, ids } + : { + question: 'Needs attention', + ids: [], + error: 'All risky/care repos already have a decision — great triage!', + }; + render(); + }, + }, + { + name: '↕ Show: Fit changed since last scan', + description: 'Repos whose fit verdict improved or regressed after a re-scan', + action: () => { + const ids = allRows.filter((r) => r.fitDelta).map((r) => r.repoId); + nlFilter = ids.length + ? { question: 'Fit changed since last scan', ids } + : { + question: 'Fit changed since last scan', + ids: [], + error: 'No fit changes yet — re-scan repos to track deltas', + }; + render(); + }, + }, + { + section: 'Sort', + name: 'Sort: Best fit', + action: () => { + state.sort = 'fit'; + document.getElementById('sort').value = 'fit'; + chrome.storage.local.set({ librarySort: 'fit' }); + render(); + }, + }, + { + name: 'Sort: Health', + action: () => { + state.sort = 'health'; + document.getElementById('sort').value = 'health'; + chrome.storage.local.set({ librarySort: 'health' }); + render(); + }, + }, + { + name: 'Sort: Recently scanned', + action: () => { + state.sort = 'recent'; + document.getElementById('sort').value = 'recent'; + chrome.storage.local.set({ librarySort: 'recent' }); + render(); + }, + }, + { + name: 'Sort: Stars', + action: () => { + state.sort = 'stars'; + document.getElementById('sort').value = 'stars'; + chrome.storage.local.set({ librarySort: 'stars' }); + render(); + }, + }, + { + name: 'Sort: Name', + action: () => { + state.sort = 'name'; + document.getElementById('sort').value = 'name'; + chrome.storage.local.set({ librarySort: 'name' }); + render(); + }, + }, + { + name: 'Sort: Recently decided', + action: () => { + state.sort = 'decided'; + document.getElementById('sort').value = 'decided'; + chrome.storage.local.set({ librarySort: 'decided' }); + render(); + }, + }, + { + name: 'Sort: Fit changed', + description: 'Repos with fit delta (improved or regressed) at the top', + action: () => { + state.sort = 'delta'; + document.getElementById('sort').value = 'delta'; + chrome.storage.local.set({ librarySort: 'delta' }); + render(); + }, + }, + { + name: 'Sort: Eval score', + description: 'Repos with highest evaluation score at the top', + action: () => { + state.sort = 'eval'; + document.getElementById('sort').value = 'eval'; + chrome.storage.local.set({ librarySort: 'eval' }); + render(); + }, + }, + { + section: 'Evaluations', + name: '▣ Evaluate focused repo', + description: 'Open the scoring panel for the focused card (or press e)', + action: () => { + const cards = getVisibleCards(); + if (jkIdx < 0 || !cards[jkIdx]) { + setStatus('Focus a card first (j/k) then press e, or use the ▣ button on a card.'); + return; + } + const repoId = cards[jkIdx].dataset.repo; + showEvalPanel(repoId, cards[jkIdx].querySelector('[data-act="eval"]') || cards[jkIdx]); + }, + }, + { + name: '▣ Edit rubric criteria', + description: 'Change the scoring criteria used by the Evaluations Workbench', + action: () => editRubric(), + }, + { + name: '▣ Show: Evaluated repos only', + description: 'Filter to repos with at least one eval score', + action: () => { + const ids = allRows.filter((r) => evalMap.has(r.repoId)).map((r) => r.repoId); + nlFilter = ids.length + ? { question: `Evaluated (${ids.length} repos)`, ids } + : { + question: 'Evaluated repos', + ids: [], + error: 'No evaluations yet — press e on a focused card to score a repo', + }; + render(); + }, + }, + { + section: 'View', + name: 'Tech Radar', + description: 'Organize repos by Adopt/Trial/Hold/Reject decision', + action: () => { + if (state.view !== 'radar') toggleRadarView(); + }, + }, + { + name: 'Corkboard', + description: 'A red-string board of your library', + action: () => { + if (state.view !== 'corkboard') toggleCorkboardView(); + }, + }, + { + name: 'List view', + description: 'Default card grid', + action: () => { + if (state.view === 'radar') toggleRadarView(); + else if (state.view === 'corkboard') toggleCorkboardView(); + }, + }, + { + section: 'Pins', + name: 'Unpin all', + description: 'Remove all pinned repos from the top section', + action: async () => { + pinned.clear(); + await chrome.storage.local.set({ repolens_pinned: [] }); + render(); + }, + }, + { + section: 'Actions', + name: 'Auto-organize by language', + description: 'Group repos into language collections', + action: () => autoOrganize(), + }, + { + name: 'Re-scan all stale (30+ days)', + description: 'Open Batch Scan pre-filled with repos not scanned in 30 days', + action: () => refreshStale(), + }, + { + name: '⟳ Show: Stale only', + description: 'Filter to repos not scanned in 30 days', + action: () => { + const ids = allRows + .filter((r) => r.savedAt && Date.now() - Date.parse(r.savedAt) > 30 * 86_400_000) + .map((r) => r.repoId); + nlFilter = ids.length + ? { question: 'Stale (not scanned in 30 days)', ids } + : { question: 'Stale repos', ids: [], error: 'No stale repos — all scans are under 30 days old' }; + render(); + }, + }, + { + name: 'Batch Scan', + description: 'Scan multiple repos at once', + action: () => chrome.tabs.create({ url: chrome.runtime.getURL('batch.html') }), + }, + { + name: 'Export visible repos (Markdown)', + description: 'Download only the currently filtered repos as Markdown', + action: () => exportVisible('md'), + }, + { + name: 'Export Library (Markdown)', + description: 'Download library as a readable Markdown report', + action: () => exportDigest('md'), + }, + { + name: 'Export Digest (JSON)', + description: 'Download library as JSON', + action: () => exportDigest('json'), + }, + { + name: 'Export Digest (CSV)', + description: 'Download library as CSV', + action: () => exportDigest('csv'), + }, + { + name: '⊞ Export Decision Matrix (CSV)', + description: 'Full matrix: fit, health, decision, eval score, rubric criteria, notes', + action: () => exportDecisionMatrix('csv'), + }, + { + name: '⊞ Export Decision Matrix (Markdown)', + description: 'Same as CSV but formatted as a Markdown table', + action: () => exportDecisionMatrix('md'), + }, { name: 'Export Backup', description: 'Full library backup', action: () => exportLibrary() }, - { name: '🔍 Discover repos', description: 'Search GitHub and get recommendations based on your library', action: () => openDiscovery() }, + { + name: '🔍 Discover repos', + description: 'Search GitHub and get recommendations based on your library', + action: () => openDiscovery(), + }, { name: 'Import Backup', description: 'Restore from a backup file', action: () => pickImportFile() }, - { section: 'Saved filters', name: '★ Save current filter…', description: 'Bookmark this filter combo by name', action: async () => { - const name = prompt('Name this filter:'); - if (!name?.trim()) return; - await saveCurrentFilter(name.trim()); - setStatus(`Filter saved: "${name.trim()}"`); - } }, - { name: 'Select mode', description: 'Select repos for bulk actions', action: () => setSelectionMode(!selectionMode) }, - { section: 'Vee', name: '✦ Auto-decide all undecided (Vee)', description: 'Apply Vee\'s fit-based suggestion to every undecided rated repo', action: () => applyVeeSuggestions() }, + { + section: 'Saved filters', + name: '★ Save current filter…', + description: 'Bookmark this filter combo by name', + action: async () => { + const name = prompt('Name this filter:'); + if (!name?.trim()) return; + await saveCurrentFilter(name.trim()); + setStatus(`Filter saved: "${name.trim()}"`); + }, + }, + { + name: 'Select mode', + description: 'Select repos for bulk actions', + action: () => setSelectionMode(!selectionMode), + }, + { + section: 'Vee', + name: '✦ Auto-decide all undecided (Vee)', + description: "Apply Vee's fit-based suggestion to every undecided rated repo", + action: () => applyVeeSuggestions(), + }, { name: 'Take the tour', description: 'Replay the Vee walkthrough', action: () => startIntro() }, { name: 'Open Settings', action: () => chrome.runtime.openOptionsPage() }, ]; @@ -2507,12 +3331,22 @@ function initLibraryPalette() { if (!savedFilters.length) return commands; const filterCmds = savedFilters.map((f) => ({ name: `★ ${f.name}`, - description: [f.snapshot.decision && `decision: ${f.snapshot.decision}`, f.snapshot.lang && `lang: ${f.snapshot.lang}`, f.snapshot.query && `"${f.snapshot.query}"`].filter(Boolean).join(' · ') || 'saved filter', + description: + [ + f.snapshot.decision && `decision: ${f.snapshot.decision}`, + f.snapshot.lang && `lang: ${f.snapshot.lang}`, + f.snapshot.query && `"${f.snapshot.query}"`, + ] + .filter(Boolean) + .join(' · ') || 'saved filter', action: () => applySavedFilter(f), })); const deleteCmds = savedFilters.map((f) => ({ name: `Delete saved filter: ${f.name}`, - action: async () => { await deleteSavedFilter(f.name); setStatus(`Deleted filter "${f.name}"`); }, + action: async () => { + await deleteSavedFilter(f.name); + setStatus(`Deleted filter "${f.name}"`); + }, })); // Insert saved filter commands right before the 'Save current filter…' entry const saveIdx = commands.findIndex((c) => c.name === '★ Save current filter…'); @@ -2559,8 +3393,11 @@ function runIntroTour() { copy: COPY, onExit: async () => { // Stage B picks up in the output tab (Task 7) — hand it the demo + a marker. - try { await chrome.storage.local.set({ onboardingSeen: true, onboardingStage: 'verdict' }); } - catch { /* storage best-effort */ } + try { + await chrome.storage.local.set({ onboardingSeen: true, onboardingStage: 'verdict' }); + } catch { + /* storage best-effort */ + } // openCachedAnalysis writes the demo payload into chrome.storage.session and opens // output-tab.html?key=… (the path Stage B reads). openRow would miss — the demo lives // in the IndexedDB repos/scenes stores, not the listCached() cache that backs cacheByRepo. @@ -2572,17 +3409,25 @@ function runIntroTour() { // A self-contained 3-way prompt (Show me / Maybe later / Don't ask) for the // milestone tour. Reuses the coachmark veil/card classes; removes itself on choice. function offerMilestone(realCount) { - const veil = document.createElement('div'); veil.className = 'cm-veil'; - const cardEl = document.createElement('div'); cardEl.className = 'cm-card'; + const veil = document.createElement('div'); + veil.className = 'cm-veil'; + const cardEl = document.createElement('div'); + cardEl.className = 'cm-card'; cardEl.setAttribute('role', 'dialog'); cardEl.setAttribute('aria-modal', 'true'); cardEl.setAttribute('aria-label', 'Take the milestone tour?'); - const textEl = document.createElement('p'); textEl.className = 'cm-text'; + const textEl = document.createElement('p'); + textEl.className = 'cm-text'; textEl.textContent = (COPY.milestoneOffer || '').replace('{N}', String(realCount)); - const ctl = document.createElement('div'); ctl.className = 'cm-ctl'; - const show = document.createElement('button'); show.textContent = 'Show me'; - const later = document.createElement('button'); later.textContent = 'Maybe later'; - const never = document.createElement('button'); never.textContent = "Don't ask"; never.className = 'cm-skip'; + const ctl = document.createElement('div'); + ctl.className = 'cm-ctl'; + const show = document.createElement('button'); + show.textContent = 'Show me'; + const later = document.createElement('button'); + later.textContent = 'Maybe later'; + const never = document.createElement('button'); + never.textContent = "Don't ask"; + never.className = 'cm-skip'; ctl.append(never, later, show); cardEl.append(textEl, ctl); veil.append(cardEl); @@ -2591,22 +3436,41 @@ function offerMilestone(realCount) { const focusables = [never, later, show]; // DOM/tab order const persist = (patch) => chrome.storage.local.set(patch).catch(() => {}); const onKeydown = (e) => { - if (e.key === 'Escape') { e.preventDefault(); later.click(); return; } + if (e.key === 'Escape') { + e.preventDefault(); + later.click(); + return; + } if (e.key !== 'Tab') return; // Trap Tab/Shift+Tab so focus cycles the three buttons (wrap first↔last). const first = focusables[0]; const last = focusables[focusables.length - 1]; - if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } - else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } }; const close = () => { document.removeEventListener('keydown', onKeydown); veil.remove(); if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus(); // restore focus }; - show.onclick = () => { close(); persist({ milestoneTourSeen: true }); startCoachmark({ steps: milestoneSteps(), copy: COPY }); }; - later.onclick = () => { close(); persist({ milestoneSnoozeAt10: true }); }; // Escape maps here (snooze) - never.onclick = () => { close(); persist({ milestoneTourSeen: true }); }; + show.onclick = () => { + close(); + persist({ milestoneTourSeen: true }); + startCoachmark({ steps: milestoneSteps(), copy: COPY }); + }; + later.onclick = () => { + close(); + persist({ milestoneSnoozeAt10: true }); + }; // Escape maps here (snooze) + never.onclick = () => { + close(); + persist({ milestoneTourSeen: true }); + }; document.addEventListener('keydown', onKeydown); document.body.append(veil); show.focus(); // move focus to the primary action on mount @@ -2621,21 +3485,38 @@ async function checkOnboarding() { return; } let prefs = {}; - try { prefs = await chrome.storage.local.get(['onboardingSeen', 'milestoneTourSeen', 'milestoneSnoozeAt10']); } - catch { return; } + try { + prefs = await chrome.storage.local.get(['onboardingSeen', 'milestoneTourSeen', 'milestoneSnoozeAt10']); + } catch { + return; + } const real = allRows.filter((r) => !isDemo(r)); // A returning user who never saw the intro: mark it seen silently (no demo seed). if (!prefs.onboardingSeen) { - try { await chrome.storage.local.set({ onboardingSeen: true }); } catch { /* best-effort */ } + try { + await chrome.storage.local.set({ onboardingSeen: true }); + } catch { + /* best-effort */ + } } // Snooze: "Maybe later" defers the offer until the library reaches ≥10 real repos. let snoozed = !!prefs.milestoneSnoozeAt10; if (snoozed && real.length >= 10) { snoozed = false; - try { await chrome.storage.local.set({ milestoneSnoozeAt10: false }); } catch { /* best-effort */ } + try { + await chrome.storage.local.set({ milestoneSnoozeAt10: false }); + } catch { + /* best-effort */ + } } if (snoozed) return; - if (shouldOfferMilestone({ realCount: real.length, milestoneTourSeen: prefs.milestoneTourSeen, onboardingSeen: true })) { + if ( + shouldOfferMilestone({ + realCount: real.length, + milestoneTourSeen: prefs.milestoneTourSeen, + onboardingSeen: true, + }) + ) { offerMilestone(real.length); } } @@ -2650,7 +3531,9 @@ async function init() { const [points, cachedList, prefs, savedCollections, savedDecisions, loadedMastery] = await Promise.all([ scrollPoints(), listCached().catch(() => []), - chrome.storage.local.get(['librarySort', 'mascotEnabled', 'repolens_pinned', SAVED_FILTERS_KEY]).catch(() => ({})), + chrome.storage.local + .get(['librarySort', 'mascotEnabled', 'repolens_pinned', SAVED_FILTERS_KEY]) + .catch(() => ({})), listCollections().catch(() => []), listDecisions().catch(() => []), getAllMastery(), @@ -2671,21 +3554,33 @@ async function init() { notesMap.set(k.slice('repolens_note_'.length), v.trim()); } } - } catch { /* best-effort */ } + } catch { + /* best-effort */ + } // Drift alert: show banner if background worker found stale repos - chrome.storage.local.get('repolens_drift').then(({ repolens_drift: drift }) => { - if (drift?.staleCount && !sessionStorage.getItem('drift_dismissed')) { - const banner = document.getElementById('drift-banner'); - const msg = banner?.querySelector('.drift-msg'); - if (banner && msg) { msg.textContent = `${drift.staleCount} repos haven't been scanned in 14+ days.`; banner.classList.remove('hidden'); } - } - }).catch(() => {}); + chrome.storage.local + .get('repolens_drift') + .then(({ repolens_drift: drift }) => { + if (drift?.staleCount && !sessionStorage.getItem('drift_dismissed')) { + const banner = document.getElementById('drift-banner'); + const msg = banner?.querySelector('.drift-msg'); + if (banner && msg) { + msg.textContent = `${drift.staleCount} repos haven't been scanned in 14+ days.`; + banner.classList.remove('hidden'); + } + } + }) + .catch(() => {}); if (prefs?.librarySort) state.sort = prefs.librarySort; if (prefs?.libraryDensity === 'compact') { document.getElementById('grid')?.classList.add('density-compact'); const btn = document.getElementById('density-toggle'); - if (btn) { btn.textContent = '⊞'; btn.classList.add('on'); btn.title = 'Switch to comfortable view'; } + if (btn) { + btn.textContent = '⊞'; + btn.classList.add('on'); + btn.title = 'Switch to comfortable view'; + } } const mascotOn = prefs?.mascotEnabled !== false; // default on collections = savedCollections; @@ -2698,7 +3593,9 @@ async function init() { const cacheRows = cachedList.filter((c) => c && c.repoId).map((c) => libraryRow(c)); allRows = mergeRows(savedRows, cacheRows).map((r) => { const cached = cacheByRepo.get(r.repoId); - const searchParts = [cached?.eli5, cached?.technical, (cached?.use_cases || []).join(' ')].filter(Boolean); + const searchParts = [cached?.eli5, cached?.technical, (cached?.use_cases || []).join(' ')].filter( + Boolean + ); return { ...r, hasCache: !!cached, @@ -2732,7 +3629,9 @@ async function init() { } // veeSvg() and EMPTY_GLYPH are static, code-owned strings — safe for the // STATIC-only showEmpty (no user data ever reaches innerHTML here). - const vee = mascotOn ? `` : EMPTY_GLYPH; + const vee = mascotOn + ? `` + : EMPTY_GLYPH; showEmpty( `${vee}

No repos yet

Open any GitHub / GitLab / npm / PyPI page and click the RepoLens icon —
every scan lands here automatically.

` ); @@ -2743,10 +3642,21 @@ async function init() { if (allRows.some((r) => isDemo(r))) { const { onboardingSeen } = await chrome.storage.local.get('onboardingSeen').catch(() => ({})); if (onboardingSeen && !sessionStorage.getItem(INTRO_PENDING)) { - for (const r of allRows) if (isDemo(r)) { await deleteRepo(r.repoId); await deleteSnapshots(r.repoId); } - try { await deleteScene(demoScene().id); } catch { /* scene may not exist */ } + for (const r of allRows) + if (isDemo(r)) { + await deleteRepo(r.repoId); + await deleteSnapshots(r.repoId); + } + try { + await deleteScene(demoScene().id); + } catch { + /* scene may not exist */ + } allRows = allRows.filter((r) => !isDemo(r)); - if (!allRows.length) { location.reload(); return; } // back to the clean empty state + if (!allRows.length) { + location.reload(); + return; + } // back to the clean empty state } } renderCaps(); @@ -2766,13 +3676,18 @@ async function init() { const askBtn = document.getElementById('ask-btn'); const doAsk = () => submitAsk(askInput?.value); askBtn?.addEventListener('click', doAsk); - askInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') doAsk(); }); + askInput?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doAsk(); + }); const searchEl = document.getElementById('search'); let searchTimer = null; searchEl.addEventListener('input', (e) => { state.query = e.target.value; - if (nlFilter) { nlFilter = null; renderNlFilterBanner(); } + if (nlFilter) { + nlFilter = null; + renderNlFilterBanner(); + } clearTimeout(searchTimer); searchTimer = setTimeout(render, 180); // debounce: don't re-render the whole grid on every keystroke }); @@ -2805,7 +3720,7 @@ async function init() { const docs = buildAskDocs(); try { const resp = await chrome.runtime.sendMessage({ type: 'FILTER_LIBRARY', question, docs }); - nlFilter = { question, ids: resp?.ok ? (resp.ids || []) : [], error: resp?.error }; + nlFilter = { question, ids: resp?.ok ? resp.ids || [] : [], error: resp?.error }; } catch (err) { nlFilter = { question, ids: [], error: err?.message || 'Filter failed' }; } @@ -2819,7 +3734,8 @@ async function init() { // '/' focuses search; Escape clears it when search is focused. document.addEventListener('keydown', (e) => { const t = e.target; - const inInput = t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable; + const inInput = + t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable; if (e.key === '/' && !inInput) { e.preventDefault(); searchEl.focus(); diff --git a/license-compat.js b/license-compat.js index 8463ac6..298c050 100644 --- a/license-compat.js +++ b/license-compat.js @@ -5,34 +5,52 @@ /** Canonical SPDX id → compatibility bucket. */ export const SPDX_BUCKETS = { // Permissive - 'MIT': 'permissive', 'ISC': 'permissive', 'Unlicense': 'permissive', - '0BSD': 'permissive', 'BSD-2-Clause': 'permissive', 'BSD-3-Clause': 'permissive', - 'Apache-2.0': 'permissive', 'Zlib': 'permissive', 'WTFPL': 'permissive', - 'CC0-1.0': 'permissive', 'BlueOak-1.0.0': 'permissive', + MIT: 'permissive', + ISC: 'permissive', + Unlicense: 'permissive', + '0BSD': 'permissive', + 'BSD-2-Clause': 'permissive', + 'BSD-3-Clause': 'permissive', + 'Apache-2.0': 'permissive', + Zlib: 'permissive', + WTFPL: 'permissive', + 'CC0-1.0': 'permissive', + 'BlueOak-1.0.0': 'permissive', // Weak copyleft — modifications must be shared, but you can link freely - 'LGPL-2.0-only': 'weak-copyleft', 'LGPL-2.0-or-later': 'weak-copyleft', - 'LGPL-2.1-only': 'weak-copyleft', 'LGPL-2.1-or-later': 'weak-copyleft', - 'LGPL-3.0-only': 'weak-copyleft', 'LGPL-3.0-or-later': 'weak-copyleft', - 'MPL-2.0': 'weak-copyleft', 'CDDL-1.0': 'weak-copyleft', - 'EPL-1.0': 'weak-copyleft', 'EPL-2.0': 'weak-copyleft', + 'LGPL-2.0-only': 'weak-copyleft', + 'LGPL-2.0-or-later': 'weak-copyleft', + 'LGPL-2.1-only': 'weak-copyleft', + 'LGPL-2.1-or-later': 'weak-copyleft', + 'LGPL-3.0-only': 'weak-copyleft', + 'LGPL-3.0-or-later': 'weak-copyleft', + 'MPL-2.0': 'weak-copyleft', + 'CDDL-1.0': 'weak-copyleft', + 'EPL-1.0': 'weak-copyleft', + 'EPL-2.0': 'weak-copyleft', // Strong copyleft — derivative works must use the same license - 'GPL-2.0-only': 'strong-copyleft', 'GPL-2.0-or-later': 'strong-copyleft', - 'GPL-3.0-only': 'strong-copyleft', 'GPL-3.0-or-later': 'strong-copyleft', - 'AGPL-3.0-only': 'strong-copyleft', 'AGPL-3.0-or-later': 'strong-copyleft', + 'GPL-2.0-only': 'strong-copyleft', + 'GPL-2.0-or-later': 'strong-copyleft', + 'GPL-3.0-only': 'strong-copyleft', + 'GPL-3.0-or-later': 'strong-copyleft', + 'AGPL-3.0-only': 'strong-copyleft', + 'AGPL-3.0-or-later': 'strong-copyleft', }; // Short aliases the GitHub API commonly returns (not always valid SPDX) const ALIASES = { - 'GPL-2.0': 'GPL-2.0-only', 'GPL-3.0': 'GPL-3.0-only', - 'LGPL-2.0': 'LGPL-2.0-only', 'LGPL-2.1': 'LGPL-2.1-only', 'LGPL-3.0': 'LGPL-3.0-only', + 'GPL-2.0': 'GPL-2.0-only', + 'GPL-3.0': 'GPL-3.0-only', + 'LGPL-2.0': 'LGPL-2.0-only', + 'LGPL-2.1': 'LGPL-2.1-only', + 'LGPL-3.0': 'LGPL-3.0-only', 'AGPL-3.0': 'AGPL-3.0-only', }; const BUCKET_LABELS = { - 'permissive': 'Permissive', + permissive: 'Permissive', 'weak-copyleft': 'Weak Copyleft', 'strong-copyleft': 'Strong Copyleft', - 'unknown': 'Unknown', + unknown: 'Unknown', }; /** @@ -72,23 +90,41 @@ export function checkPairCompat(licenseA, licenseB) { return { status: 'ok', note: 'Weak copyleft + permissive — OK to link; modified files must be shared.' }; } if (bA === 'permissive' && bB === 'strong-copyleft') { - return { status: 'conflict', note: 'Permissive + strong copyleft — distribution of proprietary code alongside this is restricted. Review use-case carefully.' }; + return { + status: 'conflict', + note: 'Permissive + strong copyleft — distribution of proprietary code alongside this is restricted. Review use-case carefully.', + }; } if (bA === 'strong-copyleft' && bB === 'permissive') { - return { status: 'conflict', note: 'Strong copyleft + permissive — the strong-copyleft license may require your entire project to be open-sourced on distribution.' }; + return { + status: 'conflict', + note: 'Strong copyleft + permissive — the strong-copyleft license may require your entire project to be open-sourced on distribution.', + }; } if (bA === 'weak-copyleft' && bB === 'weak-copyleft') { - return { status: 'warn', note: 'Both weak copyleft — usually compatible, but verify the specific licenses allow combination.' }; + return { + status: 'warn', + note: 'Both weak copyleft — usually compatible, but verify the specific licenses allow combination.', + }; } - if ((bA === 'weak-copyleft' && bB === 'strong-copyleft') || (bA === 'strong-copyleft' && bB === 'weak-copyleft')) { - return { status: 'warn', note: 'Weak + strong copyleft — the strong-copyleft license may pull the weak-copyleft code under its terms. Legal review recommended.' }; + if ( + (bA === 'weak-copyleft' && bB === 'strong-copyleft') || + (bA === 'strong-copyleft' && bB === 'weak-copyleft') + ) { + return { + status: 'warn', + note: 'Weak + strong copyleft — the strong-copyleft license may pull the weak-copyleft code under its terms. Legal review recommended.', + }; } if (bA === 'strong-copyleft' && bB === 'strong-copyleft') { // Same license = OK; different = potentially incompatible const a = (ALIASES[licenseA] || licenseA).replace(/-only$/, '').replace(/-or-later$/, ''); const b = (ALIASES[licenseB] || licenseB).replace(/-only$/, '').replace(/-or-later$/, ''); if (a === b) return { status: 'ok', note: 'Same strong-copyleft license family — compatible.' }; - return { status: 'conflict', note: 'Two different strong-copyleft licenses — typically incompatible. Legal review required.' }; + return { + status: 'conflict', + note: 'Two different strong-copyleft licenses — typically incompatible. Legal review required.', + }; } return { status: 'warn', note: 'Unable to determine compatibility automatically — review manually.' }; } @@ -101,7 +137,7 @@ export function checkPairCompat(licenseA, licenseB) { */ export function checkLibraryCompat(currentLicense, libraryRepos) { const currentBucket = bucketFor(currentLicense); - const repos = (libraryRepos || []).filter(r => r && r.repoId && r.license && r.license !== 'Unknown'); + const repos = (libraryRepos || []).filter((r) => r && r.repoId && r.license && r.license !== 'Unknown'); const bucketCounts = { permissive: 0, 'weak-copyleft': 0, 'strong-copyleft': 0, unknown: 0 }; const concerns = []; @@ -115,8 +151,8 @@ export function checkLibraryCompat(currentLicense, libraryRepos) { } } - const conflicts = concerns.filter(c => c.status === 'conflict').length; - const warns = concerns.filter(c => c.status === 'warn').length; + const conflicts = concerns.filter((c) => c.status === 'conflict').length; + const warns = concerns.filter((c) => c.status === 'warn').length; let summary; if (!repos.length) { diff --git a/maintenance.js b/maintenance.js index 97ec136..3adf1e7 100644 --- a/maintenance.js +++ b/maintenance.js @@ -45,7 +45,10 @@ function formatSignals(signals, today) { const top1 = signals.topContributors[0]; const top1Pct = total ? Math.round((top1.contributions / total) * 100) : 0; lines.push(`Top contributor share: ${top1.login} — ${top1.contributions} commits (${top1Pct}% of top-5)`); - const others = signals.topContributors.slice(1).map(c => c.login).join(', '); + const others = signals.topContributors + .slice(1) + .map((c) => c.login) + .join(', '); if (others) lines.push(`Other top contributors: ${others}`); } else { lines.push('Contributor data: unavailable'); @@ -57,17 +60,22 @@ function formatSignals(signals, today) { /** Detect CI and project-health signals from the file tree. */ export function ciSignals(tree) { if (!Array.isArray(tree) || !tree.length) return 'No file tree available.'; - const has = (p) => tree.some(f => String(f).toLowerCase().includes(p)); + const has = (p) => tree.some((f) => String(f).toLowerCase().includes(p)); return [ ['GitHub Actions (.github/workflows)', has('.github/workflows')], ['CircleCI (.circleci)', has('.circleci')], ['Travis CI (.travis.yml)', has('.travis')], ['Jenkins (Jenkinsfile)', has('jenkinsfile')], - ['Test files (test/ or tests/ or *.test.* or *.spec.*)', has('test/') || has('tests/') || has('.test.') || has('.spec.')], + [ + 'Test files (test/ or tests/ or *.test.* or *.spec.*)', + has('test/') || has('tests/') || has('.test.') || has('.spec.'), + ], ['CONTRIBUTING guide', has('contributing')], ['SECURITY policy', has('security')], ['CHANGELOG', has('changelog') || has('changes.md') || has('history.md')], - ].map(([label, present]) => `${present ? '✓' : '✗'} ${label}`).join('\n'); + ] + .map(([label, present]) => `${present ? '✓' : '✗'} ${label}`) + .join('\n'); } /** diff --git a/manifest.json b/manifest.json index 5fca507..a44a040 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,19 @@ { "manifest_version": 3, "name": "RepoLens", - "version": "3.0.1", + "version": "3.1.0", "description": "Click any repo. Get a straight answer on whether to use it.", "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, - "permissions": ["storage", "activeTab", "tabs", "identity", "webNavigation", "notifications", "contextMenus", "alarms"], + "permissions": [ + "storage", + "activeTab", + "tabs", + "identity", + "webNavigation", + "notifications", + "contextMenus", + "alarms" + ], "host_permissions": [ "https://github.com/*", "https://gitlab.com/*", @@ -14,6 +23,8 @@ "https://registry.npmjs.org/*", "https://pypi.org/pypi/*", "https://api.anthropic.com/*", + "https://claude.ai/*", + "https://console.anthropic.com/*", "https://generativelanguage.googleapis.com/*", "https://openrouter.ai/*", "https://api.x.ai/*", @@ -39,10 +50,7 @@ "http://localhost/*", "http://127.0.0.1/*" ], - "optional_host_permissions": [ - "https://*/*", - "http://*/*" - ], + "optional_host_permissions": ["https://*/*", "http://*/*"], "background": { "service_worker": "background.js", "type": "module" @@ -68,9 +76,7 @@ "128": "icons/icon128.png" } }, - "web_accessible_resources": [ - { "resources": ["whats-new.html"], "matches": [""] } - ], + "web_accessible_resources": [{ "resources": ["whats-new.html"], "matches": [""] }], "options_page": "options.html", "icons": { "16": "icons/icon16.png", diff --git a/mascot-preview.html b/mascot-preview.html index 2746284..9d51e94 100644 --- a/mascot-preview.html +++ b/mascot-preview.html @@ -1,171 +1,484 @@ - + - - - -RepoLens — "Vee" mascot preview - - - - -
-
-
-

Meet “Vee”

-

A proposed mascot for RepoLens — it is the lens, not a sticker on top. One token-aware SVG; every expression is a CSS class swap; reduced-motion gets a static glyph. Switch themes to see it re-skin for free.

-
-
-
- -
Expressions — mapped to real app moments
-
- -
In context
-
-
-
Scan loading
- -
facebook/react
-
Reading the source. This is the boring, important part.
-
+ .vee.is-strong .vee-aperture { + r: 12; + stroke: var(--ok); + } + .vee.is-strong .vee-pupil { + fill: var(--ok); + } + .vee.is-risky .vee-aperture { + r: 4.5; + stroke-width: 3.4; + stroke: var(--bad); + } + .vee.is-risky .vee-pupil { + fill: var(--bad); + } + .vee.is-risky svg { + transform: rotate(-8deg); + } + .vee.is-scanning .vee-aperture { + r: 8.5; + } + .vee.is-thinking .vee-aperture { + r: 9; + } + .vee.is-empty .vee-aperture { + r: 5; + opacity: 0.7; + } + .vee.is-empty .vee-pupil { + opacity: 0.7; + } + .vee.is-empty svg { + transform: translateY(1px); + } + .vee.is-error svg { + transform: rotate(5deg); + } + .vee.is-error .vee-aperture { + r: 7; + } -
-
Verdict — strong fit
-
- - STRONG - Battle-tested, well-documented, actively maintained. -
-
“Looked hard. Couldn’t find the catch. Rare.”
-
+ @media (prefers-reduced-motion: no-preference) { + .vee .vee-aperture, + .vee .vee-pupil { + transition: + r var(--dur) var(--ease-out), + stroke var(--dur) var(--ease-out), + fill var(--dur) var(--ease-out), + stroke-width var(--dur) var(--ease-out), + opacity var(--dur) var(--ease-out); + } + .vee svg { + transition: transform var(--dur) var(--ease-out); + } -
-
Verdict — risky fit
-
- - RISKY - One maintainer, no tests, last push 14 months ago. -
-
“Eyes narrowed. The README and the code disagree.”
-
+ .vee.is-strong .vee-aperture { + animation: vee-pop var(--dur-slow) var(--ease-spring); + } + .vee.is-risky svg { + animation: vee-squint var(--dur) var(--ease-out); + } + .vee.is-scanning .vee-aperture { + animation: vee-breathe 2.4s var(--ease-out) infinite; + } + .vee.is-scanning .vee-ticks { + animation: vee-rotate 9s linear infinite; + } + .vee.is-thinking .vee-pupil { + animation: vee-drift 2.2s var(--ease-out) infinite; + } + .vee.is-empty .vee-aperture { + animation: vee-rest 4.5s var(--ease-out) infinite; + } + .vee.is-error svg { + animation: vee-shake 360ms var(--ease-out); + } + } + @keyframes vee-pop { + 0% { + r: 9; + } + 60% { + r: 13; + } + 100% { + r: 12; + } + } + @keyframes vee-squint { + 0% { + transform: rotate(0); + } + 100% { + transform: rotate(-8deg); + } + } + @keyframes vee-breathe { + 0%, + 100% { + r: 6; + } + 50% { + r: 11; + } + } + @keyframes vee-rotate { + to { + transform: rotate(360deg); + } + } + @keyframes vee-drift { + 0%, + 100% { + transform: translateX(-1.6px); + } + 50% { + transform: translateX(1.6px); + } + } + @keyframes vee-rest { + 0%, + 100% { + r: 5; + } + 50% { + r: 5.8; + } + } + @keyframes vee-shake { + 0% { + transform: rotate(5deg) translateX(0); + } + 25% { + transform: rotate(5deg) translateX(-1.5px); + } + 75% { + transform: rotate(5deg) translateX(1.5px); + } + 100% { + transform: rotate(5deg) translateX(0); + } + } + + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; + } + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + } + + + +
+
+
+

Meet “Vee”

+

+ A proposed mascot for RepoLens — it is the lens, not a sticker on top. One token-aware SVG; + every expression is a CSS class swap; reduced-motion gets a static glyph. Switch themes to see it + re-skin for free. +

+
+
+
+ +
Expressions — mapped to real app moments
+
+ +
In context
+
+
+
Scan loading
+ +
facebook/react
+
Reading the source. This is the boring, important part.
+
+ +
+
Verdict — strong fit
+
+ + STRONG + Battle-tested, well-documented, actively maintained. +
+
“Looked hard. Couldn’t find the catch. Rare.”
+
-
-
Empty library
-
- -
Nothing scanned yet.
Point me at a repo.
+
+
Verdict — risky fit
+
+ + RISKY + One maintainer, no tests, last push 14 months ago. +
+
“Eyes narrowed. The README and the code disagree.”
+
+ +
+
Empty library
+
+ +
+ Nothing scanned yet.
Point me at a repo. +
+
+
Vee rests when there’s nothing to look at. It reacts; it never interrupts.
+
-
Vee rests when there’s nothing to look at. It reacts; it never interrupts.
-
-
-

- Guardrails: every Vee is aria-hidden — meaning is always carried by the real chip text and color, never the mascot. Red is borrowed only for the verdict (strong→green, risky→red); a UI error gets a sheepish tilt, not red. All motion lives behind prefers-reduced-motion: no-preference; the only loop (scanning breath) ends the moment the verdict lands. A Settings toggle (mascotEnabled, default on) turns it off entirely. Not yet wired into the extension — this page is a proposal you can eyeball first. -

-
+

+ Guardrails: every Vee is aria-hidden — meaning is always carried by the real chip + text and color, never the mascot. Red is borrowed only for the verdict (strong→green, + risky→red); a UI error gets a sheepish tilt, not red. All motion lives behind + prefers-reduced-motion: no-preference; the only loop (scanning breath) ends the moment + the verdict lands. A Settings toggle (mascotEnabled, default on) turns it off entirely. + Not yet wired into the extension — this page is a proposal you can eyeball first. +

+
- - +
` + ).join(''); + + // ── Theme picker (all 13 real themes from themes.css) ──────────────── + const THEMES = [ + 'midnight', + 'paper', + 'terminal', + 'nord', + 'gruvbox', + 'rosepine', + 'latte', + 'solarized', + 'synthwave', + 'claude', + 'xai', + 'apple', + 'bmw', + ]; + const bar = document.getElementById('theme-bar'); + for (const t of THEMES) { + const b = document.createElement('button'); + b.className = 'tb' + (t === 'midnight' ? ' active' : ''); + b.textContent = t; + b.onclick = () => { + document.documentElement.dataset.theme = t; + bar.querySelectorAll('.tb').forEach((x) => x.classList.toggle('active', x === b)); + }; + bar.appendChild(b); + } + + diff --git a/mascot.css b/mascot.css index 48e1cc4..0acd073 100644 --- a/mascot.css +++ b/mascot.css @@ -3,47 +3,164 @@ a class swap. Static shapes live outside the motion guard so reduced-motion users still get a correct, distinct pose for each state — it just snaps. */ -.vee { display: inline-flex; color: var(--text); line-height: 0; } -.vee svg { display: block; overflow: visible; } -.vee .vee-aperture, .vee .vee-pupil, .vee .vee-ticks, .vee svg { - transform-box: fill-box; transform-origin: center; +.vee { + display: inline-flex; + color: var(--text); + line-height: 0; +} +.vee svg { + display: block; + overflow: visible; +} +.vee .vee-aperture, +.vee .vee-pupil, +.vee .vee-ticks, +.vee svg { + transform-box: fill-box; + transform-origin: center; } /* idle = the markup defaults (r=9, --accent). Nothing to add. */ -.vee.is-strong .vee-aperture { r: 12; stroke: var(--ok); } -.vee.is-strong .vee-pupil { fill: var(--ok); } -.vee.is-risky .vee-aperture { r: 4.5; stroke-width: 3.4; stroke: var(--bad); } -.vee.is-risky .vee-pupil { fill: var(--bad); } -.vee.is-risky svg { transform: rotate(-8deg); } -.vee.is-scanning .vee-aperture { r: 8.5; } -.vee.is-thinking .vee-aperture { r: 9; } -.vee.is-empty .vee-aperture { r: 5; opacity: 0.7; } -.vee.is-empty .vee-pupil { opacity: 0.7; } -.vee.is-empty svg { transform: translateY(1px); } -.vee.is-error svg { transform: rotate(5deg); } -.vee.is-error .vee-aperture { r: 7; } +.vee.is-strong .vee-aperture { + r: 12; + stroke: var(--ok); +} +.vee.is-strong .vee-pupil { + fill: var(--ok); +} +.vee.is-risky .vee-aperture { + r: 4.5; + stroke-width: 3.4; + stroke: var(--bad); +} +.vee.is-risky .vee-pupil { + fill: var(--bad); +} +.vee.is-risky svg { + transform: rotate(-8deg); +} +.vee.is-scanning .vee-aperture { + r: 8.5; +} +.vee.is-thinking .vee-aperture { + r: 9; +} +.vee.is-empty .vee-aperture { + r: 5; + opacity: 0.7; +} +.vee.is-empty .vee-pupil { + opacity: 0.7; +} +.vee.is-empty svg { + transform: translateY(1px); +} +.vee.is-error svg { + transform: rotate(5deg); +} +.vee.is-error .vee-aperture { + r: 7; +} @media (prefers-reduced-motion: no-preference) { - .vee .vee-aperture, .vee .vee-pupil { - transition: r var(--dur) var(--ease-out), stroke var(--dur) var(--ease-out), - fill var(--dur) var(--ease-out), stroke-width var(--dur) var(--ease-out), - opacity var(--dur) var(--ease-out); + .vee .vee-aperture, + .vee .vee-pupil { + transition: + r var(--dur) var(--ease-out), + stroke var(--dur) var(--ease-out), + fill var(--dur) var(--ease-out), + stroke-width var(--dur) var(--ease-out), + opacity var(--dur) var(--ease-out); + } + .vee svg { + transition: transform var(--dur) var(--ease-out); } - .vee svg { transition: transform var(--dur) var(--ease-out); } - .vee.is-strong .vee-aperture { animation: vee-pop var(--dur-slow) var(--ease-spring); } - .vee.is-risky svg { animation: vee-squint var(--dur) var(--ease-out); } - .vee.is-scanning .vee-aperture { animation: vee-breathe 2.4s var(--ease-out) infinite; } - .vee.is-scanning .vee-ticks { animation: vee-rotate 9s linear infinite; } - .vee.is-thinking .vee-pupil { animation: vee-drift 2.2s var(--ease-out) infinite; } - .vee.is-empty .vee-aperture { animation: vee-rest 4.5s var(--ease-out) infinite; } - .vee.is-error svg { animation: vee-shake 360ms var(--ease-out); } + .vee.is-strong .vee-aperture { + animation: vee-pop var(--dur-slow) var(--ease-spring); + } + .vee.is-risky svg { + animation: vee-squint var(--dur) var(--ease-out); + } + .vee.is-scanning .vee-aperture { + animation: vee-breathe 2.4s var(--ease-out) infinite; + } + .vee.is-scanning .vee-ticks { + animation: vee-rotate 9s linear infinite; + } + .vee.is-thinking .vee-pupil { + animation: vee-drift 2.2s var(--ease-out) infinite; + } + .vee.is-empty .vee-aperture { + animation: vee-rest 4.5s var(--ease-out) infinite; + } + .vee.is-error svg { + animation: vee-shake 360ms var(--ease-out); + } } -@keyframes vee-pop { 0% { r: 9; } 60% { r: 13; } 100% { r: 12; } } -@keyframes vee-squint { 0% { transform: rotate(0); } 100% { transform: rotate(-8deg); } } -@keyframes vee-breathe{ 0%, 100% { r: 6; } 50% { r: 11; } } -@keyframes vee-rotate { to { transform: rotate(360deg); } } -@keyframes vee-drift { 0%, 100% { transform: translateX(-1.6px); } 50% { transform: translateX(1.6px); } } -@keyframes vee-rest { 0%, 100% { r: 5; } 50% { r: 5.8; } } -@keyframes vee-shake { 0% { transform: rotate(5deg) translateX(0); } 25% { transform: rotate(5deg) translateX(-1.5px); } 75% { transform: rotate(5deg) translateX(1.5px); } 100% { transform: rotate(5deg) translateX(0); } } +@keyframes vee-pop { + 0% { + r: 9; + } + 60% { + r: 13; + } + 100% { + r: 12; + } +} +@keyframes vee-squint { + 0% { + transform: rotate(0); + } + 100% { + transform: rotate(-8deg); + } +} +@keyframes vee-breathe { + 0%, + 100% { + r: 6; + } + 50% { + r: 11; + } +} +@keyframes vee-rotate { + to { + transform: rotate(360deg); + } +} +@keyframes vee-drift { + 0%, + 100% { + transform: translateX(-1.6px); + } + 50% { + transform: translateX(1.6px); + } +} +@keyframes vee-rest { + 0%, + 100% { + r: 5; + } + 50% { + r: 5.8; + } +} +@keyframes vee-shake { + 0% { + transform: rotate(5deg) translateX(0); + } + 25% { + transform: rotate(5deg) translateX(-1.5px); + } + 75% { + transform: rotate(5deg) translateX(1.5px); + } + 100% { + transform: rotate(5deg) translateX(0); + } +} diff --git a/mascot.js b/mascot.js index 2117a45..da9e381 100644 --- a/mascot.js +++ b/mascot.js @@ -7,7 +7,13 @@ // it works identically in the extension and in mascot-preview.html. export const VEE_STATES = Object.freeze([ - 'idle', 'scanning', 'strong', 'risky', 'thinking', 'empty', 'error', + 'idle', + 'scanning', + 'strong', + 'risky', + 'thinking', + 'empty', + 'error', ]); // Verdict fit value → Vee state. Only the two extremes earn a distinct face; diff --git a/mastery.js b/mastery.js index b514a88..e69345b 100644 --- a/mastery.js +++ b/mastery.js @@ -35,16 +35,35 @@ export function deriveCheckResult(questions, ratings) { const rs = Array.isArray(ratings) ? ratings : []; const total = qs.length; if (total === 0) { - return { level: MASTERY_LEVELS.NEW, score: 0, gotIt: 0, shaky: 0, missed: 0, total: 0, glows: [], grows: [] }; + return { + level: MASTERY_LEVELS.NEW, + score: 0, + gotIt: 0, + shaky: 0, + missed: 0, + total: 0, + glows: [], + grows: [], + }; } - let gotIt = 0, shaky = 0, missed = 0; - const glows = [], grows = []; + let gotIt = 0, + shaky = 0, + missed = 0; + const glows = [], + grows = []; qs.forEach((q, i) => { const text = (q && q.q) || ''; const r = rs[i]; - if (r === 'gotIt') { gotIt++; glows.push(text); } - else if (r === 'shaky') { shaky++; grows.push(text); } - else { missed++; grows.push(text); } + if (r === 'gotIt') { + gotIt++; + glows.push(text); + } else if (r === 'shaky') { + shaky++; + grows.push(text); + } else { + missed++; + grows.push(text); + } }); const score = gotIt / total; const level = score >= UNDERSTOOD_THRESHOLD ? MASTERY_LEVELS.UNDERSTOOD : MASTERY_LEVELS.EXPLORED; diff --git a/mcp/README.md b/mcp/README.md index f8b0e70..fa6898f 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -52,9 +52,20 @@ component. Heavier than `scan_repo`: it reads source and makes two model calls ```json { - "id": "repo:...", "scope": "blueprint", "repoId": "honojs/hono", - "nodes": [{ "id": "app", "label": "Hono app", "kind": "entrypoint", "x": 120, "y": 40, - "layer": "entrypoint", "ref": { "root": true, "purpose": "...", "files": ["src/hono.ts"] } }], + "id": "repo:...", + "scope": "blueprint", + "repoId": "honojs/hono", + "nodes": [ + { + "id": "app", + "label": "Hono app", + "kind": "entrypoint", + "x": 120, + "y": 40, + "layer": "entrypoint", + "ref": { "root": true, "purpose": "...", "files": ["src/hono.ts"] } + } + ], "edges": [{ "id": "e123", "from": "app", "to": "router", "rel": "depends-on" }], "camera": { "x": 0, "y": 0, "zoom": 1 } } @@ -78,7 +89,11 @@ self-test `questions`, per-claim `confidence`, plus the underlying `atoms` and "questions": [{ "q": "What runs a request?", "a": "..." }], "confidence": [{ "claim": "...", "level": "high", "note": "..." }], "atoms": [{ "id": "router", "name": "Router", "kind": "subsystem", "purpose": "..." }], - "lineage": { "links": [{ "from": "app", "to": "router", "relation": "depends-on" }], "roots": ["app"], "leaves": [] } + "lineage": { + "links": [{ "from": "app", "to": "router", "relation": "depends-on" }], + "roots": ["app"], + "leaves": [] + } } ``` diff --git a/mcp/anthropic.js b/mcp/anthropic.js index 478152e..4888dce 100644 --- a/mcp/anthropic.js +++ b/mcp/anthropic.js @@ -52,7 +52,10 @@ export async function callAnthropic(prompt) { } const data = await res.json(); - const text = (data.content || []).map((b) => b.text || '').join('').trim(); + const text = (data.content || []) + .map((b) => b.text || '') + .join('') + .trim(); if (!text) throw new Error('Anthropic returned an empty response'); return text; } diff --git a/mcp/deep-dive.js b/mcp/deep-dive.js index d88712d..d243977 100644 --- a/mcp/deep-dive.js +++ b/mcp/deep-dive.js @@ -10,9 +10,12 @@ import { fetchRepoData } from '../fetcher.js'; import { fetchSource, - buildAtomsPrompt, parseAtoms, - buildLineagePrompt, parseLineage, - buildFeynmanPrompt, parseFeynman, + buildAtomsPrompt, + parseAtoms, + buildLineagePrompt, + parseLineage, + buildFeynmanPrompt, + parseFeynman, } from '../deepdive.js'; import { parseRepoInput } from './repo-input.js'; import { callAnthropic } from './anthropic.js'; @@ -21,7 +24,7 @@ import { ghOpts } from './github-auth.js'; export const DEEP_DIVE_TOOL = { name: 'deep_dive', description: - "Explain how a GitHub repo actually works, in plain language, with its weak spots named. " + + 'Explain how a GitHub repo actually works, in plain language, with its weak spots named. ' + 'Returns a from-scratch explanation, the gaps/assumptions behind it, self-test questions, ' + 'per-claim confidence, and the underlying atoms + causal lineage. Use this when the user ' + 'wants to *understand* a codebase, not just judge it. Heaviest tool (reads source, three model calls).', @@ -38,7 +41,11 @@ export const DEEP_DIVE_TOOL = { degraded: { type: 'boolean', description: 'true when no source tree was available (README-only read)' }, explanation: { type: 'string', description: 'Plain-language explanation from scratch.' }, gaps: { type: 'array', items: { type: 'string' }, description: 'Where the explanation is weakest.' }, - assumptions: { type: 'array', items: { type: 'string' }, description: 'Inferred, not directly verified.' }, + assumptions: { + type: 'array', + items: { type: 'string' }, + description: 'Inferred, not directly verified.', + }, questions: { type: 'array', items: { diff --git a/mcp/package.json b/mcp/package.json index a9c2c2f..a2fe473 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -4,11 +4,15 @@ "private": true, "type": "module", "description": "Local MCP server exposing RepoLens repo analysis (scan_repo) as a tool.", - "bin": { "repolens-mcp": "./server.js" }, + "bin": { + "repolens-mcp": "./server.js" + }, "scripts": { "start": "node server.js" }, - "engines": { "node": ">=18" }, + "engines": { + "node": ">=18" + }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" } diff --git a/mcp/server.js b/mcp/server.js index dbb182c..6d57cd8 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -22,10 +22,7 @@ const TOOLS = { [DEEP_DIVE_TOOL.name]: { def: DEEP_DIVE_TOOL, run: runDeepDive }, }; -const server = new Server( - { name: 'repolens', version: '0.1.0' }, - { capabilities: { tools: {} } }, -); +const server = new Server({ name: 'repolens', version: '0.1.0' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: Object.values(TOOLS).map((t) => t.def), diff --git a/models.js b/models.js index eff30d3..ed75b5d 100644 --- a/models.js +++ b/models.js @@ -30,25 +30,30 @@ export const CATALOG = { google: { label: 'Gemini', models: [ - { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', recommended: true }, - { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash — fast' }, - { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, + { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview', recommended: true }, + { value: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash — fast' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, ], }, nous: { label: 'Nous', models: [ - { value: 'Hermes-4-405B', label: 'Hermes 4 405B — flagship', recommended: true }, - { value: 'stepfun/step-3.7-flash', label: 'Step 3.7 Flash — free 30d' }, - { value: 'Hermes-4-70B', label: 'Hermes 4 70B — faster' }, + { value: 'stepfun/step-3.7-flash', label: 'StepFun: Step 3.7 Flash', recommended: true }, + { value: 'nousresearch/hermes-4-405b', label: 'Nous: Hermes 4 405B' }, + { value: 'nousresearch/hermes-4-70b', label: 'Nous: Hermes 4 70B' }, + { value: 'anthropic/claude-opus-4.8', label: 'Anthropic: Claude Opus 4.8' }, ], }, openrouter: { label: 'OpenRouter', models: [ - { value: 'x-ai/grok-4.3', label: 'Grok 4.3', recommended: true }, - { value: 'anthropic/claude-opus-4-8', label: 'Claude Opus 4.8' }, - { value: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'x-ai/grok-4.3', label: 'xAI: Grok 4.3', recommended: true }, + { value: 'x-ai/grok-4.20', label: 'xAI: Grok 4.20' }, + { value: 'anthropic/claude-opus-4.8', label: 'Anthropic: Claude Opus 4.8' }, + { value: 'anthropic/claude-sonnet-4.6', label: 'Anthropic: Claude Sonnet 4.6' }, + { value: 'google/gemini-2.5-pro', label: 'Google: Gemini 2.5 Pro' }, + { value: 'google/gemini-2.5-flash', label: 'Google: Gemini 2.5 Flash' }, ], }, xai: { @@ -59,3 +64,35 @@ export const CATALOG = { ], }, }; + +// Backward-compatible cleanup for model ids that old RepoLens builds displayed/saved +// with provider aliases or pre-release spellings. The APIs still accept some aliases, +// but saving canonical ids keeps the UI aligned with /v1/models and avoids failures +// for providers that do not resolve the old form. +export const MODEL_ALIASES = { + google: { + 'models/gemini-3.1-pro-preview': 'gemini-3.1-pro-preview', + 'models/gemini-3.5-flash': 'gemini-3.5-flash', + 'models/gemini-2.5-pro': 'gemini-2.5-pro', + 'models/gemini-2.5-flash': 'gemini-2.5-flash', + }, + nous: { + 'Step-3.7-Flash': 'stepfun/step-3.7-flash', + 'step-3.7-flash': 'stepfun/step-3.7-flash', + 'Hermes-4-405B': 'nousresearch/hermes-4-405b', + 'NousResearch/Hermes-4-405B': 'nousresearch/hermes-4-405b', + 'Hermes-4-70B': 'nousresearch/hermes-4-70b', + 'NousResearch/Hermes-4-70B': 'nousresearch/hermes-4-70b', + 'anthropic/claude-opus-4-8': 'anthropic/claude-opus-4.8', + }, + openrouter: { + 'anthropic/claude-opus-4-8': 'anthropic/claude-opus-4.8', + 'anthropic/claude-sonnet-4-6': 'anthropic/claude-sonnet-4.6', + 'anthropic/claude-haiku-4-5-20251001': 'anthropic/claude-haiku-4.5', + }, +}; + +export function canonicalModel(provider, model) { + const value = (model || '').trim(); + return MODEL_ALIASES[provider]?.[value] || value; +} diff --git a/oauth-anthropic.js b/oauth-anthropic.js new file mode 100644 index 0000000..61cb5dd --- /dev/null +++ b/oauth-anthropic.js @@ -0,0 +1,156 @@ +// Anthropic subscription OAuth (Claude Pro/Max), adapted from the Claude Code / pi flow. +// This is separate from Console API keys: OAuth tokens are bearer tokens and require +// Anthropic's Claude Code beta headers when calling the Messages API. + +import { base64url } from './oauth-pkce.js'; + +const decode = (s) => atob(s); +export const ANTHROPIC_CLIENT_ID = decode('OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl'); +export const ANTHROPIC_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'; +export const ANTHROPIC_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token'; +export const ANTHROPIC_REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback'; +export const ANTHROPIC_SCOPES = 'org:create_api_key user:profile user:inference'; + +export const ANTHROPIC_ACCESS_KEY = 'anthropicAccess'; +export const ANTHROPIC_REFRESH_KEY = 'anthropicRefresh'; +export const ANTHROPIC_EXPIRY_KEY = 'anthropicExpiry'; +export const ANTHROPIC_OAUTH_VERIFIER_KEY = 'anthropicOAuthVerifier'; + +export async function createAnthropicPkcePair() { + const verifierArr = new Uint8Array(32); + crypto.getRandomValues(verifierArr); + const verifier = base64url(verifierArr); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return { verifier, challenge: base64url(new Uint8Array(digest)) }; +} + +export function buildAnthropicAuthorizeUrl({ verifier, challenge }) { + const params = new URLSearchParams({ + code: 'true', + client_id: ANTHROPIC_CLIENT_ID, + response_type: 'code', + redirect_uri: ANTHROPIC_REDIRECT_URI, + scope: ANTHROPIC_SCOPES, + code_challenge: challenge, + code_challenge_method: 'S256', + // Claude's CLI flow echoes the verifier back as the state value. The callback + // page shows users a pasteable `code#state` string. + state: verifier, + }); + return `${ANTHROPIC_AUTHORIZE_URL}?${params.toString()}`; +} + +export function parseAnthropicAuthCode(input, fallbackState = '') { + const raw = String(input || '').trim(); + if (!raw) return { code: '', state: '' }; + try { + const url = new URL(raw); + const code = url.searchParams.get('code') || ''; + const state = url.searchParams.get('state') || (url.hash ? url.hash.slice(1) : '') || fallbackState; + return { code, state }; + } catch { + const [code, state] = raw.split('#'); + return { code: (code || '').trim(), state: (state || fallbackState || '').trim() }; + } +} + +function expiresAt(expiresInSec) { + const sec = Number(expiresInSec) || 3600; + return Date.now() + sec * 1000 - 5 * 60 * 1000; +} + +async function parseTokenResponse(res, context) { + const json = await res.json().catch(() => ({})); + if (!res.ok) { + const detail = + json.error_description || json.error?.message || json.error || json.message || `${res.status}`; + throw new Error(`${context} failed: ${detail}`); + } + if (!json.access_token) throw new Error(`${context} failed: Anthropic returned no access token`); + return { + access: json.access_token, + refresh: json.refresh_token, + expires: expiresAt(json.expires_in), + }; +} + +export async function exchangeAnthropicCode({ authCode, verifier }) { + const { code, state } = parseAnthropicAuthCode(authCode, verifier); + if (!code) throw new Error('Paste the Claude authorization code first.'); + if (state && state !== verifier) throw new Error('Claude sign-in state mismatch. Start the sign-in again.'); + + const res = await fetch(ANTHROPIC_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: ANTHROPIC_CLIENT_ID, + code, + state: state || verifier, + redirect_uri: ANTHROPIC_REDIRECT_URI, + code_verifier: verifier, + }), + }); + return parseTokenResponse(res, 'Claude token exchange'); +} + +export function isAnthropicTokenExpired(expires) { + return !expires || Number(expires) <= Date.now() + 60_000; +} + +export async function saveAnthropicOAuthTokens(tokens) { + const patch = { + [ANTHROPIC_ACCESS_KEY]: tokens.access, + [ANTHROPIC_EXPIRY_KEY]: tokens.expires, + }; + if (tokens.refresh) patch[ANTHROPIC_REFRESH_KEY] = tokens.refresh; + await chrome.storage.local.set(patch); + return tokens.access; +} + +export async function clearAnthropicOAuthTokens() { + await chrome.storage.local.remove([ + ANTHROPIC_ACCESS_KEY, + ANTHROPIC_REFRESH_KEY, + ANTHROPIC_EXPIRY_KEY, + ANTHROPIC_OAUTH_VERIFIER_KEY, + 'anthropicCredentials', // legacy cleanup + ]); +} + +let refreshInFlight = null; + +export async function refreshAnthropicAccessToken() { + if (refreshInFlight) return refreshInFlight; + refreshInFlight = (async () => { + const s = await chrome.storage.local.get([ + ANTHROPIC_ACCESS_KEY, + ANTHROPIC_REFRESH_KEY, + ANTHROPIC_EXPIRY_KEY, + ]); + if (s[ANTHROPIC_ACCESS_KEY] && !isAnthropicTokenExpired(s[ANTHROPIC_EXPIRY_KEY])) { + return s[ANTHROPIC_ACCESS_KEY]; + } + if (!s[ANTHROPIC_REFRESH_KEY]) { + await clearAnthropicOAuthTokens(); + throw new Error('Claude sign-in expired — reconnect Anthropic in Settings.'); + } + + const res = await fetch(ANTHROPIC_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + client_id: ANTHROPIC_CLIENT_ID, + refresh_token: s[ANTHROPIC_REFRESH_KEY], + }), + }); + const tokens = await parseTokenResponse(res, 'Claude token refresh'); + if (!tokens.refresh) tokens.refresh = s[ANTHROPIC_REFRESH_KEY]; + await saveAnthropicOAuthTokens(tokens); + return tokens.access; + })().finally(() => { + refreshInFlight = null; + }); + return refreshInFlight; +} diff --git a/oauth-openai.js b/oauth-openai.js index 8ec170d..e62b986 100644 --- a/oauth-openai.js +++ b/oauth-openai.js @@ -47,7 +47,7 @@ let _openaiRefreshPromise = null; export function isOpenAITokenExpired(creds, skewSeconds = OPENAI_REFRESH_SKEW_SECONDS) { if (!creds?.access_token || !creds?.expires_at) return true; const skewMs = Math.max(0, skewSeconds) * 1000; - return (Date.now() + skewMs) >= creds.expires_at; + return Date.now() + skewMs >= creds.expires_at; } export async function getOpenAICredentials() { @@ -127,7 +127,7 @@ export async function exchangeOpenAICode({ code, state, verifier, storedState }) access_token: tokens.access_token, refresh_token: tokens.refresh_token, id_token: tokens.id_token, - expires_at: Date.now() + ((tokens.expires_in || 3600) * 1000), + expires_at: Date.now() + (tokens.expires_in || 3600) * 1000, }; await saveOpenAICredentials(creds); return creds; @@ -173,7 +173,7 @@ export async function refreshOpenAIToken({ force = false } = {}) { // Some refresh responses omit a rotated refresh/id token — keep the prior one. refresh_token: tokens.refresh_token || creds.refresh_token, id_token: tokens.id_token || creds.id_token, - expires_at: Date.now() + ((tokens.expires_in || 3600) * 1000), + expires_at: Date.now() + (tokens.expires_in || 3600) * 1000, }; await saveOpenAICredentials(newCreds); return newCreds; diff --git a/oauth-xai.js b/oauth-xai.js index 826579b..44c8a6f 100644 --- a/oauth-xai.js +++ b/oauth-xai.js @@ -24,12 +24,15 @@ export async function requestXaiDeviceCode() { return res.json(); } -export async function pollXaiDeviceToken(deviceCode, { intervalSec = 5, expiresInSec = 600, onPending } = {}) { +export async function pollXaiDeviceToken( + deviceCode, + { intervalSec = 5, expiresInSec = 600, onPending } = {} +) { const interval = intervalSec * 1000; - const deadline = Date.now() + (expiresInSec * 1000); + const deadline = Date.now() + expiresInSec * 1000; while (Date.now() < deadline) { - await new Promise(r => setTimeout(r, interval)); + await new Promise((r) => setTimeout(r, interval)); const res = await fetch(XAI_TOKEN_URL, { method: 'POST', @@ -49,7 +52,7 @@ export async function pollXaiDeviceToken(deviceCode, { intervalSec = 5, expiresI continue; } if (err.error === 'slow_down') { - await new Promise(r => setTimeout(r, 5000)); + await new Promise((r) => setTimeout(r, 5000)); continue; } throw new Error(err.error_description || err.error || 'Token polling failed'); @@ -62,7 +65,7 @@ export async function storeXaiOAuthTokens(token) { const structured = { access_token: token.access_token, refresh_token: token.refresh_token, - expires_at: Date.now() + (token.expires_in * 1000), + expires_at: Date.now() + token.expires_in * 1000, }; await chrome.storage.local.set({ xaiCredentials: structured, @@ -75,9 +78,7 @@ export async function storeXaiOAuthTokens(token) { } export async function getXaiCredentials() { - const data = await chrome.storage.local.get([ - 'xaiCredentials', 'xaiKey', 'xaiRefresh', 'xaiExpiry' - ]); + const data = await chrome.storage.local.get(['xaiCredentials', 'xaiKey', 'xaiRefresh', 'xaiExpiry']); if (data.xaiCredentials && typeof data.xaiCredentials === 'object') { return data.xaiCredentials; } diff --git a/onboarding-copy.js b/onboarding-copy.js index 5f301f1..b9df007 100644 --- a/onboarding-copy.js +++ b/onboarding-copy.js @@ -8,7 +8,7 @@ export const COPY = { introCorkboard: 'Same library, as a board. A line between two repos means they go together.', introSearch: 'Find a repo by name, or ask your library a question in plain words.', introOpen: 'Click a card and I open the full read on that repo.', - verdict: "The honest call on whether to use it, before the README starts selling.", + verdict: 'The honest call on whether to use it, before the README starts selling.', blueprint: "How it's built, as a map you can drag around. Hit the tour button to walk it.", farewell: 'You know your way around now. Everything stays in your browser, nothing phones home.', milestoneOffer: "{N} scans in. You've got plenty to compare and connect now. Want me to show you how?", diff --git a/onboarding-demo.html b/onboarding-demo.html index c55298d..8f659e0 100644 --- a/onboarding-demo.html +++ b/onboarding-demo.html @@ -1,92 +1,199 @@ - + - - - Onboarding demo — RepoLens Vee coachmark - - - - -

RepoLens — Vee onboarding coachmark (demo)

-

Fake target elements let the spotlight land on something real. No extension, no API.

-
-
- Library · demo - - - -
-
- -
- - - - - -
- -
- + + + Onboarding demo — RepoLens Vee coachmark + + + + +

RepoLens — Vee onboarding coachmark (demo)

+

Fake target elements let the spotlight land on something real. No extension, no API.

+
+
+ Library · demo + + +
- -
-
- evanw/esbuild - strong -

Zero-config JS bundler, extremely fast.

+
+ +
+ + + + +
-
- vitejs/vite - solid -

Dev server + bundler using Rollup.

+ +
+
-
- webpack/webpack - care -

Mature but heavy; config overhead.

+ +
+
+ evanw/esbuild + strong +

+ Zero-config JS bundler, extremely fast. +

+
+
+ vitejs/vite + solid +

Dev server + bundler using Rollup.

+
+
+ webpack/webpack + care +

Mature but heavy; config overhead.

+
-
- - + document.getElementById('run-milestone').onclick = () => { + const milestoneCopy = { ...COPY, milestoneOffer: COPY.milestoneOffer.replace('{N}', '7') }; + startCoachmark({ steps: milestoneSteps(), copy: milestoneCopy, onExit: () => {} }); + }; + + diff --git a/options-providers.js b/options-providers.js index e8398fb..7372b55 100644 --- a/options-providers.js +++ b/options-providers.js @@ -13,7 +13,6 @@ import { provVerName, compatStorageKeys, isCompatConnected, - compatModelFor, } from './providers.js'; import { createPkcePair } from './oauth-pkce.js'; import { @@ -37,7 +36,8 @@ function el(tag, props = {}, kids = []) { else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2).toLowerCase(), v); else n.setAttribute(k, v); } - for (const kid of [].concat(kids)) if (kid != null) n.appendChild(typeof kid === 'string' ? document.createTextNode(kid) : kid); + for (const kid of [].concat(kids)) + if (kid != null) n.appendChild(typeof kid === 'string' ? document.createTextNode(kid) : kid); return n; } @@ -52,7 +52,9 @@ async function requestOrigin(url) { try { const origin = new URL(url).origin + '/*'; await chrome.permissions.request({ origins: [origin] }); - } catch { /* invalid URL or already granted — the self-test will surface real failures */ } + } catch { + /* invalid URL or already granted — the self-test will surface real failures */ + } } /** Provider-routing groups for the per-part picker (only providers with a model catalog). */ @@ -82,7 +84,10 @@ function buildCard(p, snapshot) { const dot = el('div', { class: 'svc-dot', id: `cc-dot-${p.id}` }); const status = el('div', { class: 'svc-status', id: `cc-status-${p.id}` }); - const tag = el('span', { class: 'svc-tag', text: p.protocol === 'anthropic' ? 'Anthropic API' : 'OpenAI API' }); + const tag = el('span', { + class: 'svc-tag', + text: p.protocol === 'anthropic' ? 'Anthropic API' : 'OpenAI API', + }); const name = el('div', { class: 'svc-name' }, [p.label, tag]); const hint = el('div', { class: 'svc-hint', text: p.hint || '' }); const btn = el('button', { class: 'svc-btn', id: `cc-btn-${p.id}` }); @@ -102,10 +107,13 @@ function buildCard(p, snapshot) { verInput.value = snapshot[provVerName(p.id)] || p.defaultApiVersion || ''; keyInput = el('input', { type: 'password', placeholder: 'Azure API key' }); panel.append( - el('p', { class: 'token-instruction', text: 'Resource endpoint, API version, and key. Set your deployment name as the Model below.' }), + el('p', { + class: 'token-instruction', + text: 'Resource endpoint, API version, and key. Set your deployment name as the Model below.', + }), el('div', { class: 'cc-row' }, [baseInput]), el('div', { class: 'cc-row' }, [verInput]), - el('div', { class: 'token-row' }, [keyInput, el('button', { text: 'Save', onclick: saveAzure })]), + el('div', { class: 'token-row' }, [keyInput, el('button', { text: 'Save', onclick: saveAzure })]) ); } else if (p.custom) { protoSel = el('select', { class: 'model-select' }, [ @@ -120,15 +128,24 @@ function buildCard(p, snapshot) { el('p', { class: 'token-instruction', text: 'Point at any OpenAI- or Anthropic-compatible server.' }), el('div', { class: 'cc-row' }, [protoSel]), el('div', { class: 'cc-row' }, [baseInput]), - el('div', { class: 'token-row' }, [keyInput, el('button', { text: 'Save', onclick: saveCustom })]), + el('div', { class: 'token-row' }, [keyInput, el('button', { text: 'Save', onclick: saveCustom })]) ); } else if (!p.keyless) { keyInput = el('input', { type: 'password', placeholder: p.keyHint || 'API key' }); - const docs = p.docsUrl ? el('a', { class: 'key-toggle', text: 'Get a key ↗', onclick: () => chrome.tabs.create({ url: p.docsUrl }) }) : null; + const docs = p.docsUrl + ? el('a', { + class: 'key-toggle', + text: 'Get a key ↗', + onclick: () => chrome.tabs.create({ url: p.docsUrl }), + }) + : null; panel.append( - el('p', { class: 'token-instruction', text: `Paste your ${p.label} API key. Stored only in this browser.` }), + el('p', { + class: 'token-instruction', + text: `Paste your ${p.label} API key. Stored only in this browser.`, + }), el('div', { class: 'token-row' }, [keyInput, el('button', { text: 'Save', onclick: saveKey })]), - docs, + docs ); } // ── OpenAI only: "Sign in with ChatGPT" (Codex CLI OAuth), above the key field ── @@ -163,7 +180,11 @@ function buildCard(p, snapshot) { modelSel.value = storedModel; } modelSel.addEventListener('change', () => { - if (modelSel.value === CUSTOM) { modelCustom.style.display = ''; modelCustom.focus(); return; } + if (modelSel.value === CUSTOM) { + modelCustom.style.display = ''; + modelCustom.focus(); + return; + } modelCustom.style.display = 'none'; set({ [provModelName(p.id)]: modelSel.value }); }); @@ -178,33 +199,52 @@ function buildCard(p, snapshot) { if (!p.custom && p.protocol !== 'azure') { const ovInput = el('input', { type: 'text', placeholder: p.endpoint || 'endpoint URL' }); ovInput.value = snapshot[provBaseName(p.id)] || ''; - const ovSave = el('button', { class: 'cc-test-btn', text: 'Save endpoint', onclick: async () => { - const v = ovInput.value.trim(); - if (v) { await requestOrigin(v); await set({ [provBaseName(p.id)]: v }); } - else await remove(provBaseName(p.id)); - } }); - card.appendChild(el('details', { class: 'cc-adv' }, [ - el('summary', { text: 'Advanced — override endpoint' }), - el('div', { class: 'cc-row' }, [ovInput, ovSave]), - ])); + const ovSave = el('button', { + class: 'cc-test-btn', + text: 'Save endpoint', + onclick: async () => { + const v = ovInput.value.trim(); + if (v) { + await requestOrigin(v); + await set({ [provBaseName(p.id)]: v }); + } else await remove(provBaseName(p.id)); + }, + }); + card.appendChild( + el('details', { class: 'cc-adv' }, [ + el('summary', { text: 'Advanced — override endpoint' }), + el('div', { class: 'cc-row' }, [ovInput, ovSave]), + ]) + ); } // ── self-tests ─────────────────────────────────────────────────────────── const result = el('span', { class: 'cc-test-result' }); - const testConn = el('button', { class: 'cc-test-btn', text: 'Test connection', onclick: () => runTest('connection') }); - const testFn = el('button', { class: 'cc-test-btn', text: 'Test function', onclick: () => runTest('function') }); + const testConn = el('button', { + class: 'cc-test-btn', + text: 'Test connection', + onclick: () => runTest('connection'), + }); + const testFn = el('button', { + class: 'cc-test-btn', + text: 'Test function', + onclick: () => runTest('function'), + }); card.appendChild(el('div', { class: 'cc-tests' }, [testConn, testFn, result])); // ── behaviour ────────────────────────────────────────────────────────────── function setState(connected, via) { dot.classList.toggle('on', connected); status.classList.toggle('on', connected); - status.textContent = !connected ? 'Not connected' - : via === 'chatgpt' ? 'Connected (ChatGPT)' - : p.keyless ? 'Enabled (local)' - : 'Connected (API key)'; + status.textContent = !connected + ? 'Not connected' + : via === 'chatgpt' + ? 'Connected (ChatGPT)' + : p.keyless + ? 'Enabled (local)' + : 'Connected (API key)'; card.classList.toggle('connected', connected); - btn.textContent = connected ? (p.keyless ? 'Disable' : 'Disconnect') : (p.keyless ? 'Enable' : 'Connect'); + btn.textContent = connected ? (p.keyless ? 'Disable' : 'Disconnect') : p.keyless ? 'Enable' : 'Connect'; btn.classList.toggle('disconnect', connected); modelRow.classList.toggle('visible', connected); if (connected) panel.classList.remove('open'); @@ -213,7 +253,10 @@ function buildCard(p, snapshot) { // "Sign in with ChatGPT" — PKCE authorize in a new tab; background.js intercepts the // loopback redirect, exchanges the code, and mints an OpenAI API key into openaiKey. async function connectOpenAiOAuth() { - const restore = () => { openAiOAuthBtn.disabled = false; openAiOAuthBtn.textContent = 'Sign in with ChatGPT'; }; + const restore = () => { + openAiOAuthBtn.disabled = false; + openAiOAuthBtn.textContent = 'Sign in with ChatGPT'; + }; try { openAiOAuthBtn.disabled = true; openAiOAuthBtn.textContent = 'Signing in…'; @@ -248,13 +291,19 @@ function buildCard(p, snapshot) { btn.addEventListener('click', async () => { if (await isOn()) { if (p.keyless) await remove(provEnabledName(p.id)); - else if (p.custom || p.protocol === 'azure') await remove([provBaseName(p.id), provKeyName(p.id)]); // endpoint marks these connected - else if (p.id === 'openai') await remove([provKeyName(p.id), OPENAI_CREDENTIALS_KEY]); // minted key + ChatGPT session, together + else if (p.custom || p.protocol === 'azure') + await remove([provBaseName(p.id), provKeyName(p.id)]); // endpoint marks these connected + else if (p.id === 'openai') + await remove([provKeyName(p.id), OPENAI_CREDENTIALS_KEY]); // minted key + ChatGPT session, together else await remove(provKeyName(p.id)); setState(false); return; } - if (p.keyless) { await set({ [provEnabledName(p.id)]: true }); setState(true); return; } + if (p.keyless) { + await set({ [provEnabledName(p.id)]: true }); + setState(true); + return; + } panel.classList.toggle('open'); }); @@ -269,7 +318,10 @@ function buildCard(p, snapshot) { async function saveCustom() { const base = baseInput.value.trim(); - if (!base) { baseInput.focus(); return; } + if (!base) { + baseInput.focus(); + return; + } await requestOrigin(base); // custom hosts aren't pre-declared — ask for the origin const patch = { [provBaseName(p.id)]: base, [provProtoName(p.id)]: protoSel.value }; const key = keyInput.value.trim(); @@ -281,9 +333,15 @@ function buildCard(p, snapshot) { async function saveAzure() { const base = baseInput.value.trim(); - if (!base) { baseInput.focus(); return; } + if (!base) { + baseInput.focus(); + return; + } await requestOrigin(base); // the resource host isn't pre-declared - const patch = { [provBaseName(p.id)]: base, [provVerName(p.id)]: verInput.value.trim() || p.defaultApiVersion }; + const patch = { + [provBaseName(p.id)]: base, + [provVerName(p.id)]: verInput.value.trim() || p.defaultApiVersion, + }; const key = keyInput.value.trim(); if (key) patch[provKeyName(p.id)] = key; await set(patch); @@ -299,7 +357,8 @@ function buildCard(p, snapshot) { const r = await chrome.runtime.sendMessage({ type: 'TEST_PROVIDER', provider: p.id }); const pass = kind === 'connection' ? r?.connection : r?.function; result.classList.add(pass ? 'ok' : 'err'); - if (kind === 'connection') result.textContent = pass ? '✓ Endpoint reachable' : `✗ ${r?.detail || 'unreachable'}`; + if (kind === 'connection') + result.textContent = pass ? '✓ Endpoint reachable' : `✗ ${r?.detail || 'unreachable'}`; else result.textContent = pass ? '✓ Model followed the instruction' : `✗ ${r?.detail || 'no response'}`; } catch (e) { result.classList.add('err'); @@ -309,7 +368,8 @@ function buildCard(p, snapshot) { } } - const initVia = (p.id === 'openai' && snapshot[OPENAI_CREDENTIALS_KEY]?.refresh_token) ? 'chatgpt' : undefined; + const initVia = + p.id === 'openai' && snapshot[OPENAI_CREDENTIALS_KEY]?.refresh_token ? 'chatgpt' : undefined; setState(isCompatConnected(p.id, snapshot), initVia); return card; } diff --git a/options.html b/options.html index 2d5b254..34a7036 100644 --- a/options.html +++ b/options.html @@ -1,462 +1,999 @@ - + - - -RepoLens Settings - - - - -

RepoLens

-

Everything stored locally. Nothing leaves your machine except calls to each service's own API. Open Library →

- - - - - -
-
Appearance
- -
-
- - -
-
Voice
- -
-

The voice the AI writes every analysis in — the scan and all lenses.

-
- - -
-
Skills
- -

Adds a one-tap SKTPG tab to every scan — where the repo is heading, not just what it is. On by default.

-
- - -
-
Interface
- - -

A small lens character that reacts to your scans — scanning as it reads, wide open on a strong fit, eyes narrowed on a risky one. Decorative only; reduced-motion shows a static glyph. On by default.

-
- - -
-
History
- -
-

Every repo you've analyzed is cached locally — click to reopen instantly (no AI call). Re-scanning a cached repo loads it instantly too.

-
- - -
-
Core
- - -

Your library is stored in the browser — no server, nothing to set up.

- - - -

Paces bursts (Run-all lenses, Deep Dive's 3 stages) so slow / rate-limited models like Step 3.7 aren't hit all at once. 0 = no delay.

-
- - -
-
Import from VelesDB
-

One-time migration. If you ran a VelesDB server before, pull your saved repos into the extension's built-in storage while that server is still running. Safe to run more than once.

- - - -

-
- - -
-
Back up your settings
-

Export your theme, voice, model picks and per-part routing to a JSON file you can carry to another machine. API keys and tokens are never included.

-
- - + .token-panel.open { + animation: panel-in var(--dur-slow) var(--ease-out) both; + } + .model-row.visible { + animation: panel-in var(--dur-slow) var(--ease-out) both; + } + @keyframes panel-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: none; + } + } + } + + + +

RepoLens

+

+ Everything stored locally. Nothing leaves your machine except calls to each service's own API. + Open Library → +

+ + + - -

-

Want the first-run walkthrough again? Replay the onboarding tour from the Library.

- -
- - -
-
Connected Services
- - -
-
-
-
-
-
Anthropic Claude
-
Not connected
+ + +
+
Appearance
+ +
+
+ + +
+
Voice
+ +
+

+ The voice the AI writes every analysis in — the scan and all lenses. +

+
+ + +
+
Skills
+ +

+ Adds a one-tap SKTPG tab to every scan — where the repo is heading, not just what it is. On by + default. +

+
+ + +
+
Interface
+ + +

+ A small lens character that reacts to your scans — scanning as it reads, wide open on a strong fit, + eyes narrowed on a risky one. Decorative only; reduced-motion shows a static glyph. On by default. +

+
+ + +
+
History
+ +
+

+ Every repo you've analyzed is cached locally — click to reopen instantly (no AI call). Re-scanning a + cached repo loads it instantly too. +

+
+ + +
+
Core
+ + +

+ Your library is stored in the browser — no server, nothing to set up. +

+ + + +

+ Paces bursts (Run-all lenses, Deep Dive's 3 stages) so slow / rate-limited models like Step 3.7 aren't + hit all at once. 0 = no delay. +

+
+ + +
+
Import from VelesDB
+

+ One-time migration. If you ran a VelesDB server before, pull your saved repos into the extension's + built-in storage while that server is still running. Safe to run more than once. +

+ + + +

+
+ + +
+
Back up your settings
+

+ Export your theme, voice, model picks and per-part routing to a JSON file you can carry to another + machine. API keys and tokens are never included. +

+
+ + +
+ +

+

+ Want the first-run walkthrough again? Replay the onboarding tour from the Library. +

+ +
+ + +
+
Connected Services
+ + +
+
+
+
+
+
+ Anthropic + Claude +
+
Not connected
+
+
- -
-
-

- Paste a Console API key from console.anthropic.com/settings/keys. - (Claude subscription sign-in isn’t supported — Anthropic restricts it to the Claude Code app.) -

-
- - +
+

+ Sign in with Claude Pro/Max using the same Claude Code OAuth flow Pi uses, or paste a Console API + key from console.anthropic.com/settings/keys. +

+
+ +
+
+ + +
+

Or use a Console API key:

+
+ + +
+
+
+ Model +
-
- Model - -
-
- -
-
-
-
-
-
Google AI Gemini · recommended
-
Not connected
+ +
+
+
+
+
+
+ Google AI + Gemini · recommended +
+
Not connected
+
+
- -
-
- Model - -
-
-

- Go to aistudio.google.com/app/apikey, create a free API key, - and paste it here. Works with your Google AI Ultra subscription. -

-
- - +
+ Model + +
+
+

+ RepoLens loads the live Gemini model list from your API key. If Ultra exposes a model before it + appears here, enter the exact model id without the + models/ + prefix. +

+
+ + +
+
+
+

+ Go to aistudio.google.com/app/apikey, create a free API key, and paste + it here. Works with your Google AI Ultra subscription. +

+
+ + +
-
- -
-
-
-
-
-
Nous Research Nous Portal · membership
-
Not connected
+ +
+
+
+
+
+
+ Nous Research + Nous Portal · membership +
+
Not connected
+
+
- -
-
- Model - -
-
-

- Enter the exact model slug from portal.nousresearch.com. - The API resolves OpenRouter-catalog slugs (e.g. stepfun/step-3.7-flash) and Nous-native names (Hermes-4-405B). -

-
- - +
+ Model +
-
-
-

- Create an API key at portal.nousresearch.com and paste it here. - Uses your Nous Portal membership credits. -

-
- - +
+

+ Enter the exact model slug from portal.nousresearch.com. The API + loads the live /v1/models catalog when Settings opens. Enter an exact model id if you need one + that is not listed (for example + nousresearch/hermes-4-405b). +

+
+ + +
+
+
+

+ Create an API key at portal.nousresearch.com and paste it here. Uses your + Nous Portal membership credits. +

+
+ + +
-
- -
-
-
-
-
-
OpenRouter
-
Not connected
+ +
+
+
+
+
+
OpenRouter
+
Not connected
+
+
- -
-
- Model - -
-
-

- Enter any model ID from openrouter.ai/models - (e.g. meta-llama/llama-3.3-70b-instruct). -

-
- - +
+ Model + +
+
+

+ Settings loads OpenRouter's live /api/v1/models catalog. Enter any model ID from + openrouter.ai/models if you need one that is not listed (e.g. + meta-llama/llama-3.3-70b-instruct). +

+
+ + +
-
- -
-
-
-
-
-
Grok xAI · SuperGrok
-
Not connected
+ +
+
+
+
+
+
+ Grok + xAI · SuperGrok +
+
Not connected
+
+
- -
- Use an API key instead → -
-
-

- Paste a developer API key from console.x.ai (starts with - xai-). Note: this is billed per token — - separate from a SuperGrok subscription. -

-
- - + Use an API key instead → +
+
+

+ Paste a developer API key from console.x.ai (starts with + xai-). Note: this is billed per token — separate from + a SuperGrok subscription. +

+
+ + +
+
+
+ Model +
-
-
- Model -
-
- - -
-
More model providers
-

Any OpenAI- or Anthropic-compatible endpoint. Each keeps its own key, so switching never loses data. Test connection checks the endpoint answers; Test function asks the model to follow a tiny instruction. Local Ollama needs no key.

-
-
+ +
+
More model providers
+

+ Any OpenAI- or Anthropic-compatible endpoint. Each keeps its own key, so switching never loses data. + Test connection checks the endpoint answers; Test function asks the model to follow a + tiny instruction. Local Ollama needs no key. +

+
+
- -
-
Models per scan part
-

Choose which model handles each part of a scan. Default uses the smart fallback chain (Nous → Gemini → OpenRouter → Grok → Anthropic). Any specific pick still falls back to the chain if that provider errors or isn't connected. ★ = recommended.

-
-
+ +
+
Models per scan part
+

+ Choose which model handles each part of a scan. Default uses the smart fallback chain (Nous → + Gemini → OpenRouter → Grok → Anthropic). Any specific pick still falls back to the chain if that + provider errors or isn't connected. ★ = recommended. +

+
+
- -
+ +
- - + + diff --git a/options.js b/options.js index e892707..c13db64 100644 --- a/options.js +++ b/options.js @@ -1,12 +1,19 @@ import { createPkcePair } from './oauth-pkce.js'; import { - pollXaiDeviceToken, - requestXaiDeviceCode, - storeXaiOAuthTokens, -} from './oauth-xai.js'; + ANTHROPIC_ACCESS_KEY, + ANTHROPIC_EXPIRY_KEY, + ANTHROPIC_OAUTH_VERIFIER_KEY, + ANTHROPIC_REFRESH_KEY, + buildAnthropicAuthorizeUrl, + clearAnthropicOAuthTokens, + createAnthropicPkcePair, + exchangeAnthropicCode, + saveAnthropicOAuthTokens, +} from './oauth-anthropic.js'; +import { pollXaiDeviceToken, requestXaiDeviceCode, storeXaiOAuthTokens } from './oauth-xai.js'; import { importFromVelesdb } from './migrate/velesdb-import.js'; import { SAFE_SETTING_KEYS, buildSettingsBackup, validateSettingsBackup } from './settings-backup.js'; -import { PARTS, CATALOG } from './models.js'; +import { PARTS, CATALOG, canonicalModel } from './models.js'; import { renderCompatProviders, compatPartGroups, anyCompatConnected } from './options-providers.js'; import { THEMES, initTheme, saveTheme } from './theme.js'; import { TONES, DEFAULT_TONE } from './tone.js'; @@ -15,23 +22,33 @@ import { listCached, removeCached, openCachedAnalysis } from './cache.js'; // ─── Core settings ─────────────────────────────────────────────────────────── const autoSaveInput = document.getElementById('autoSave'); -const saveBtn = document.getElementById('save'); -const statusEl = document.getElementById('status'); +const saveBtn = document.getElementById('save'); +const statusEl = document.getElementById('status'); -const orModelSel = document.getElementById('openrouterModel'); +const CUSTOM = '__custom__'; + +const orModelSel = document.getElementById('openrouterModel'); const orCustomPanel = document.getElementById('custom-openrouter'); const orCustomInput = document.getElementById('openrouterModelCustom'); function syncOpenrouterCustom() { - orCustomPanel.classList.toggle('open', orModelSel.value === '__custom__'); + orCustomPanel.classList.toggle('open', orModelSel.value === CUSTOM); +} + +const googleModelSel = document.getElementById('googleModel'); +const googleCustomPanel = document.getElementById('custom-google'); +const googleCustomInput = document.getElementById('googleModelCustom'); + +function syncGoogleCustom() { + googleCustomPanel?.classList.toggle('open', googleModelSel.value === CUSTOM); } -const nousModelSel = document.getElementById('nousModel'); +const nousModelSel = document.getElementById('nousModel'); const nousCustomPanel = document.getElementById('custom-nous'); const nousCustomInput = document.getElementById('nousModelCustom'); function syncNousCustom() { - nousCustomPanel.classList.toggle('open', nousModelSel.value === '__custom__'); + nousCustomPanel.classList.toggle('open', nousModelSel.value === CUSTOM); } chrome.storage.local.get(['autoSave'], ({ autoSave }) => { @@ -40,7 +57,8 @@ chrome.storage.local.get(['autoSave'], ({ autoSave }) => { // ─── History (cached analyses) ─────────────────────────────────────────────── const historySearch = document.getElementById('historySearch'); -const escH = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +const escH = (s) => + String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); let historyItems = []; async function loadHistory() { @@ -52,7 +70,7 @@ function renderHistory() { const host = document.getElementById('history-list'); if (!host) return; const q = (historySearch?.value || '').toLowerCase(); - const items = historyItems.filter(it => !q || (it.repoId || '').toLowerCase().includes(q)); + const items = historyItems.filter((it) => !q || (it.repoId || '').toLowerCase().includes(q)); if (!items.length) { host.innerHTML = `
${historyItems.length ? 'No matches.' : "Nothing analyzed yet — scan a repo and it'll show up here."}
`; return; @@ -64,7 +82,10 @@ function renderHistory() { row.className = 'hist-row'; row.innerHTML = `${escH(it.platform || '')} · ${escH(date)}`; row.querySelector('.hist-open').addEventListener('click', () => openCachedAnalysis(it)); - row.querySelector('.hist-del').addEventListener('click', async () => { await removeCached(it.platform, it.repoId); loadHistory(); }); + row.querySelector('.hist-del').addEventListener('click', async () => { + await removeCached(it.platform, it.repoId); + loadHistory(); + }); host.appendChild(row); } } @@ -73,13 +94,18 @@ historySearch?.addEventListener('input', renderHistory); loadHistory(); // ─── Library link ──────────────────────────────────────────────────────────── -document.getElementById('open-library-link') +document + .getElementById('open-library-link') ?.addEventListener('click', () => openTab(chrome.runtime.getURL('library.html'))); // ─── Replay onboarding ──────────────────────────────────────────────────────── // Reset the onboarding flags and open the Library, where the first-run tour fires. document.getElementById('replayOnboardingBtn')?.addEventListener('click', async () => { - await chrome.storage.local.set({ onboardingSeen: false, milestoneTourSeen: false, milestoneSnoozeAt10: false }); + await chrome.storage.local.set({ + onboardingSeen: false, + milestoneTourSeen: false, + milestoneSnoozeAt10: false, + }); openTab(chrome.runtime.getURL('library.html')); }); @@ -126,7 +152,9 @@ animateIconInput.addEventListener('change', () => { // Persist the user's OS reduced-motion preference so the service worker (which has // no DOM / matchMedia) can honor it before animating the toolbar icon. -chrome.storage.local.set({ reduceMotion: typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches }); +chrome.storage.local.set({ + reduceMotion: typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches, +}); // ─── Voice / tone ──────────────────────────────────────────────────────────── chrome.storage.local.get('tone', ({ tone }) => renderTonePicker(tone || DEFAULT_TONE)); @@ -141,7 +169,7 @@ function renderTonePicker(current) { chip.innerHTML = `${t.label}${t.blurb}`; chip.addEventListener('click', () => { chrome.storage.local.set({ tone: t.key }); - host.querySelectorAll('.tone-chip').forEach(c => c.classList.remove('active')); + host.querySelectorAll('.tone-chip').forEach((c) => c.classList.remove('active')); chip.classList.add('active'); }); host.appendChild(chip); @@ -162,7 +190,7 @@ function renderThemePicker() { chip.innerHTML = `${t.label}`; chip.addEventListener('click', async () => { await saveTheme(t.key); - host.querySelectorAll('.theme-chip').forEach(c => c.classList.remove('active')); + host.querySelectorAll('.theme-chip').forEach((c) => c.classList.remove('active')); chip.classList.add('active'); }); host.appendChild(chip); @@ -184,9 +212,12 @@ if (importBtn) { importStatus.style.color = 'var(--text-sub)'; importStatus.textContent = 'Connecting to VelesDB…'; try { - const { imported, failed, total } = await importFromVelesdb(importUrlInput.value, ({ imported, total }) => { - importStatus.textContent = `Importing… ${imported}/${total}`; - }); + const { imported, failed, total } = await importFromVelesdb( + importUrlInput.value, + ({ imported, total }) => { + importStatus.textContent = `Importing… ${imported}/${total}`; + } + ); importStatus.style.color = '#4ade80'; importStatus.textContent = total === 0 @@ -238,7 +269,10 @@ settingsFile?.addEventListener('change', async (e) => { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; - if (file.size > 5 * 1024 * 1024) { setSettingsStatus('✗ That file is too large.', '#f87171'); return; } + if (file.size > 5 * 1024 * 1024) { + setSettingsStatus('✗ That file is too large.', '#f87171'); + return; + } let parsed; try { parsed = JSON.parse(await file.text()); @@ -247,8 +281,14 @@ settingsFile?.addEventListener('change', async (e) => { return; } const { ok, errors, value } = validateSettingsBackup(parsed); - if (!ok) { setSettingsStatus(`✗ ${errors[0]}`, '#f87171'); return; } - if (!Object.keys(value).length) { setSettingsStatus('Nothing importable in that file.', 'var(--text-sub)'); return; } + if (!ok) { + setSettingsStatus(`✗ ${errors[0]}`, '#f87171'); + return; + } + if (!Object.keys(value).length) { + setSettingsStatus('Nothing importable in that file.', 'var(--text-sub)'); + return; + } await chrome.storage.local.set(value); setSettingsStatus('✓ Settings imported. Reloading…', '#4ade80'); setTimeout(() => location.reload(), 700); @@ -256,8 +296,159 @@ settingsFile?.addEventListener('change', async (e) => { // ─── Models per scan part ────────────────────────────────────────────────────── const partModelsHost = document.getElementById('part-models'); +const liveCatalog = {}; + +const LIVE_MODEL_SOURCES = { + google: { + endpoint: (stored) => + stored.googleKey + ? `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(stored.googleKey)}` + : '', + select: () => googleModelSel, + customInput: () => googleCustomInput, + storageKey: 'googleModel', + }, + nous: { + endpoint: 'https://inference-api.nousresearch.com/v1/models', + select: () => nousModelSel, + customInput: () => nousCustomInput, + storageKey: 'nousModel', + }, + openrouter: { + endpoint: 'https://openrouter.ai/api/v1/models', + select: () => orModelSel, + customInput: () => orCustomInput, + storageKey: 'openrouterModel', + }, +}; + +function normalizeLiveModel(provider, raw) { + let value = String(raw?.id || raw?.name || '').trim(); + if (provider === 'google') { + if ( + !Array.isArray(raw?.supportedGenerationMethods) || + !raw.supportedGenerationMethods.includes('generateContent') + ) { + return null; + } + value = value.replace(/^models\//, ''); + } + if (!value) return null; + + // RepoLens sends text prompts and expects text back. Hide embedding/audio/image-only + // entries, but keep multimodal models that can still return text. + const inMods = raw?.architecture?.input_modalities; + const outMods = raw?.architecture?.output_modalities; + if (Array.isArray(inMods) && inMods.length && !inMods.includes('text')) return null; + if (Array.isArray(outMods) && outMods.length && !outMods.includes('text')) return null; + + const aliases = [raw?.canonical_slug, raw?.name, ...(Array.isArray(raw?.aliases) ? raw.aliases : [])] + .filter(Boolean) + .map((x) => String(x).replace(/^models\//, '')); + const rec = CATALOG[provider]?.models.find((m) => m.recommended)?.value; + const canonicalRec = canonicalModel(provider, rec); + return { + value, + label: String(raw?.displayName || raw?.name || value) + .replace(/^models\//, '') + .trim(), + aliases, + recommended: value === canonicalRec || aliases.includes(rec) || aliases.includes(canonicalRec), + }; +} + +function liveOptionText(model) { + const id = model.value && model.label !== model.value ? ` — ${model.value}` : ''; + return `${model.label}${id}${model.recommended ? ' — ★ Recommended' : ''}`; +} + +function applyLiveModelList(provider, models, storedModel) { + const cfg = LIVE_MODEL_SOURCES[provider]; + const sel = cfg.select(); + const customInput = cfg.customInput(); + const hasStoredModel = !!storedModel; + const previous = storedModel || (sel.value === CUSTOM ? customInput.value.trim() : sel.value) || ''; + const canonicalPrevious = canonicalModel(provider, previous); + const match = models.find( + (m) => + m.value === canonicalPrevious || m.aliases.includes(previous) || m.aliases.includes(canonicalPrevious) + ); + + sel.textContent = ''; + for (const model of models) { + const opt = document.createElement('option'); + opt.value = model.value; + opt.textContent = liveOptionText(model); + sel.appendChild(opt); + } + const custom = document.createElement('option'); + custom.value = CUSTOM; + custom.textContent = 'Custom…'; + sel.appendChild(custom); + + if (match) { + sel.value = match.value; + customInput.value = ''; + if (previous && previous !== match.value) chrome.storage.local.set({ [cfg.storageKey]: match.value }); + } else if (hasStoredModel && previous && previous !== CUSTOM) { + sel.value = CUSTOM; + customInput.value = previous; + } else { + sel.value = models.find((m) => m.recommended)?.value || models[0]?.value || CUSTOM; + customInput.value = ''; + } + + if (provider === 'google') syncGoogleCustom(); + if (provider === 'openrouter') syncOpenrouterCustom(); + if (provider === 'nous') syncNousCustom(); +} + +async function loadLiveModelCatalog(provider, stored = {}) { + const cfg = LIVE_MODEL_SOURCES[provider]; + const endpoint = typeof cfg.endpoint === 'function' ? cfg.endpoint(stored) : cfg.endpoint; + if (!endpoint) return []; + const res = await fetch(endpoint, { headers: { Accept: 'application/json' } }); + if (!res.ok) throw new Error(`${provider} models ${res.status}`); + const json = await res.json(); + const rows = Array.isArray(json?.data) ? json.data : Array.isArray(json?.models) ? json.models : []; + const seen = new Set(); + const models = rows + .map((row) => normalizeLiveModel(provider, row)) + .filter((model) => model && !seen.has(model.value) && seen.add(model.value)); + if (!models.length) throw new Error(`${provider} returned no text models`); + liveCatalog[provider] = models; + return models; +} + +async function loadLiveModelCatalogs() { + const stored = await chrome.storage.local.get([ + 'googleKey', + ...Object.values(LIVE_MODEL_SOURCES).map((cfg) => cfg.storageKey), + ]); + await Promise.all( + Object.keys(LIVE_MODEL_SOURCES).map(async (provider) => { + try { + const models = await loadLiveModelCatalog(provider, stored); + if (models.length) + applyLiveModelList(provider, models, stored[LIVE_MODEL_SOURCES[provider].storageKey]); + } catch (err) { + console.warn('[RepoLens] live model catalog failed', provider, err); + } + }) + ); + renderPartModels(); +} + +function canonicalPartRouting(value) { + if (!value || value === 'default') return 'default'; + const i = value.indexOf(':'); + if (i <= 0) return value; + const provider = value.slice(0, i); + return `${provider}:${canonicalModel(provider, value.slice(i + 1))}`; +} function buildPartSelect(part, current) { + const currentValue = canonicalPartRouting(current); const sel = document.createElement('select'); sel.className = 'model-select'; sel.dataset.part = part.id; @@ -269,7 +460,11 @@ function buildPartSelect(part, current) { sel.appendChild(def); const groups = [ - ...Object.entries(CATALOG).map(([provider, { label, models }]) => ({ provider, label, models })), + ...Object.entries(CATALOG).map(([provider, { label, models }]) => ({ + provider, + label, + models: liveCatalog[provider] || models, + })), ...compatPartGroups(), // OpenAI/Anthropic-compatible registry providers ]; for (const { provider, label, models } of groups) { @@ -278,13 +473,13 @@ function buildPartSelect(part, current) { for (const m of models) { const opt = document.createElement('option'); opt.value = `${provider}:${m.value}`; - opt.textContent = `${m.recommended ? '★ ' : ''}${m.label}`; + opt.textContent = liveCatalog[provider] ? liveOptionText(m) : `${m.recommended ? '★ ' : ''}${m.label}`; group.appendChild(opt); } sel.appendChild(group); } - sel.value = current && current !== 'default' ? current : 'default'; + sel.value = currentValue && currentValue !== 'default' ? currentValue : 'default'; if (!sel.value) sel.value = 'default'; // current pointed at a model no longer in the catalog return sel; } @@ -313,22 +508,30 @@ async function renderPartModels() { } } renderPartModels(); +loadLiveModelCatalogs(); // More providers (OpenAI/Anthropic-compatible registry) + dismiss the first-run // nudge if one of them is the connected provider. renderCompatProviders(document.getElementById('compat-providers')); anyCompatConnected().then((on) => { - if (on) { const gs = document.getElementById('getting-started'); if (gs) gs.style.display = 'none'; } + if (on) { + const gs = document.getElementById('getting-started'); + if (gs) gs.style.display = 'none'; + } }); function showStatus(msg, color) { - statusEl.textContent = msg; - statusEl.style.color = color; - statusEl.style.display = 'block'; - setTimeout(() => { statusEl.style.display = 'none'; }, 2200); + statusEl.textContent = msg; + statusEl.style.color = color; + statusEl.style.display = 'block'; + setTimeout(() => { + statusEl.style.display = 'none'; + }, 2200); } -function openTab(url) { chrome.tabs.create({ url }); } +function openTab(url) { + chrome.tabs.create({ url }); +} // ─── Service card helpers ───────────────────────────────────────────────────── @@ -351,13 +554,13 @@ function setButtonBusy(btn, busy, busyLabel = 'Connecting…') { } function setConnected(service, key, { method } = {}) { - const dot = document.getElementById(`dot-${service}`); + const dot = document.getElementById(`dot-${service}`); const status = document.getElementById(`status-${service}`); - const btn = document.getElementById(`btn-${service}`); - const card = document.getElementById(`card-${service}`); - const panel = document.getElementById(`panel-${service}`); + const btn = document.getElementById(`btn-${service}`); + const card = document.getElementById(`card-${service}`); + const panel = document.getElementById(`panel-${service}`); const modelRow = document.getElementById(`model-row-${service}`); - const toggle = document.getElementById(`toggle-${service}`); + const toggle = document.getElementById(`toggle-${service}`); setButtonBusy(btn, false); @@ -384,46 +587,84 @@ function setConnected(service, key, { method } = {}) { } chrome.storage.local.get( - ['anthropicKey', 'anthropicModel', 'googleKey', 'googleModel', 'openrouterKey', 'openrouterModel', 'xaiKey', 'xaiRefresh', 'xaiCredentials', 'xaiModel', 'nousKey', 'nousModel'], + [ + 'anthropicKey', + ANTHROPIC_ACCESS_KEY, + ANTHROPIC_REFRESH_KEY, + ANTHROPIC_EXPIRY_KEY, + 'anthropicModel', + 'googleKey', + 'googleModel', + 'openrouterKey', + 'openrouterModel', + 'xaiKey', + 'xaiRefresh', + 'xaiCredentials', + 'xaiModel', + 'nousKey', + 'nousModel', + ], (s) => { - setConnected('anthropic', s.anthropicKey, { method: 'apikey' }); + setConnected('anthropic', s.anthropicKey || s[ANTHROPIC_ACCESS_KEY] || s[ANTHROPIC_REFRESH_KEY], { + method: s.anthropicKey ? 'apikey' : 'oauth', + }); setConnected('google', s.googleKey, { method: 'apikey' }); setConnected('openrouter', s.openrouterKey, { method: 'oauth' }); - setConnected('xai', s.xaiKey || s.xaiRefresh, { method: s.xaiRefresh || (s.xaiCredentials && s.xaiCredentials.refresh_token) ? 'oauth' : 'apikey' }); + setConnected('xai', s.xaiKey || s.xaiRefresh, { + method: s.xaiRefresh || (s.xaiCredentials && s.xaiCredentials.refresh_token) ? 'oauth' : 'apikey', + }); setConnected('nous', s.nousKey, { method: 'apikey' }); // First run: walk the user in until any provider is connected. - const anyProvider = !!(s.anthropicKey || s.googleKey || s.openrouterKey || s.xaiKey || s.xaiRefresh || s.nousKey); + const anyProvider = !!( + s.anthropicKey || + s[ANTHROPIC_ACCESS_KEY] || + s[ANTHROPIC_REFRESH_KEY] || + s.googleKey || + s.openrouterKey || + s.xaiKey || + s.xaiRefresh || + s.nousKey + ); const gettingStarted = document.getElementById('getting-started'); if (gettingStarted) gettingStarted.style.display = anyProvider ? 'none' : ''; if (s.anthropicModel) document.getElementById('anthropicModel').value = s.anthropicModel; - if (s.googleModel) document.getElementById('googleModel').value = s.googleModel; + if (s.googleModel) { + const known = [...googleModelSel.options].some((o) => o.value === s.googleModel); + if (known) { + googleModelSel.value = s.googleModel; + } else { + googleModelSel.value = CUSTOM; + googleCustomInput.value = s.googleModel; + } + } + syncGoogleCustom(); if (s.xaiModel) document.getElementById('xaiModel').value = s.xaiModel; if (s.openrouterModel) { - const known = [...orModelSel.options].some(o => o.value === s.openrouterModel); + const canonical = canonicalModel('openrouter', s.openrouterModel); + if (canonical !== s.openrouterModel) chrome.storage.local.set({ openrouterModel: canonical }); + s.openrouterModel = canonical; + const known = [...orModelSel.options].some((o) => o.value === s.openrouterModel); if (known) { orModelSel.value = s.openrouterModel; } else { - orModelSel.value = '__custom__'; + orModelSel.value = CUSTOM; orCustomInput.value = s.openrouterModel; } } syncOpenrouterCustom(); - // Heal the legacy bare slug that an earlier build saved — the API wants the - // OpenRouter-catalog id for Step 3.7. - if (s.nousModel === 'Step-3.7-Flash' || s.nousModel === 'step-3.7-flash') { - s.nousModel = 'stepfun/step-3.7-flash'; - chrome.storage.local.set({ nousModel: s.nousModel }); - } if (s.nousModel) { - const known = [...nousModelSel.options].some(o => o.value === s.nousModel); + const canonical = canonicalModel('nous', s.nousModel); + if (canonical !== s.nousModel) chrome.storage.local.set({ nousModel: canonical }); + s.nousModel = canonical; + const known = [...nousModelSel.options].some((o) => o.value === s.nousModel); if (known) { nousModelSel.value = s.nousModel; } else { - nousModelSel.value = '__custom__'; + nousModelSel.value = CUSTOM; nousCustomInput.value = s.nousModel; } } @@ -435,8 +676,15 @@ document.getElementById('anthropicModel').addEventListener('change', (e) => { chrome.storage.local.set({ anthropicModel: e.target.value }); }); -document.getElementById('googleModel').addEventListener('change', (e) => { - chrome.storage.local.set({ googleModel: e.target.value }); +googleModelSel.addEventListener('change', () => { + syncGoogleCustom(); + if (googleModelSel.value !== CUSTOM) chrome.storage.local.set({ googleModel: googleModelSel.value }); +}); + +document.getElementById('save-google-model')?.addEventListener('click', () => { + const v = googleCustomInput.value.trim().replace(/^models\//, ''); + if (!v) return; + chrome.storage.local.set({ googleModel: v }, () => showStatus('✓ Model set: ' + v, '#4ade80')); }); document.getElementById('xaiModel').addEventListener('change', (e) => { @@ -447,36 +695,38 @@ document.getElementById('xaiModel').addEventListener('change', (e) => { orModelSel.addEventListener('change', () => { syncOpenrouterCustom(); - if (orModelSel.value !== '__custom__') { - chrome.storage.local.set({ openrouterModel: orModelSel.value }); + if (orModelSel.value !== CUSTOM) { + chrome.storage.local.set({ openrouterModel: canonicalModel('openrouter', orModelSel.value) }); } }); document.getElementById('save-openrouter-model').addEventListener('click', () => { - const v = orCustomInput.value.trim(); + const v = canonicalModel('openrouter', orCustomInput.value); if (!v) return; chrome.storage.local.set({ openrouterModel: v }, () => showStatus('✓ Model set: ' + v, '#4ade80')); }); -document.getElementById('link-openrouter') +document + .getElementById('link-openrouter') ?.addEventListener('click', () => openTab('https://openrouter.ai/models')); // ─── Nous Research — model selector + API key ──────────────────────────────── nousModelSel.addEventListener('change', () => { syncNousCustom(); - if (nousModelSel.value !== '__custom__') { - chrome.storage.local.set({ nousModel: nousModelSel.value }); + if (nousModelSel.value !== CUSTOM) { + chrome.storage.local.set({ nousModel: canonicalModel('nous', nousModelSel.value) }); } }); document.getElementById('save-nous-model').addEventListener('click', () => { - const v = nousCustomInput.value.trim(); + const v = canonicalModel('nous', nousCustomInput.value); if (!v) return; chrome.storage.local.set({ nousModel: v }, () => showStatus('✓ Model set: ' + v, '#4ade80')); }); -document.getElementById('link-nous-models') +document + .getElementById('link-nous-models') ?.addEventListener('click', () => openTab('https://portal.nousresearch.com')); document.getElementById('btn-nous').addEventListener('click', () => { @@ -489,7 +739,8 @@ document.getElementById('btn-nous').addEventListener('click', () => { }); }); -document.getElementById('link-nous') +document + .getElementById('link-nous') ?.addEventListener('click', () => openTab('https://portal.nousresearch.com')); document.getElementById('save-nous').addEventListener('click', () => { @@ -507,7 +758,9 @@ document.getElementById('btn-xai').addEventListener('click', async () => { const btn = document.getElementById('btn-xai'); const { xaiKey, xaiRefresh } = await chrome.storage.local.get(['xaiKey', 'xaiRefresh']); if (xaiKey || xaiRefresh) { - chrome.storage.local.remove(['xaiKey', 'xaiRefresh', 'xaiExpiry', 'xaiCredentials'], () => setConnected('xai', null)); + chrome.storage.local.remove(['xaiKey', 'xaiRefresh', 'xaiExpiry', 'xaiCredentials'], () => + setConnected('xai', null) + ); return; } @@ -531,7 +784,9 @@ document.getElementById('btn-xai').addEventListener('click', async () => { `; const verifyUrl = dc.verification_uri_complete || dc.verification_uri; - document.getElementById('xai-verify-link').addEventListener('click', () => chrome.tabs.create({ url: verifyUrl })); + document + .getElementById('xai-verify-link') + .addEventListener('click', () => chrome.tabs.create({ url: verifyUrl })); chrome.tabs.create({ url: verifyUrl }); document.getElementById('xai-copy-code').addEventListener('click', () => { @@ -543,11 +798,14 @@ document.getElementById('btn-xai').addEventListener('click', async () => { const token = await pollXaiDeviceToken(dc.device_code, { intervalSec: dc.interval || 5, expiresInSec: dc.expires_in, - onPending: () => { pollStatus.textContent = '● Still waiting…'; }, + onPending: () => { + pollStatus.textContent = '● Still waiting…'; + }, }); const accessToken = await storeXaiOAuthTokens(token); - panel.innerHTML = '

✓ Connected to Grok via SuperGrok

'; + panel.innerHTML = + '

✓ Connected to Grok via SuperGrok

'; setConnected('xai', accessToken, { method: 'oauth' }); } catch (err) { setButtonBusy(btn, false); @@ -555,11 +813,11 @@ document.getElementById('btn-xai').addEventListener('click', async () => { } }); -document.getElementById('toggle-xai') +document + .getElementById('toggle-xai') ?.addEventListener('click', () => document.getElementById('panel-xai').classList.toggle('open')); -document.getElementById('link-xai') - ?.addEventListener('click', () => openTab('https://console.x.ai')); +document.getElementById('link-xai')?.addEventListener('click', () => openTab('https://console.x.ai')); document.getElementById('save-xai')?.addEventListener('click', () => { const key = document.getElementById('xaiKey').value.trim(); @@ -584,7 +842,8 @@ document.getElementById('btn-google').addEventListener('click', () => { }); }); -document.getElementById('link-aistudio') +document + .getElementById('link-aistudio') ?.addEventListener('click', () => openTab('https://aistudio.google.com/app/apikey')); document.getElementById('save-google').addEventListener('click', () => { @@ -593,43 +852,74 @@ document.getElementById('save-google').addEventListener('click', () => { chrome.storage.local.set({ googleKey: key }, () => { document.getElementById('googleKey').value = ''; setConnected('google', key, { method: 'apikey' }); + loadLiveModelCatalogs(); }); }); -// ─── Anthropic — Console API key (sk-ant-api…) ─────────────────────────────── -// Subscription "Sign in with Claude" was removed: Anthropic locks Claude-subscription -// tokens to the Claude Code client and prohibits subscription auth in third-party apps, -// so a Console API key is the only supported path. +// ─── Anthropic — Claude OAuth or Console API key ───────────────────────────── document.getElementById('btn-anthropic').addEventListener('click', () => { - chrome.storage.local.get('anthropicKey', ({ anthropicKey }) => { - if (anthropicKey) { - // Also sweep any legacy OAuth keys left by an older build. - chrome.storage.local.remove( - ['anthropicKey', 'anthropicRefresh', 'anthropicExpiry', 'anthropicCredentials'], - () => setConnected('anthropic', null) - ); + chrome.storage.local.get(['anthropicKey', ANTHROPIC_ACCESS_KEY, ANTHROPIC_REFRESH_KEY], async (s) => { + if (s.anthropicKey || s[ANTHROPIC_ACCESS_KEY] || s[ANTHROPIC_REFRESH_KEY]) { + await chrome.storage.local.remove(['anthropicKey']); + await clearAnthropicOAuthTokens(); + setConnected('anthropic', null); return; } document.getElementById('panel-anthropic').classList.toggle('open'); }); }); -document.getElementById('toggle-anthropic') +document + .getElementById('toggle-anthropic') ?.addEventListener('click', () => document.getElementById('panel-anthropic').classList.toggle('open')); -document.getElementById('link-anthropic') +document + .getElementById('link-anthropic') ?.addEventListener('click', () => openTab('https://console.anthropic.com/settings/keys')); -document.getElementById('save-anthropic')?.addEventListener('click', () => { +document.getElementById('anthropic-oauth-start')?.addEventListener('click', async () => { + const btn = document.getElementById('anthropic-oauth-start'); + try { + setButtonBusy(btn, true, 'Opening…'); + const { verifier, challenge } = await createAnthropicPkcePair(); + await chrome.storage.local.set({ [ANTHROPIC_OAUTH_VERIFIER_KEY]: verifier }); + openTab(buildAnthropicAuthorizeUrl({ verifier, challenge })); + showStatus('Paste the Claude code here after approving sign-in.', '#818cf8'); + document.getElementById('anthropicOAuthCode')?.focus(); + } catch (err) { + showStatus('✗ Claude sign-in: ' + (err?.message || err), '#f87171'); + } finally { + setButtonBusy(btn, false); + } +}); + +document.getElementById('save-anthropic-oauth')?.addEventListener('click', async () => { + const input = document.getElementById('anthropicOAuthCode'); + const authCode = input.value.trim(); + if (!authCode) return; + try { + const s = await chrome.storage.local.get(ANTHROPIC_OAUTH_VERIFIER_KEY); + const verifier = s[ANTHROPIC_OAUTH_VERIFIER_KEY]; + if (!verifier) throw new Error('Start Claude sign-in first.'); + const tokens = await exchangeAnthropicCode({ authCode, verifier }); + await saveAnthropicOAuthTokens(tokens); + await chrome.storage.local.remove(['anthropicKey', ANTHROPIC_OAUTH_VERIFIER_KEY]); + input.value = ''; + setConnected('anthropic', tokens.access, { method: 'oauth' }); + showStatus('✓ Connected to Claude', '#4ade80'); + } catch (err) { + showStatus('✗ Claude sign-in: ' + (err?.message || err), '#f87171'); + } +}); + +document.getElementById('save-anthropic')?.addEventListener('click', async () => { const key = document.getElementById('anthropicApiKey').value.trim(); if (!key) return; - chrome.storage.local.set({ anthropicKey: key }, () => { - chrome.storage.local.remove(['anthropicRefresh', 'anthropicExpiry', 'anthropicCredentials'], () => { - document.getElementById('anthropicApiKey').value = ''; - setConnected('anthropic', key, { method: 'apikey' }); - }); - }); + await chrome.storage.local.set({ anthropicKey: key }); + await clearAnthropicOAuthTokens(); + document.getElementById('anthropicApiKey').value = ''; + setConnected('anthropic', key, { method: 'apikey' }); }); // ─── OpenRouter — OAuth via chrome.identity ────────────────────────────────── @@ -647,7 +937,8 @@ document.getElementById('btn-openrouter').addEventListener('click', async () => const { verifier, challenge } = await createPkcePair(); const redirectUrl = chrome.identity.getRedirectURL(); - const authUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl)}` + + const authUrl = + `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl)}` + `&code_challenge=${challenge}&code_challenge_method=S256`; const responseUrl = await new Promise((resolve, reject) => { @@ -675,7 +966,9 @@ document.getElementById('btn-openrouter').addEventListener('click', async () => const { key } = await res.json(); if (!key) throw new Error('OpenRouter returned no key'); - chrome.storage.local.set({ openrouterKey: key }, () => setConnected('openrouter', key, { method: 'oauth' })); + chrome.storage.local.set({ openrouterKey: key }, () => + setConnected('openrouter', key, { method: 'oauth' }) + ); } catch (err) { setButtonBusy(btn, false); showStatus('✗ OpenRouter: ' + err.message, '#f87171'); diff --git a/output-tab.html b/output-tab.html index d3dddb4..1159b07 100644 --- a/output-tab.html +++ b/output-tab.html @@ -1,957 +1,4241 @@ - + - - - -RepoLens - - - - - - - - -
-
-
-
Starting…
- -
-
-
- - - -
- - - - - -