diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..be93132 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,51 @@ +name: Bug report +description: Report a reproducible problem in Perspectica. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Do not include provider keys, ChatGPT tokens, private article text, or exported logs with secrets. + - type: input + id: version + attributes: + label: Perspectica version + placeholder: v0.1.0 or development commit + validations: + required: true + - type: input + id: browser + attributes: + label: Browser and version + placeholder: Chrome 140 on macOS 15 + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + description: Include the provider choice and whether the issue survives a fresh profile. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior and sanitized diagnostics + validations: + required: true + - type: checkboxes + id: checks + attributes: + label: Safety checks + options: + - label: I removed credentials, tokens, private article text, and sensitive URLs. + required: true + - label: This is not a security vulnerability; I will use private reporting for security issues. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b05fa50 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Private security report + url: https://github.com/ + about: Use the repository owner's private vulnerability-reporting channel; do not disclose exploit details publicly. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..2d09dbd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest a user-facing improvement or developer change. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem to solve + description: Who is affected and what outcome is missing? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: boundaries + attributes: + label: Product boundaries + options: + - label: This request does not require silently installing browser policy or automating a provider website. + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ba8432b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## Summary + + + +## Validation + +- [ ] `pnpm verify` +- [ ] `pnpm verify:release` (when packaging or manifest behavior changes) +- [ ] `pnpm audit --prod --audit-level=high` +- [ ] `cargo test --manifest-path tools/installer/Cargo.toml --locked` (when installer changes) + +## Security and privacy review + +- [ ] No credentials, raw article corpus, local profiles, or generated release output committed. +- [ ] New host permissions are documented in `docs/permissions.md` and requested only with a user gesture. +- [ ] Provider/network behavior is documented in `docs/provider-boundaries.md`. +- [ ] This change does not automate ChatGPT UI, parse DuckDuckGo HTML, or add remote executable code. +- [ ] Store listing, privacy, threat-model, or retention docs updated if behavior changed. + +## Release notes + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdb1482..b30146a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,15 +20,15 @@ jobs: timeout-minutes: 15 steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: version: 9.15.4 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: pnpm @@ -44,3 +44,6 @@ jobs: - name: Audit production dependencies run: pnpm audit --prod --audit-level=high + + - name: Test release installer + run: cargo test --manifest-path tools/installer/Cargo.toml --locked diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..340619e --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,41 @@ +name: Documentation Pages + +on: + push: + branches: [main] + paths: + - "docs/site/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Configure Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + + - name: Upload static support pages + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: docs/site + + - name: Deploy Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..895127e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,211 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + id-token: write + attestations: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + extension: + name: Verify and package extension + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 9.15.4 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify release tag and package versions + env: + TAG: ${{ github.ref_name }} + run: node scripts/check-release-version.mjs "$TAG" + + - name: Audit production dependencies + run: pnpm audit --prod --audit-level=high + + - name: Verify and package production extension + run: pnpm verify:release + + - name: Verify generated manifest version + shell: bash + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + expected="${TAG#v}" + EXPECTED_VERSION="$expected" node <<'NODE' + const fs = require("node:fs"); + const expected = process.env.EXPECTED_VERSION; + const manifest = JSON.parse( + fs.readFileSync("apps/extension/.output/chrome-mv3/manifest.json", "utf8"), + ); + if (manifest.version !== expected) { + throw new Error( + `generated manifest version ${manifest.version} does not match ${expected}`, + ); + } + console.log(`Generated manifest version ${expected} matches release tag.`); + NODE + + - name: Prepare versioned extension artifact + id: extension + shell: bash + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + mkdir -p release + source_zip="$(find apps/extension/.output -maxdepth 1 -type f -name '*.zip' -print -quit)" + test -n "$source_zip" + zip_name="perspectica-extension-${TAG}.zip" + cp "$source_zip" "release/$zip_name" + echo "zip_name=$zip_name" >> "$GITHUB_OUTPUT" + + - name: Generate SPDX SBOM + uses: anchore/sbom-action@d94f46e13c6c62f59525ac9a1e147a99dc0b9bf5 # v0.17.0 + with: + path: release/${{ steps.extension.outputs.zip_name }} + format: spdx-json + output-file: release/perspectica-sbom.spdx.json + + - name: Upload extension artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-extension + path: release/ + if-no-files-found: error + retention-days: 14 + + installer-macos: + name: Build universal macOS helper + runs-on: macos-14 + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Add Rust targets + run: rustup target add x86_64-apple-darwin aarch64-apple-darwin + + - name: Test helper + run: cargo test --manifest-path tools/installer/Cargo.toml --locked + + - name: Build universal helper + shell: bash + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + cargo build --manifest-path tools/installer/Cargo.toml --locked --release --target x86_64-apple-darwin + cargo build --manifest-path tools/installer/Cargo.toml --locked --release --target aarch64-apple-darwin + mkdir -p release/perspectica-installer-macos + lipo -create \ + tools/installer/target/x86_64-apple-darwin/release/perspectica-installer \ + tools/installer/target/aarch64-apple-darwin/release/perspectica-installer \ + -output release/perspectica-installer-macos/perspectica-installer + chmod +x release/perspectica-installer-macos/perspectica-installer + cp tools/installer/README.md release/perspectica-installer-macos/README.md + tar -C release -czf "release/perspectica-installer-macos-universal-${TAG}.tar.gz" perspectica-installer-macos + rm -rf release/perspectica-installer-macos + + - name: Upload macOS helper + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-installer-macos + path: release/ + if-no-files-found: error + retention-days: 14 + + installer-windows: + name: Build Windows helper + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Test helper + run: cargo test --manifest-path tools/installer/Cargo.toml --locked + + - name: Build Windows helper + shell: pwsh + env: + TAG: ${{ github.ref_name }} + run: | + cargo build --manifest-path tools/installer/Cargo.toml --locked --release + New-Item -ItemType Directory -Force release/perspectica-installer-windows | Out-Null + Copy-Item tools/installer/target/release/perspectica-installer.exe release/perspectica-installer-windows/ + Copy-Item tools/installer/README.md release/perspectica-installer-windows/README.md + Compress-Archive -Path release/perspectica-installer-windows/* -DestinationPath "release/perspectica-installer-windows-x64-$env:TAG.zip" + Remove-Item -Recurse -Force release/perspectica-installer-windows + + - name: Upload Windows helper + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-installer-windows + path: release/ + if-no-files-found: error + retention-days: 14 + + publish: + name: Attest and publish release + needs: [extension, installer-macos, installer-windows] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Download release artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-* + path: release + merge-multiple: true + + - name: Generate checksums + run: | + set -euo pipefail + cd release + find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 \ + | sort -z \ + | xargs -0 sha256sum \ + | sed 's# \./# #' > SHA256SUMS + + - name: Attest release files + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: release/* + + - name: Upload release evidence bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: perspectica-${{ github.ref_name }}-release-evidence + path: release/ + if-no-files-found: error + retention-days: 14 + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: gh release create "$TAG" release/* --verify-tag --generate-notes --title "Perspectica $TAG" diff --git a/.gitignore b/.gitignore index 2b0c9b4..9e8679d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ **/data/ **/dist/ **/node_modules/ +**/target/ **/*.tsbuildinfo .turbo/ .playwright-cli/ diff --git a/.prettierignore b/.prettierignore index fbb7efe..47b88cf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,4 +7,5 @@ dist node_modules output pnpm-lock.yaml +**/target/ tmp diff --git a/README.md b/README.md index 4dfa069..233663f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,26 @@ # Perspectica +## Install Perspectica + +### Chrome Web Store (recommended) + +The Chrome Web Store is the consumer installation and automatic-update channel. Open the official +listing, confirm the publisher and permissions, and choose **Add to Chrome**. Chrome, Edge, and +Brave are supported on macOS and Windows; the release checklist remains policy-conditional until +the public listing has passed review. + +### GitHub developer release + +Tagged [GitHub Releases](https://github.com/drperky20/perspectica/releases) include the self-contained extension ZIP, checksums, an +SBOM, provenance, and small macOS/Windows setup helpers. The helper verifies the ZIP, installs it +to a stable local directory, detects supported browsers, opens the selected browser's extensions +page, and reveals the directory to choose with **Load unpacked**. Browser security still requires +that one visible Developer Mode action; the helper never changes browser policy or silently +installs code. + +See [browser support](docs/browser-support.md), [release installation](tools/installer/README.md), +and [troubleshooting](docs/troubleshooting.md) before using the developer channel. + Perspectica is a self-contained Chrome side-panel extension for source-grounded news transparency. It examines political framing, bias signals, journalist context, supporting and contradicting information, and the sources cited by an article. @@ -7,7 +28,7 @@ contradicting information, and the sources cited by an article. Perspectica helps readers inspect an article. It does not replace reading the article, determine whether a person is trustworthy, or assign a permanent political label to a publication. -## About us +## Built by Perspectica was created by: @@ -16,25 +37,29 @@ Perspectica was created by: - **Mathew Estis** — Developer & project coordination - **Jordan Allen** — Brand design & QA testing -The same team credits are available from the extension's first-run **About us** panel. +The same team credits are available from the extension's first-run **About us** section and +Settings → **About** screen. ## How it works Everything runs inside the installed extension: -1. The user opens Perspectica and chooses **Login with ChatGPT**. Chrome requests only the narrow - OpenAI origins needed for the device flow and inference. -2. During article-access onboarding, the user separately grants a remembered all-sites permission - so Perspectica can extract the active page when a report starts or is reopened. -3. An offscreen extension document runs the analysis pipeline so MV3 service-worker suspension - does not interrupt longer work. -4. The extension uses the user's connected ChatGPT account for inference. -5. Research uses either the user's Exa API key or ChatGPT hosted web search when the selected - account and model support it. -6. Validated pipeline events are appended to an IndexedDB journal and streamed into the side panel through a reconnectable runtime port. +1. Open a news article and review the local article preview. Nothing is sent to a provider yet. +2. Choose Quick, Balanced, Deep, or Verified research and select **Analyze article**. +3. Article Lens identifies claims and framing, then one bounded research coordinator builds a + deduplicated source pool for the whole report. +4. Perspective and evidence evaluators use the same source ledger in parallel. Deterministic + validation removes unsupported quotations, unsafe URLs, duplicate claims, and wrong-lane + evidence before sections appear. +5. A reconnectable event journal streams each section to the side panel and supports cancellation, + resume, and targeted section retries without restarting completed work. -There is no Perspectica API, hosted database, localhost service, telemetry service, or required -Codex installation. +Research can use the keyless Free lane, authenticated ChatGPT discovery, or a user-supplied Exa +key. Search summaries are never quoted as source text; a quotation requires a fetched publisher +page and exact-excerpt validation. + +There is no Perspectica API, hosted database, localhost service, telemetry service, hosting bill, +or required Codex installation. ## Privacy model @@ -47,7 +72,8 @@ Codex installation. - The non-exportable encryption key is stored separately in IndexedDB. - Access tokens are kept in `chrome.storage.session` and refreshed when needed. - Exa API keys are encrypted by the same local vault. -- Article text and research queries are sent only to the providers the user selects. +- Article text and research queries are sent only after Analyze and only to the configured + inference/research providers and validated public source pages needed for the requested report. - Perspectica does not operate a server that receives credentials, article text, or analysis history. @@ -66,7 +92,8 @@ apps/extension/ WXT Manifest V3 extension, side panel, background and off packages/contracts/ shared Zod wire contracts, budgets, events and evidence graph types packages/extraction/ page-to-article extraction and deterministic ArticleIndex construction packages/intelligence/ Article Lens, adaptive planning, retrieval coordination and report projection -docs/ current architecture, privacy, release and product notes +tools/installer/ audited GitHub developer-channel setup helper +docs/ architecture, privacy, security, release and support notes ``` The implemented V2 runtime is documented in [`docs/architecture-v2.md`](docs/architecture-v2.md). @@ -137,16 +164,20 @@ See [`docs/public-deployment.md`](docs/public-deployment.md) for the store check ## Analysis architecture V2 builds one compact `ArticleIndex`, creates an adaptive `AnalysisPlan`, and runs a global -`RetrievalCoordinator` against a shared source ledger and evidence graph. Fast, balanced, and deep -modes change passage, mission, concurrency, deadline, and output budgets. Providers return +`RetrievalCoordinator` against a shared source ledger and evidence graph. Quick, Balanced, Deep, +and Verified depths change passage, mission, concurrency, source, model-call, and time budgets. +Providers return candidate sources only; a bounded adjudication step maps exact candidate IDs to article claims and relationships, and centralized validation is required before an assertion can serve any report section. The side panel only projects validated ledger assertions. -Exa uses bounded search requests with returned text/highlights. Native ChatGPT web search is one -bounded global search workflow: its URL-attributed results are `search-summary` evidence and are -never treated as page transcripts or quoteable excerpts. Login with ChatGPT remains the sole -account/authentication path; Exa is an optional research provider key, not a second account system. +The one-dimensional spectrum stays on `-3…+3`. Article framing supplies exactly half of the +placement evidence, while verified publication and journalist context share the remaining half. +Outside reporting may refine context but cannot manufacture an article position. “Unclear” is +reserved for runs where the article and all contextual research provide no defensible signal. + +See [V2 architecture](docs/architecture-v2.md), [provider boundaries](docs/provider-boundaries.md), +[privacy](docs/privacy.md), and the [threat model](docs/threat-model.md). ## Optional calibration tooling diff --git a/apps/extension/entrypoints/offscreen/main.ts b/apps/extension/entrypoints/offscreen/main.ts index 812ec15..4bf43b8 100644 --- a/apps/extension/entrypoints/offscreen/main.ts +++ b/apps/extension/entrypoints/offscreen/main.ts @@ -7,7 +7,9 @@ import { type AnalysisArtifacts, type PipelineTelemetry, } from "@perspectica/intelligence"; +import { researchProfileFor } from "@perspectica/contracts"; import { EvidenceLedger } from "@perspectica/intelligence"; +import type { PipelineEvent } from "@perspectica/contracts/events"; import type { EvidenceRetriever } from "@perspectica/contracts/evidence"; import { ExtensionResponseSchema, @@ -21,6 +23,11 @@ import { } from "../../src/runtime/messages"; import { ExaEvidenceRetriever } from "../../src/providers/exa-evidence"; import { NativeChatGptEvidenceRetriever } from "../../src/providers/chatgpt-evidence"; +import { FreeEvidenceRetriever } from "../../src/providers/free-evidence"; +import { + FallbackEvidenceRetriever, + type ProviderFallbackDiagnostics, +} from "../../src/providers/fallback-evidence"; import { IndexedDbAnalysisArtifactStore } from "../../src/storage/analysis-artifacts"; import { EvidenceCache } from "../../src/storage/evidence-cache"; import { describeError, redactText, serializeRedacted } from "../../src/runtime/redaction"; @@ -29,13 +36,96 @@ const activeJobs = new Map(); const artifactStore = new IndexedDbAnalysisArtifactStore(); const telemetryTails = new Map>(); +const runCaches = new Map(); +const jobCompletions = new Map>>(); +const OFFSCREEN_IDLE_CLOSE_MS = 5_000; +let offscreenCloseTimer: ReturnType | null = null; + +function trackCompletion(jobId: string, completion: Promise): void { + const completions = jobCompletions.get(jobId) ?? new Set>(); + completions.add(completion); + jobCompletions.set(jobId, completions); + void completion.then( + () => { + const current = jobCompletions.get(jobId); + current?.delete(completion); + if (current?.size === 0) jobCompletions.delete(jobId); + }, + () => { + const current = jobCompletions.get(jobId); + current?.delete(completion); + if (current?.size === 0) jobCompletions.delete(jobId); + }, + ); +} + +const TIMEOUT_ERROR = "The analysis reached its research-depth time limit. Try again."; + +function timeoutEvent(analysisId: string): PipelineEvent { + return { + type: "analysis.failed", + analysisId, + emittedAt: new Date().toISOString(), + data: { message: TIMEOUT_ERROR, retryable: true }, + }; +} function artifactKey(jobId: string, runToken: string): string { return `${jobId}:${runToken}`; } +function runCacheScope( + jobId: string, + runToken: string, + cacheScope: string | null | undefined, +): string { + const providerScope = (cacheScope?.trim() || "global").slice(0, 190); + return `run:${providerScope}:${jobId}:${runToken}`.slice(0, 256); +} + +function cacheForRun( + jobId: string, + runToken: string, + cacheScope: string | null | undefined, +): EvidenceCache { + const key = artifactKey(jobId, runToken); + const existing = runCaches.get(key); + if (existing) return existing; + const cache = new EvidenceCache(runCacheScope(jobId, runToken, cacheScope)); + runCaches.set(key, cache); + return cache; +} + +async function clearRunCache(jobId: string, runToken: string): Promise { + const key = artifactKey(jobId, runToken); + const cache = runCaches.get(key); + runCaches.delete(key); + await cache?.clear().catch((error: unknown) => { + console.warn("[perspectica] run-scoped evidence cache cleanup failed", describeError(error)); + }); +} + +function cancelScheduledOffscreenClose(): void { + if (offscreenCloseTimer !== null) { + clearTimeout(offscreenCloseTimer); + offscreenCloseTimer = null; + } +} + +function scheduleOffscreenClose(): void { + cancelScheduledOffscreenClose(); + offscreenCloseTimer = setTimeout(() => { + offscreenCloseTimer = null; + if (activeJobs.size > 0) return; + void chrome.offscreen.closeDocument().catch((error: unknown) => { + console.debug("[perspectica] offscreen document was already closed", describeError(error)); + }); + }, OFFSCREEN_IDLE_CLOSE_MS); +} + function queueTelemetry( jobId: string, + runToken: string, entry: Omit & { timestamp?: string }, ): Promise { const sanitized: AnalysisLogInput = { @@ -49,7 +139,7 @@ function queueTelemetry( const previous = telemetryTails.get(jobId) ?? Promise.resolve(); const next: Promise = previous .catch(() => undefined) - .then(() => sendInternal({ type: "internal.analysis.log", jobId, entry: sanitized })) + .then(() => sendInternal({ type: "internal.analysis.log", jobId, runToken, entry: sanitized })) .then(() => undefined) .catch((error: unknown) => { console.warn("[perspectica] telemetry persistence failed", describeError(error)); @@ -83,8 +173,8 @@ async function sendInternal(request: InternalRequestInput, attempts = 3): Pro throw lastError instanceof Error ? lastError : new Error("The extension runtime is unavailable."); } -function logTelemetry(jobId: string, telemetry: PipelineTelemetry): void { - void queueTelemetry(jobId, { +function logTelemetry(jobId: string, runToken: string, telemetry: PipelineTelemetry): void { + void queueTelemetry(jobId, runToken, { level: "info", scope: "pipeline", event: "phase.snapshot", @@ -106,9 +196,11 @@ function logTelemetry(jobId: string, telemetry: PipelineTelemetry): void { async function createRetriever( jobId: string, + runToken: string, modelId: string, reasoningEffort: ReasoningEffort, providerKind: SearchProviderKind, + cacheScope: string | null | undefined, ): Promise<{ model: ReturnType>; retriever: EvidenceRetriever }> { const chatgpt = createChatGPT({ credentials: () => sendInternal({ type: "internal.auth.getTokens" }), @@ -116,44 +208,81 @@ async function createRetriever( reasoningEffort, textVerbosity: "low", }); + const cache = cacheForRun(jobId, runToken, cacheScope); + const freeRetriever = () => + new FreeEvidenceRetriever( + globalThis.fetch.bind(globalThis), + (diagnostics) => { + void queueTelemetry(jobId, runToken, { + level: diagnostics.outcome === "failed" ? "error" : "debug", + scope: "provider.free", + event: `mission.${diagnostics.outcome}`, + message: `Free research mission ${diagnostics.missionId} ${diagnostics.outcome}.`, + payload: serializeRedacted(diagnostics), + }); + }, + cache, + ); + const fallbackTelemetry = (diagnostics: ProviderFallbackDiagnostics) => { + void queueTelemetry(jobId, runToken, { + level: "warn", + scope: "provider.fallback", + event: `${diagnostics.primaryProvider}->${diagnostics.fallbackProvider}.${diagnostics.reason}`, + message: `The ${diagnostics.primaryProvider} provider returned no usable mission results; free retrieval was attempted.`, + payload: serializeRedacted(diagnostics), + }); + }; if (providerKind === "exa") { const secret = await sendInternal<{ apiKey: string }>({ type: "internal.providers.getSecret", provider: "exa", }); + const primary = new ExaEvidenceRetriever( + secret.apiKey, + undefined, + (diagnostics) => { + void queueTelemetry(jobId, runToken, { + level: diagnostics.outcome === "failed" ? "error" : "debug", + scope: "provider.exa", + event: `mission.${diagnostics.outcome}`, + message: `Exa mission ${diagnostics.missionId} ${diagnostics.outcome}.`, + payload: serializeRedacted(diagnostics), + }); + }, + cache, + ); + return { + model: chatgpt(modelId), + retriever: new FallbackEvidenceRetriever("exa", primary, freeRetriever(), fallbackTelemetry), + }; + } + if (providerKind === "free") { return { model: chatgpt(modelId), - retriever: new ExaEvidenceRetriever( - secret.apiKey, - undefined, - (diagnostics) => { - void queueTelemetry(jobId, { - level: diagnostics.outcome === "failed" ? "error" : "debug", - scope: "provider.exa", - event: `mission.${diagnostics.outcome}`, - message: `Exa mission ${diagnostics.missionId} ${diagnostics.outcome}.`, - payload: serializeRedacted(diagnostics), - }); - }, - new EvidenceCache(), - ), + retriever: freeRetriever(), }; } + const primary = new NativeChatGptEvidenceRetriever( + chatgpt, + modelId, + (diagnostics) => { + void queueTelemetry(jobId, runToken, { + level: diagnostics.outcome === "failed" ? "error" : "debug", + scope: "provider.chatgpt", + event: `global-search.${diagnostics.outcome}`, + message: "Native ChatGPT global search completed.", + payload: serializeRedacted(diagnostics), + }); + }, + cache, + ); return { model: chatgpt(modelId), - retriever: new NativeChatGptEvidenceRetriever( - chatgpt, - modelId, - (diagnostics) => { - void queueTelemetry(jobId, { - level: diagnostics.outcome === "failed" ? "error" : "debug", - scope: "provider.chatgpt", - event: `global-search.${diagnostics.outcome}`, - message: "Native ChatGPT global search completed.", - payload: serializeRedacted(diagnostics), - }); - }, - new EvidenceCache(), + retriever: new FallbackEvidenceRetriever( + "chatgpt", + primary, + freeRetriever(), + fallbackTelemetry, ), }; } @@ -164,6 +293,7 @@ async function testSearchProvider( { type: "offscreen.providers.test" } >, ): Promise<{ available: true; sourceCount: number }> { + if (command.provider === "free") return { available: true, sourceCount: 0 }; const chatgpt = createChatGPT({ credentials: () => sendInternal({ type: "internal.auth.getTokens" }), defaultModel: command.preferences.model, @@ -171,7 +301,7 @@ async function testSearchProvider( textVerbosity: "low", }); if (command.provider !== "chatgpt") - throw new Error("Only ChatGPT web search is tested in the analysis runtime."); + throw new Error("Exa API connectivity is tested from the settings key flow."); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 45_000); try { @@ -217,21 +347,45 @@ async function runJob( >, ): Promise { activeJobs.get(jobId)?.controller.abort(); + cancelScheduledOffscreenClose(); const controller = new AbortController(); activeJobs.set(jobId, { controller, runToken: command.runToken }); let sequence = command.initialSequence; const startedAt = Date.now(); + let timedOut = false; + let timeoutEventSent = false; + let hardCeilingTimer: ReturnType | undefined; try { const preferences = command.request.preferences ?? { model: "gpt-5.6-luna" as const, reasoningEffort: "medium" as const, mode: "balanced" as const, }; + const hardCeilingMs = researchProfileFor(preferences.depth ?? preferences.mode).hardCeilingMs; + hardCeilingTimer = setTimeout(() => { + if (controller.signal.aborted) return; + timedOut = true; + controller.abort(new DOMException("Analysis hard ceiling reached.", "TimeoutError")); + }, hardCeilingMs); + const emitTimeoutFailure = async (analysisId: string): Promise => { + if (timeoutEventSent) return; + await flushTelemetry(jobId); + await sendInternal({ + type: "internal.analysis.event", + jobId, + runToken: command.runToken, + sequence: ++sequence, + event: timeoutEvent(analysisId), + }); + timeoutEventSent = true; + }; const { model, retriever } = await createRetriever( jobId, + command.runToken, preferences.model, preferences.reasoningEffort, command.searchProvider, + command.cacheScope, ); for await (const event of analyzeArticle({ article: command.request.article, @@ -241,9 +395,23 @@ async function runJob( modelVersion: preferences.model, reasoningEffort: preferences.reasoningEffort, mode: preferences.mode, + depth: preferences.depth, signal: controller.signal, - onTelemetry: (telemetry) => logTelemetry(jobId, telemetry), + onTelemetry: (telemetry) => logTelemetry(jobId, command.runToken, telemetry), onArtifacts: async (artifacts) => { + // Cancellation/account reset can happen while the pipeline is + // finishing a persistence callback. Do not accept artifacts from a + // run that no longer owns the active controller; the cancel handler + // waits for this job's completion before the background clears the + // shared artifact store. + const active = activeJobs.get(jobId); + if ( + !active || + controller.signal.aborted || + active.controller !== controller || + active.runToken !== command.runToken + ) + return; for (const key of completedArtifacts.keys()) { if (key !== artifactKey(jobId, command.runToken)) completedArtifacts.delete(key); } @@ -251,7 +419,18 @@ async function runJob( await artifactStore.set(jobId, command.runToken, artifacts); }, })) { - void queueTelemetry(jobId, { + if (timedOut && event.type === "phase.changed" && event.data.phase === "cancelled") { + continue; + } + if (timedOut && event.type === "analysis.cancelled") { + await emitTimeoutFailure(event.analysisId); + return; + } + if (timedOut) { + await emitTimeoutFailure(event.analysisId); + return; + } + void queueTelemetry(jobId, command.runToken, { timestamp: event.emittedAt, level: event.type === "analysis.failed" ? "error" : "info", scope: "pipeline.events", @@ -267,6 +446,10 @@ async function runJob( event, }); } + if (timedOut) { + await emitTimeoutFailure("analysis-timeout"); + return; + } await flushTelemetry(jobId); await sendInternal({ type: "internal.analysis.finished", @@ -278,7 +461,23 @@ async function runJob( `[perspectica] job=${jobId} V2 pipeline completed durationMs=${Date.now() - startedAt}`, ); } catch (error) { - if (!controller.signal.aborted) { + if (timedOut) { + try { + await flushTelemetry(jobId); + await sendInternal({ + type: "internal.analysis.failed", + jobId, + runToken: command.runToken, + sequence: ++sequence, + error: TIMEOUT_ERROR, + }); + } catch (deliveryError) { + console.error( + "[perspectica] could not deliver hard-ceiling failure", + describeError(deliveryError), + ); + } + } else if (!controller.signal.aborted) { console.error(`[perspectica] job=${jobId} failed`, describeError(error)); await flushTelemetry(jobId); try { @@ -300,7 +499,23 @@ async function runJob( } } } finally { - if (activeJobs.get(jobId)?.controller === controller) activeJobs.delete(jobId); + if (hardCeilingTimer !== undefined) clearTimeout(hardCeilingTimer); + // Aborted runs skip the normal pre-terminal flush. Drain queued + // telemetry before acknowledging cancellation so account cleanup cannot + // be followed by a late same-job log write. + await flushTelemetry(jobId); + const ownsActiveJob = activeJobs.get(jobId)?.controller === controller; + if (ownsActiveJob && controller.signal.aborted) { + completedArtifacts.delete(artifactKey(jobId, command.runToken)); + await artifactStore.clear(jobId, command.runToken).catch((error: unknown) => { + console.warn("[perspectica] aborted artifact cleanup failed", describeError(error)); + }); + } + if (ownsActiveJob) activeJobs.delete(jobId); + if (ownsActiveJob || activeJobs.get(jobId)?.runToken !== command.runToken) { + await clearRunCache(jobId, command.runToken); + } + if (activeJobs.size === 0) scheduleOffscreenClose(); } } @@ -312,9 +527,25 @@ async function runRetryJob( >, ): Promise { activeJobs.get(jobId)?.controller.abort(); + cancelScheduledOffscreenClose(); const controller = new AbortController(); activeJobs.set(jobId, { controller, runToken: command.runToken }); let sequence = command.initialSequence; + let timedOut = false; + let hardCeilingTimer: ReturnType | undefined; + const retryPreferences = command.request.preferences ?? { + model: "gpt-5.6-luna" as const, + reasoningEffort: "medium" as const, + mode: "balanced" as const, + }; + const hardCeilingMs = researchProfileFor( + retryPreferences.depth ?? retryPreferences.mode, + ).hardCeilingMs; + hardCeilingTimer = setTimeout(() => { + if (controller.signal.aborted) return; + timedOut = true; + controller.abort(new DOMException("Analysis hard ceiling reached.", "TimeoutError")); + }, hardCeilingMs); let artifacts = completedArtifacts.get(artifactKey(jobId, command.runToken)); try { if (!artifacts) { @@ -337,16 +568,13 @@ async function runRetryJob( "The bounded retry context is unavailable after the analysis runtime restarted.", ); } - const preferences = command.request.preferences ?? { - model: "gpt-5.6-luna" as const, - reasoningEffort: "medium" as const, - mode: "balanced" as const, - }; const { model, retriever } = await createRetriever( jobId, - preferences.model, - preferences.reasoningEffort, + command.runToken, + retryPreferences.model, + retryPreferences.reasoningEffort, command.searchProvider, + command.cacheScope, ); for await (const event of retryArticleSections({ artifacts, @@ -354,9 +582,34 @@ async function runRetryJob( adjudicator: createModelEvidenceAdjudicator(model), sections: command.sections, signal: controller.signal, - onTelemetry: (telemetry) => logTelemetry(jobId, telemetry), + onTelemetry: (telemetry) => logTelemetry(jobId, command.runToken, telemetry), })) { - void queueTelemetry(jobId, { + if (timedOut && event.type === "phase.changed" && event.data.phase === "cancelled") { + continue; + } + if (timedOut && event.type === "analysis.cancelled") { + await flushTelemetry(jobId); + await sendInternal({ + type: "internal.analysis.event", + jobId, + runToken: command.runToken, + sequence: ++sequence, + event: timeoutEvent(event.analysisId), + }); + return; + } + if (timedOut) { + await flushTelemetry(jobId); + await sendInternal({ + type: "internal.analysis.event", + jobId, + runToken: command.runToken, + sequence: ++sequence, + event: timeoutEvent(event.analysisId), + }); + return; + } + void queueTelemetry(jobId, command.runToken, { timestamp: event.emittedAt, level: event.type === "analysis.failed" ? "error" : "info", scope: "pipeline.events", @@ -372,6 +625,17 @@ async function runRetryJob( event, }); } + if (timedOut) { + await flushTelemetry(jobId); + await sendInternal({ + type: "internal.analysis.failed", + jobId, + runToken: command.runToken, + sequence: ++sequence, + error: TIMEOUT_ERROR, + }); + return; + } await flushTelemetry(jobId); await sendInternal({ type: "internal.analysis.finished", @@ -380,7 +644,23 @@ async function runRetryJob( sequence: ++sequence, }); } catch (error) { - if (!controller.signal.aborted) { + if (timedOut) { + try { + await flushTelemetry(jobId); + await sendInternal({ + type: "internal.analysis.failed", + jobId, + runToken: command.runToken, + sequence: ++sequence, + error: TIMEOUT_ERROR, + }); + } catch (deliveryError) { + console.error( + "[perspectica] could not deliver retry hard-ceiling failure", + describeError(deliveryError), + ); + } + } else if (!controller.signal.aborted) { await flushTelemetry(jobId); try { await sendInternal( @@ -401,7 +681,20 @@ async function runRetryJob( } } } finally { - if (activeJobs.get(jobId)?.controller === controller) activeJobs.delete(jobId); + if (hardCeilingTimer !== undefined) clearTimeout(hardCeilingTimer); + await flushTelemetry(jobId); + const ownsActiveJob = activeJobs.get(jobId)?.controller === controller; + if (ownsActiveJob && controller.signal.aborted) { + completedArtifacts.delete(artifactKey(jobId, command.runToken)); + await artifactStore.clear(jobId, command.runToken).catch((error: unknown) => { + console.warn("[perspectica] aborted retry artifact cleanup failed", describeError(error)); + }); + } + if (ownsActiveJob) activeJobs.delete(jobId); + if (ownsActiveJob || activeJobs.get(jobId)?.runToken !== command.runToken) { + await clearRunCache(jobId, command.runToken); + } + if (activeJobs.size === 0) scheduleOffscreenClose(); } } @@ -414,14 +707,18 @@ chrome.runtime.onMessage.addListener((raw, sender, sendResponse) => { return false; } if (command.data.type === "offscreen.providers.test") { - void testSearchProvider(command.data).then( - (result) => sendResponse({ ok: true, data: result }), - (error: unknown) => - sendResponse({ - ok: false, - error: publicError(error, "ChatGPT web search is not available for this account."), - }), - ); + void testSearchProvider(command.data) + .then( + (result) => sendResponse({ ok: true, data: result }), + (error: unknown) => + sendResponse({ + ok: false, + error: publicError(error, "ChatGPT web search is not available for this account."), + }), + ) + .finally(() => { + if (activeJobs.size === 0) scheduleOffscreenClose(); + }); return true; } if (command.data.type === "offscreen.analysis.cancel") { @@ -429,13 +726,19 @@ chrome.runtime.onMessage.addListener((raw, sender, sendResponse) => { const accepted = Boolean(active && active.runToken === command.data.runToken); if (accepted && active) { active.controller.abort(); - activeJobs.delete(command.data.jobId); + const completions = jobCompletions.get(command.data.jobId); + if (completions && completions.size > 0) { + void Promise.allSettled([...completions]).then(() => + sendResponse({ accepted: true, cancelled: true }), + ); + return true; + } } sendResponse({ accepted, cancelled: accepted }); return false; } if (command.data.type === "offscreen.analysis.retry") { - void runRetryJob(command.data.jobId, command.data); + trackCompletion(command.data.jobId, runRetryJob(command.data.jobId, command.data)); sendResponse({ accepted: true }); return false; } @@ -444,7 +747,7 @@ chrome.runtime.onMessage.addListener((raw, sender, sendResponse) => { sendResponse({ accepted: true, alreadyRunning: true }); return false; } - void runJob(command.data.jobId, command.data); + trackCompletion(command.data.jobId, runJob(command.data.jobId, command.data)); sendResponse({ accepted: true }); return false; }); diff --git a/apps/extension/entrypoints/sidepanel/AnalysisProgress.test.ts b/apps/extension/entrypoints/sidepanel/AnalysisProgress.test.ts index 6af23f3..503f033 100644 --- a/apps/extension/entrypoints/sidepanel/AnalysisProgress.test.ts +++ b/apps/extension/entrypoints/sidepanel/AnalysisProgress.test.ts @@ -3,12 +3,18 @@ import { getAnalysisProgress } from "./AnalysisProgress"; import { beginExtraction, createInitialReportState } from "./report-state"; describe("analysis pipeline progress", () => { - it("exposes the five pipeline stages", () => { + it("maps five internal stages to four reader-facing phases", () => { const state = beginExtraction(); - expect(getAnalysisProgress(state)).toMatchObject({ phase: "index", completed: 0, total: 5 }); - expect( - getAnalysisProgress({ ...state, phase: "plan", phaseMessage: "Planning" }), - ).toMatchObject({ phase: "plan", label: "Planning research", completed: 1 }); + expect(getAnalysisProgress(state)).toMatchObject({ + phase: "reading", + label: "Reading article", + completed: 0, + total: 4, + }); + expect(getAnalysisProgress({ ...state, phase: "plan" })).toMatchObject({ + phase: "planning", + completed: 1, + }); expect( getAnalysisProgress({ ...state, @@ -22,17 +28,27 @@ describe("analysis pipeline progress", () => { sufficiency: "more evidence needed", }, }), - ).toMatchObject({ phase: "retrieval", detail: "2 of 4 missions checked", completed: 2 }); + ).toMatchObject({ phase: "researching", detail: "3 sources accepted", completed: 2 }); expect(getAnalysisProgress({ ...state, phase: "perspective" })).toMatchObject({ - phase: "perspective", - completed: 3, + phase: "researching", + completed: 2, }); expect(getAnalysisProgress({ ...state, phase: "composition" })).toMatchObject({ - phase: "composition", - completed: 4, + phase: "synthesizing", + completed: 3, }); }); + it("marks timeline steps monotonically", () => { + const progress = getAnalysisProgress({ ...beginExtraction(), phase: "perspective" }); + expect(progress?.steps.map((step) => step.status)).toEqual([ + "complete", + "complete", + "active", + "waiting", + ]); + }); + it("does not render after a terminal state", () => { expect(getAnalysisProgress(createInitialReportState())).toBeNull(); expect(getAnalysisProgress({ ...createInitialReportState(), phase: "complete" })).toBeNull(); diff --git a/apps/extension/entrypoints/sidepanel/AnalysisProgress.tsx b/apps/extension/entrypoints/sidepanel/AnalysisProgress.tsx index d39544b..b17e390 100644 --- a/apps/extension/entrypoints/sidepanel/AnalysisProgress.tsx +++ b/apps/extension/entrypoints/sidepanel/AnalysisProgress.tsx @@ -3,36 +3,92 @@ import type { ReportPhase, ReportState } from "./report-state"; import { isAnalysisActive } from "./report-state"; import { formatElapsedMs } from "./streaming"; -const phaseLabels: Readonly< - Record, string> -> = { - index: "Indexing the article", - plan: "Planning research", - retrieval: "Retrieving evidence", - perspective: "Comparing perspectives", - composition: "Composing the report", -}; - -const phaseDetails: Readonly< - Record, string> -> = { - index: "Finding the article structure and claims", - plan: "Selecting the smallest useful research missions", - retrieval: "Checking accepted sources against the article", - perspective: "Calibrating framing and publication context", - composition: "Organizing the evidence into report sections", -}; - -const phaseOrder = ["index", "plan", "retrieval", "perspective", "composition"] as const; -type ActivePhase = (typeof phaseOrder)[number]; +export type ReaderProgressPhase = "reading" | "planning" | "researching" | "synthesizing"; -export interface AnalysisProgressState { - phase: ActivePhase; +const readerPhases: ReadonlyArray<{ + phase: ReaderProgressPhase; label: string; - detail: string; - completed: number; - total: number; - elapsedMs: number; + waitingDetail: string; +}> = [ + { phase: "reading", label: "Reading article", waitingDetail: "Waiting to read the page" }, + { + phase: "planning", + label: "Planning research", + waitingDetail: "Waiting for the article review", + }, + { + phase: "researching", + label: "Checking sources", + waitingDetail: "Waiting for the research plan", + }, + { + phase: "synthesizing", + label: "Preparing report", + waitingDetail: "Waiting for verified evidence", + }, +] as const; + +const phaseIndex = new Map(readerPhases.map((entry, index) => [entry.phase, index])); + +function toReaderPhase(phase: ReportPhase): ReaderProgressPhase | null { + switch (phase) { + case "index": + return "reading"; + case "plan": + return "planning"; + case "retrieval": + case "perspective": + return "researching"; + case "composition": + return "synthesizing"; + default: + return null; + } +} + +function readySectionCount(state: ReportState): number { + return Math.min( + 6, + [ + state.compass, + state.bias, + state.journalistContext, + state.supporting, + state.contradicting, + state.additionalContext, + ].filter((section) => section.status === "ready" || section.status === "empty").length, + ); +} + +function activeDetail(state: ReportState, phase: ReaderProgressPhase): string { + if (phase === "reading") { + if (state.indexed) { + return `${state.indexed.paragraphCount} paragraphs indexed`; + } + return "Finding the article structure and claims"; + } + if (phase === "planning") { + const missions = state.plan?.missions.length ?? 0; + return missions > 0 + ? `${missions} research mission${missions === 1 ? "" : "s"} planned` + : "Choosing the smallest useful research plan"; + } + if (phase === "researching") { + const sources = Math.max(state.research?.acceptedSources ?? 0, state.ledger.sourceCount); + const missions = state.research?.completedMissions ?? 0; + if (sources > 0) { + return `${sources} source${sources === 1 ? "" : "s"} accepted`; + } + if (missions > 0) { + return `${missions} research mission${missions === 1 ? "" : "s"} checked`; + } + return "Comparing independent evidence"; + } + + const sections = readySectionCount(state); + return sections > 0 + ? `${sections} of 6 report sections ready` + : "Validating evidence and citations"; } function elapsedSince(startedAt: string | null, now: number): number { @@ -41,25 +97,45 @@ function elapsedSince(startedAt: string | null, now: number): number { return Number.isFinite(timestamp) ? Math.max(0, now - timestamp) : 0; } +export interface AnalysisProgressState { + phase: ReaderProgressPhase; + label: string; + detail: string; + completed: number; + total: number; + elapsedMs: number; + steps: ReadonlyArray<{ + phase: ReaderProgressPhase; + label: string; + detail: string; + status: "waiting" | "active" | "complete"; + }>; +} + export function getAnalysisProgress( state: ReportState, now = Date.now(), ): AnalysisProgressState | null { if (!isAnalysisActive(state.phase)) return null; - const phase = state.phase as ActivePhase; - const phaseIndex = phaseOrder.indexOf(phase); - const research = state.research; - const detail = - phase === "retrieval" && research - ? `${research.completedMissions} of ${research.totalMissions} missions checked` - : phaseDetails[phase]; + const phase = toReaderPhase(state.phase); + if (!phase) return null; + const activeIndex = phaseIndex.get(phase) ?? 0; + const detail = activeDetail(state, phase); + const entry = readerPhases[activeIndex]!; + return { phase, - label: phaseLabels[phase], + label: entry.label, detail, - completed: phaseIndex, - total: phaseOrder.length, + completed: activeIndex, + total: readerPhases.length, elapsedMs: elapsedSince(state.startedAt, now), + steps: readerPhases.map((step, index) => ({ + ...step, + detail: + index < activeIndex ? "Complete" : index === activeIndex ? detail : step.waitingDetail, + status: index < activeIndex ? "complete" : index === activeIndex ? "active" : "waiting", + })), }; } @@ -81,28 +157,35 @@ export function AnalysisProgress({ state, onCancel }: AnalysisProgressProps) { const liveProgress = progress ? getAnalysisProgress(state, now) : null; if (!liveProgress) return null; - const width = Math.max(0.08, liveProgress.completed / liveProgress.total); return ( -
-
+
+

+ {liveProgress.label}. {liveProgress.detail} +

+
    + {liveProgress.steps.map((step) => ( +
  1. +
  2. + ))} +
+
+ + {onCancel ? ( + + ) : null} +
+
); } diff --git a/apps/extension/entrypoints/sidepanel/App.test.ts b/apps/extension/entrypoints/sidepanel/App.test.ts index ba6ade7..c9e6a53 100644 --- a/apps/extension/entrypoints/sidepanel/App.test.ts +++ b/apps/extension/entrypoints/sidepanel/App.test.ts @@ -1,7 +1,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; -import { PartialReportNotice, ProvisionalCompassWarning } from "./App"; +import { AnalyzeScreen, PartialReportNotice, ProvisionalCompassWarning } from "./App"; describe("partial report feedback", () => { it("clearly identifies a partial report and offers a bounded retry", () => { @@ -20,3 +20,44 @@ describe("partial report feedback", () => { expect(html).toContain("preliminary placement may change"); }); }); + +describe("explicit article analysis", () => { + it("keeps Analyze disabled until the local article preview is ready", () => { + const html = renderToStaticMarkup( + createElement(AnalyzeScreen, { + metadata: null, + previewStatus: "loading", + onAnalyze: vi.fn(), + onOpenSettings: vi.fn(), + }), + ); + + expect(html).toContain("Article preview"); + expect(html).toContain("Reading article…"); + expect(html).toContain("disabled"); + expect(html).toContain("Research begins only when you select Analyze."); + }); + + it("renders extracted article metadata and the selected research depth", () => { + const html = renderToStaticMarkup( + createElement(AnalyzeScreen, { + metadata: { + title: "A verified local preview", + author: "Riley Reporter", + publication: "Example News", + publishedAt: "2026-08-03T12:00:00.000Z", + contentType: "news", + }, + previewStatus: "ready", + researchDepth: "deep", + onAnalyze: vi.fn(), + onOpenSettings: vi.fn(), + }), + ); + + expect(html).toContain("A verified local preview"); + expect(html).toContain("Example News · By Riley Reporter"); + expect(html).toContain('aria-valuetext="Deep"'); + expect(html).toContain("Analyze article"); + }); +}); diff --git a/apps/extension/entrypoints/sidepanel/App.tsx b/apps/extension/entrypoints/sidepanel/App.tsx index 6d8cfdf..ffea2a6 100644 --- a/apps/extension/entrypoints/sidepanel/App.tsx +++ b/apps/extension/entrypoints/sidepanel/App.tsx @@ -1,5 +1,7 @@ import { + lazy, memo, + Suspense, useCallback, useEffect, useRef, @@ -16,6 +18,7 @@ import type { ReaderCopy, ReaderCitation, SourceListResult, + ResearchDepth, } from "@perspectica/contracts"; import type { ReportSection } from "@perspectica/contracts/report"; import { AnalysisProgress } from "./AnalysisProgress"; @@ -26,10 +29,28 @@ import { ChatGptConnectionScreen, usePerspecticaChatGpt } from "./ChatGptConnect import { TargetIcon } from "./Icons"; import { ProgressiveText } from "./ProgressiveText"; import { Section } from "./Section"; -import { SettingsScreen } from "./SettingsScreen"; import type { SettingsPreferences } from "./SettingsScreen"; import { SearchSetupScreen } from "./SearchSetupScreen"; -import { DEFAULT_ANALYSIS_PREFERENCES } from "./preferences"; +import { ResearchDepthControl } from "./DepthControl"; +import { DEFAULT_ANALYSIS_PREFERENCES, recommendedInferenceForDepth } from "./preferences"; +import { + acceptedReaderCopy, + buildFootnoteLedger, + FootnoteMarker, + InlineFootnotes, + SectionFootnotes, + canonicalCitationKey, + type CitationTarget, +} from "./footnotes"; +export { + acceptedReaderCopy, + buildFootnoteLedger, + canonicalCitationKey, + FootnoteMarker, + InlineFootnotes, + SectionFootnotes, +} from "./footnotes"; +export type { CitationTarget, FootnoteEntry } from "./footnotes"; import { beginExtraction, beginTargetedRetry, @@ -37,13 +58,16 @@ import { failReport, isAnalysisActive, } from "./report-state"; +import type { ReportPhase } from "./report-state"; import { ReportStore } from "./report-store"; import type { ReportSectionKey } from "./report-store"; import { extensionMode, clearAnalysisLogs, getAnalysisLogs, + getArticlePreview, getRuntimeState, + isResumableJob, streamAnalysis, type AnalysisStreamStatus, testSearchProvider, @@ -51,12 +75,34 @@ import { } from "./api"; import { subscribeRuntimePush } from "../../src/runtime/client"; import type { + ArticlePreview, ExtensionPreferences, RuntimeState, SearchProviderKind, } from "../../src/runtime/messages"; import { PERSPECTICA_RUNTIME_PROTOCOL } from "../../src/runtime/messages"; +const SettingsScreen = lazy(async () => { + const module = await import("./SettingsScreen"); + return { default: module.SettingsScreen }; +}); + +function RouteLoadingFallback() { + return ( +
+

Perspectica

+

+ Opening view… +

+ +
+ ); +} + async function copyText(text: string): Promise { try { await navigator.clipboard.writeText(text); @@ -103,7 +149,13 @@ function formatDate(value: string | null): string | null { }); } -export function SourceLink({ source }: { source: ExternalSource }) { +export function SourceLink({ + source, + footnote, +}: { + source: ExternalSource; + footnote?: { scope: string; number: number; citation: CitationTarget }; +}) { const isSearchSummary = source.citationKind === "search-summary"; return (
@@ -115,6 +167,13 @@ export function SourceLink({ source }: { source: ExternalSource }) { {source.title} + {footnote ? ( + + ) : null} {source.publication ? ` · ${source.publication}` : ""} {source.publishedAt ? ` · ${new Date(source.publishedAt).toLocaleDateString()}` : ""} @@ -132,57 +191,29 @@ export function SourceLink({ source }: { source: ExternalSource }) { ); } -interface CitationTarget { - id: string; - title: string; - publication: string; - url: string; - citationKind?: "source-excerpt" | "search-summary"; -} - -function InlineCitations({ - citationIds, - citations, -}: { - citationIds: string[]; - citations: ReadonlyMap; -}) { - const accepted = citationIds.flatMap((id) => { - const citation = citations.get(id); - return citation ? [citation] : []; - }); - if (accepted.length === 0) return null; - return ( - - {accepted.map((citation) => ( - - - {citation.publication || citation.title} - - {citation.citationKind === "search-summary" ? ( - Web-search summary - ) : null} - - ))} - - ); -} - function ReaderCopyBody({ copy, citations, labels, + sectionId = "report", }: { copy: ReaderCopy; citations: ReadonlyMap; labels?: ReadonlyMap; + sectionId?: string; }) { + const accepted = acceptedReaderCopy(copy, citations); + if (!accepted) return null; + const ledger = buildFootnoteLedger( + accepted.findings.flatMap((finding) => finding.citationIds), + citations, + ); return (

- +

- {copy.findings.map((finding) => { + {accepted.findings.map((finding) => { const findingLabels = [ ...new Set( finding.citationIds.flatMap((id) => (labels?.has(id) ? [labels.get(id)!] : [])), @@ -195,7 +226,12 @@ function ReaderCopyBody({ ) : null}

- +

{finding.keySourceNote ? ( @@ -205,6 +241,7 @@ function ReaderCopyBody({
); })} + ); } @@ -218,52 +255,71 @@ function evidenceCitationMap(sources: ExternalSource[]): Map - ); +function EvidenceBody({ result, sectionId }: { result: EvidenceSectionData; sectionId: string }) { + const citations = evidenceCitationMap(result.sources); + if (result.readerCopy && acceptedReaderCopy(result.readerCopy, citations)) { + return ; } + const ledger = buildFootnoteLedger( + result.sources.map((source) => source.id), + citations, + ); return ( <>

- {result.sources.map((source) => ( - - ))} + {result.sources.map((source) => { + const number = ledger.numbers.get(source.id); + return ( + + ); + })} + ); } function JournalistBody({ result }: { result: JournalistContextResult }) { - if (result.readerCopy) { + const citations = new Map( + result.findings.map((finding) => [ + finding.id, + { + id: finding.id, + title: finding.sourceTitle, + publication: finding.publication, + url: finding.url, + publishedAt: null, + citationKind: finding.citationKind, + }, + ]), + ); + if (result.readerCopy && acceptedReaderCopy(result.readerCopy, citations)) { return ( [ - finding.id, - { - id: finding.id, - title: finding.sourceTitle, - publication: finding.publication, - url: finding.url, - citationKind: finding.citationKind, - }, - ]), - ) - } + sectionId="journalist-context" + citations={citations} /> ); } + const ledger = buildFootnoteLedger( + result.findings.map((finding) => finding.id), + citations, + ); return ( <>

@@ -279,6 +335,13 @@ function JournalistBody({ result }: { result: JournalistContextResult }) { {finding.sourceTitle} + {ledger.numbers.has(finding.id) ? ( + + ) : null} {finding.publication ? ` · ${finding.publication}` : ""} {finding.citationKind === "search-summary" ? ( Web-search summary @@ -291,35 +354,64 @@ function JournalistBody({ result }: { result: JournalistContextResult }) { ) : null} ))} + ); } function AdditionalContextBody({ result }: { result: AdditionalContextResult }) { - if (result.readerCopy) { + const citations = evidenceCitationMap(result.sources); + if (result.readerCopy && acceptedReaderCopy(result.readerCopy, citations)) { return ( - + ); } + const ledger = buildFootnoteLedger( + result.sources.map((source) => source.id), + citations, + ); return ( <>

- {result.sources.map((source) => ( - - ))} + {result.sources.map((source) => { + const number = ledger.numbers.get(source.id); + return ( + + ); + })} + ); } function SourceListBody({ result }: { result: SourceListResult }) { - if (result.sources.length === 0) { + const seen = new Set(); + const sources = result.sources.filter((source) => { + const key = canonicalCitationKey(source.url); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + if (sources.length === 0) { return

No cited works were found in the article.

; } return (
    - {result.sources.map((source) => ( + {sources.map((source) => (
  1. @@ -332,9 +424,217 @@ function SourceListBody({ result }: { result: SourceListResult }) { ); } +interface AnalyzeScreenProps { + metadata: ArticleMetadataForPreview | null; + previewStatus?: "loading" | "ready" | "error"; + previewError?: string | null; + onAnalyze: () => void; + onOpenSettings: () => void; + menuItems?: ReadonlyArray<{ label: string; onSelect: () => void }>; + researchDepth?: ResearchDepth; + onResearchDepthChange?: (depth: ResearchDepth) => void; +} + +type ArticleMetadataForPreview = { + title: string; + author: string | null; + publication: string | null; + publishedAt: string | null; + contentType: string; +}; + +export function AnalyzeScreen({ + metadata, + previewStatus = metadata ? "ready" : "loading", + previewError = null, + onAnalyze, + onOpenSettings, + menuItems, + researchDepth = "balanced" as ResearchDepth, + onResearchDepthChange, +}: AnalyzeScreenProps) { + const publishedAt = formatDate(metadata?.publishedAt ?? null); + return ( +
    + +
    +

    Article preview

    +

    + {metadata?.title ?? "Ready to read this article?"} +

    + {metadata ? ( +

    + {metadata.publication ?? "Current article"} + {metadata.author ? ` · By ${compactByline(metadata.author, metadata.publication)}` : ""} + {publishedAt ? ` · ${publishedAt}` : ""} +

    + ) : ( +

    + Perspectica previews the active article locally, then researches it only when you choose + Analyze. +

    + )} +
    + Research begins only when you select Analyze. + Your article stays local until then. +
    + + {previewError ? ( + + ) : null} + +
    +
    + ); +} + +export function DiagnosticsScreen({ onBack }: { onBack: () => void }) { + const [status, setStatus] = useState<"idle" | "copying" | "copied" | "error">("idle"); + const [clearStatus, setClearStatus] = useState<"idle" | "clearing" | "cleared" | "error">("idle"); + const [manual, setManual] = useState(null); + const textRef = useRef(null); + const copy = async () => { + if ( + !window.confirm( + "Copy this run's support log? It may include article excerpts, research queries, and model output. Credentials and authentication values are redacted.", + ) + ) { + return; + } + setStatus("copying"); + try { + const logs = await getAnalysisLogs(); + if (await copyText(logs.text)) { + setStatus("copied"); + } else { + setManual(logs.text); + setStatus("error"); + requestAnimationFrame(() => { + textRef.current?.focus(); + textRef.current?.select(); + }); + } + } catch { + setStatus("error"); + } + }; + const clear = async () => { + if (!window.confirm("Clear saved Perspectica diagnostics from this device?")) return; + setClearStatus("clearing"); + try { + await clearAnalysisLogs(); + setManual(null); + setClearStatus("cleared"); + } catch { + setClearStatus("error"); + } + }; + return ( +
    + +
    +

    Support

    +

    + Diagnostics +

    +

    + Copy a sanitized activity log when you need help. It may include article excerpts, + searches, and model output; credentials and authentication values are redacted. +

    +
    + + +

    + {status === "error" ? "Automatic copy was blocked; use the selected log below." : ""} +

    + {manual ? ( +