# Project export: Trajectory

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: Trajectory turns long ChatGPT conversations into a clear arc, useful findings, and evidence you can trace back to the exact messages.
- Devpost: https://devpost.com/software/trajectory-e4svrg
- GitHub: https://github.com/michi883/trajectory
- Video: https://www.youtube.com/embed/gVn3g5wINSM?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Michi Yamamoto (1 commits)

## Devpost submission (written by the team)

### Inspiration

I spend a lot of time working through ideas in ChatGPT. A conversation may start with one simple question, but after a while it includes different options, changing constraints, rejected suggestions, and eventually a decision. The full transcript is still there, but it becomes difficult to see how the thinking actually changed. Search can find a phrase, and a summary can shorten the conversation, but neither really shows the path from the original question to the final direction. That is why I built Trajectory. I wanted a way to look back at a long conversation and quickly understand what happened, what changed, and what mattered.

### What it does

Trajectory is a Chrome side-panel extension that analyzes the ChatGPT conversation currently open in the browser. It organizes the conversation into two views: Arc shows the conversation as a sequence of phases. Findings surfaces patterns, tensions, and decisions that appeared across multiple exchanges. Each finding is connected to the phases and messages that support it. With Show in chat, I can jump from a finding directly to the exact words behind it. The goal is not to ask people to blindly trust an AI-generated summary. Trajectory lets them inspect the evidence for themselves.

### How we built it

Trajectory is built as a Manifest V3 Chrome extension using a side panel, background worker, and content script. The content script reads the active ChatGPT conversation from the authenticated browser tab. It traverses the thread, extracts the messages, normalizes them, and creates a fingerprint for the current version of the conversation. The extension checks chrome.storage.local for an existing compatible analysis. When it needs a new one, it sends the normalized transcript to a Node.js and Express backend. The backend uses the OpenAI Responses API with Structured Outputs. GPT-5.6 generates the phases, findings, and source references. Before the result appears in the extension, additional validation checks the schema, speaker roles, message references, finding relationships, and exact source excerpts. The backend never tries to fetch a private ChatGPT URL. It also never receives the user’s ChatGPT cookies or account credentials. Analysis only starts when the user requests it, OpenAI requests use store: false, and an updated conversation cannot reuse an outdated result. I used Codex throughout development to inspect the codebase, implement focused changes, reproduce issues, write tests, and run the verification loop. The product decisions came from my own testing and critique, while Codex helped me turn those decisions into working code much faster.

### Challenges we ran into

One of the first challenges was extracting the complete conversation. ChatGPT can virtualize parts of the interface, so the messages currently visible on screen may not be the full thread. I needed controlled traversal, completeness checks, stable local message identifiers, and transcript fingerprints. Another challenge was getting the analysis to understand who actually made a decision. ChatGPT may suggest an idea, but that does not mean the user accepted it. Trajectory had to distinguish between a suggestion, rejection, acceptance, implementation, reconsideration, and an unresolved idea. Grounding was another major problem. Some early findings sounded convincing but pointed to the wrong message. I changed the system so an evidence excerpt must be verified against the referenced source before Trajectory can display or open it. The product also became too complicated at several points. Early versions included message scores, charts, fixed perspectives, correction controls, and several overlapping analysis layers. They looked impressive, but they made the conversation harder to understand. I kept removing features until the product had two clear ideas: Arc and Findings. There was also the practical challenge of fitting meaningful analysis into a narrow Chrome side panel without horizontal scrolling, clipped text, or confusing navigation.

### Accomplishments we're proud of

I am proud that Trajectory does more than produce another summary. It reconstructs how a conversation developed and surfaces patterns that may be difficult to notice while the discussion is still happening. The part I am most proud of is Show in chat. I can open a finding, inspect one of its supporting exchanges, and jump directly to the exact words behind it without losing my place in Trajectory. I also built deterministic validation around the model output, transcript-aware cache invalidation, responsive side-panel layouts, clear separation between real and prepared data, and a sanitized judge demo that can run without an OpenAI key or private ChatGPT account. The product went through many different versions, but its value can now be explained simply: A long conversation becomes a clear Arc, useful Findings, and evidence you can verify.

### What we learned

The biggest lesson was that a better summary was not enough. The more interesting problem was helping people understand how their own thinking developed over time. I also learned that AI-generated analysis becomes much more trustworthy when the user can inspect the exact messages supporting it. Another important lesson was to keep user agency clear. Trajectory should explain the reasoning and decisions already present in a conversation. It should not act as though ChatGPT made those decisions for the user. Finally, I learned that removing features can improve a product more than adding them. The biggest improvements came from deleting scores, fixed lenses, duplicated analysis, and unnecessary navigation.

### What's next

The next step is making extraction more resilient when ChatGPT changes its interface, especially for very long conversations and responses that are still streaming. I also want to preserve more of the original content, including formatting, code, and eventually images. Longer term, Trajectory could support projects that span multiple conversations, cloud synchronization, and other AI conversation platforms. Before making the analysis service broadly available, I would also add stronger authentication, quotas, distributed rate limiting, monitoring, and budget controls.

## README (from the GitHub repository)

# Trajectory

Trajectory is an experimental Manifest V3 Chrome side-panel extension that
turns one complete ChatGPT conversation into a concise, source-grounded
retrospective.

- **Arc** reconstructs what happened chronologically.
- **Findings** identify patterns across multiple exchanges.
- **Show in chat** verifies a finding against the exact source passage.

Trajectory reads the active authenticated ChatGPT tab through its content
script. The backend never fetches private ChatGPT URLs and never receives
ChatGPT cookies or account credentials.

## Judge quick test — no build required

The repository includes a prepared, sanitized browser sandbox. From the
checkout, run:

```bash
node scripts/serve-demo.mjs
```

Open [http://127.0.0.1:4173](http://127.0.0.1:4173). No dependency install,
extension build, ChatGPT account, OpenAI key, or network analysis is required.

Suggested review flow:

1. Expand phases in **Arc**.
2. Open **Findings** and select a finding.
3. Expand Supporting phases, Evidence, and Limits.
4. Change Compact/Default/Large text size with `Aa`.
5. Resize the browser to inspect the fixed side-panel layout.

The prepared sandbox demonstrates the complete Arc/Findings UI and reviewed
source evidence. `Show in chat` is simulated because the sandbox is deliberately
not attached to a private ChatGPT conversation. See
[docs/judge-guide.md](./docs/judge-guide.md) for the test scope and live-plugin
path.

## Developer sample

Requirements: Node.js 20.19 or newer and npm.

```bash
npm install
npm run sample
```

Open [http://127.0.0.1:5173/responsive-fixture.html](http://127.0.0.1:5173/responsive-fixture.html).
This isolated preview uses reviewed sample messages and analysis. It does not
open ChatGPT, call OpenAI, or install the Chrome extension.

The sample supports these query parameters:

```text
?size=compact|default|large
?view=arc|findings
```

## Installation: live extension

1. Install dependencies and create the server environment:

   ```bash
   npm install
   cp .env.example .env
   ```

2. Set at least these values in the repository-root `.env`:

   ```dotenv
   OPENAI_API_KEY=your-server-side-key
   OPENAI_MODEL=a-structured-outputs-compatible-model
   ALLOWED_ORIGINS=http://localhost:5173
   VITE_ANALYSIS_API_URL=http://localhost:8787
   ```

   Never prefix the OpenAI key with `VITE_`. Vite variables are bundled into
   browser code; the API key must remain server-side.

3. Start the extension watchers and local analysis server:

   ```bash
   npm run dev:live
   ```

4. Open `chrome://extensions`, enable **Developer mode**, choose **Load
   unpacked**, and select this repository's `dist/` directory.

5. Open a conversation at `https://chatgpt.com`, open Trajectory from the
   extension action, and choose **Analyze conversation**.

6. Reload the unpacked extension from `chrome://extensions` after a rebuild.

`npm run dev` is an alias for `npm run dev:live`.

## Supported platforms

| Component | Supported |
| --- | --- |
| Chrome extension | Desktop Chrome 116+ on macOS, Windows, and Linux |
| Conversation source | Authenticated `https://chatgpt.com` conversations |
| Analysis server | Node.js 20.19+ on macOS, Windows, or Linux |
| Prepared judge demo | Current Chrome, Edge, Firefox, or Safari with Node.js 20.19+ available to serve static files |

The extension is not currently packaged for Firefox, Safari, mobile Chrome,
the ChatGPT desktop app, or other conversation providers.

## Environment configuration

The source server, compiled server, smoke test, and live development launcher
all resolve `.env` from the repository root. To use another file, set
`TRAJECTORY_ENV_FILE` to its path.

| Variable | Required | Purpose |
| --- | --- | --- |
| `OPENAI_API_KEY` | live | Server-only OpenAI credential |
| `OPENAI_MODEL` | live | Model used by the Responses API |
| `VITE_ANALYSIS_API_URL` | extension | Analysis service URL compiled into the extension |
| `ALLOWED_ORIGINS` | server | Comma-separated browser origins allowed by CORS |
| `ANALYSIS_PIPELINE_MODE` | no | `fast` (one model call) or `thorough` (two calls) |
| `OPENAI_REASONING_EFFORT` | no | Model reasoning effort when supported |
| `OPENAI_TEXT_VERBOSITY` | no | `low`, `medium`, or `high` |
| `PORT` | no | Server port; defaults to `8787` |
| `MAX_TRANSCRIPT_CHARS` | no | Maximum normalized transcript size |
| `MAX_REQUEST_BODY_BYTES` | no | Raw JSON request ceiling |
| `ANALYSIS_TIMEOUT_MS` | no | Analysis timeout |
| `RATE_LIMIT_WINDOW_MS` | no | In-memory rate-limit window |
| `RATE_LIMIT_MAX_REQUESTS` | no | Requests allowed per window |

See [.env.example](./.env.example) for defaults and safety notes.

## Useful commands

| Command | What it does |
| --- | --- |
| `npm run demo:serve` | Serves the committed prepared demo without rebuilding |
| `npm run sample` | Serves the isolated sample UI |
| `npm run build:sample` | Regenerates the committed static demo for maintainers |
| `npm run dev:live` | Builds/watches the extension and starts the live API |
| `npm run dev:mock` | Runs deterministic extension fixture mode after a fixture is prepared |
| `npm run smoke:openai` | Makes a tiny structured OpenAI request using server configuration |
| `npm run evaluate:analysis` | Runs the offline golden quality evaluation |
| `npm run evaluate:analysis -- --live` | Intentionally evaluates the golden transcript with OpenAI |
| `npm run verify:demo` | Validates the bundled demo fixture |
| `npm test` | Runs unit and integration tests without live model calls |
| `npm run typecheck` | Checks extension, scripts, shared code, and server types |
| `npm run build` | Produces extension and server production builds |
| `npm run check` | Runs tests, type checks, and both production builds |
| `npm start` | Starts the already-built analysis server |

## Sample data

The repository contains two sanitized datasets:

- [`fixtures/trajectoryGolden.ts`](./fixtures/trajectoryGolden.ts) is a
  50-message regression fixture used by the sample page and analysis-quality
  checks.
- [`src/analysis/fixtures/trajectory-demo.analysis.json`](./src/analysis/fixtures/trajectory-demo.analysis.json)
  is a 16-message packaged extension fixture used by explicit mock mode.

The checked-in demo uses a non-routable sample conversation identifier. It is
not tied to a private ChatGPT URL. To create a deterministic extension demo for
a conversation you control, analyze it in live mode, export the verified
fixture, and import it with:

```bash
npm run demo:import-fixture -- /path/to/trajectory-demo.fixture.json
npm run verify:demo
npm run dev:mock
```

Fixture mode is always labeled `PREPARED DEMO`. Production and live analysis
never silently fall back to sample data.

## Architecture

```text
Active ChatGPT tab
  -> content-script extraction and controlled full-thread traversal
  -> background state scoped to the tab and conversation
  -> side-panel fingerprint and local cache lookup
  -> user-initiated POST /api/analyze
  -> OpenAI Responses API with Structured Outputs
  -> deterministic grounding and quality validation
  -> Arc and Findings
  -> optional exact source-range navigation
```

The analysis service exposes:

- `GET /api/health` — service and schema health information.
- `POST /api/analyze` — complete normalized conversation analysis.

Validated analyses are cached in `chrome.storage.local` by conversation
identity, transcript fingerprint, analysis schema version, and pipeline
version. A changed transcript cannot reuse a stale analysis as current.

See [docs/architecture.md](./docs/architecture.md) for the extraction,
analysis, grounding, caching, and source-navigation design.

## How GPT-5.6 and Codex were used

GPT-5.6 and Codex served different roles:

- **GPT-5.6** powered the live conversation analysis during submission
  development. The server sends the complete normalized transcript through the
  Responses API, asks for structured phases, findings, and exact source
  references, then applies

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 98 recognized source files, 914 KB.
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- OpenAI (technology) — detected in the code
- React (technology) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (112 of 112)

```
.editorconfig
.env.example
.gitattributes
.github/workflows/ci.yml
.gitignore
CONTRIBUTING.md
demo/assets/analysisValidation-Ck_iF-Bv.js
demo/assets/main-9BXGmDGJ.js
demo/assets/main-tHMsNsuP.css
demo/assets/responsive-fixture-IJIUAdVF.js
demo/responsive-fixture.html
docs/ai-development.md
docs/architecture.md
docs/development.md
docs/judge-guide.md
docs/privacy-and-deployment.md
fixtures/README.md
fixtures/trajectoryGolden.ts
LICENSE
package.json
public/manifest.json
README.md
responsive-fixture.html
scripts/dev.mjs
scripts/evaluate-analysis.ts
scripts/import-demo-fixture.ts
scripts/serve-demo.mjs
scripts/verify-analysis-path.ts
scripts/viewport-audit.mjs
SECURITY.md
server/app.test.ts
server/app.ts
server/config.test.ts
server/config.ts
server/contracts.test.ts
server/contracts.ts
server/environment.ts
server/index.ts
server/rateLimit.ts
server/services/analysisPrompt.test.ts
server/services/analysisPrompt.ts
server/services/analyzeConversation.test.ts
server/services/analyzeConversation.ts
server/services/conversationMapGrounding.ts
server/services/phaseRanges.test.ts
server/services/phaseRanges.ts
server/services/primaryFindingRelationships.ts
server/services/shortPhraseRepair.ts
server/services/sourceSpanRepair.test.ts
server/services/sourceSpanRepair.ts
server/services/trajectoryCoreNormalization.ts
server/smokeOpenAi.ts
shared/analysis.ts
shared/analysisQuality.test.ts
shared/analysisQuality.ts
shared/analysisValidation.ts
shared/labels.test.ts
shared/labels.ts
sidepanel.html
src/analysis/cache.test.ts
src/analysis/cache.ts
src/analysis/demoConfig.ts
src/analysis/demoFixture.test.ts
src/analysis/demoFixture.ts
src/analysis/diagnostics.ts
src/analysis/fixtures/trajectory-demo.analysis.json
src/analysis/request.test.ts
src/analysis/request.ts
src/analysis/sourceSpans.test.ts
src/analysis/sourceSpans.ts
src/api/trajectoryAnalysis.demo.test.ts
src/api/trajectoryAnalysis.test.ts
src/api/trajectoryAnalysis.ts
src/App.demo.test.tsx
src/App.test.tsx
src/App.tsx
src/background.ts
src/components/AnalysisStatus.test.tsx
src/components/AnalysisStatus.tsx
src/components/ArcView.test.tsx
src/components/ArcView.tsx
src/components/DisplaySettings.test.tsx
src/components/DisplaySettings.tsx
src/components/FindingsView.test.tsx
src/components/FindingsView.tsx
src/components/PhaseList.test.tsx
src/components/PhaseList.tsx
src/components/SourceExchangeCard.tsx
src/components/ViewNavigation.test.tsx
src/components/ViewNavigation.tsx
src/content.ts
src/hooks/useAnalysisCache.ts
src/hooks/useAnalysisConsent.ts
src/hooks/usePanelPreferences.ts
src/hooks/useTranscriptFingerprint.ts
src/main.tsx
src/navigation/findingDetail.ts
src/providers/chatgpt.test.ts
src/providers/chatgpt.ts
src/providers/index.ts
src/providers/types.ts
src/sourceNavigation.test.ts
src/sourceNavigation.ts
src/styles.css
src/types.ts
src/vite-env.d.ts
tsconfig.json
tsconfig.node.json
tsconfig.server.json
vite.config.ts
vite.content.config.ts
vite.demo.config.ts
```

### Dependencies

- package.json: @types/chrome@^0.0.326, @types/express@^5.0.6, @types/jsdom@^28.0.3, @types/node@^24.3.0, @types/react@^19.1.10, @types/react-dom@^19.1.7, @vitejs/plugin-react@^5.0.2, dotenv@^17.2.3, express@^5.2.1, jsdom@^29.1.1, openai@^6.16.0, react@^19.1.1, react-dom@^19.1.1, tsx@^4.21.0, typescript@~5.9.2, vite@^7.1.4, vitest@^4.1.10, zod@^4.3.5

### Recent commits (newest first)

- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### SECURITY.md

```markdown
# Security policy

Trajectory handles private conversation text and a server-side OpenAI API key.
Please do not report vulnerabilities by posting sensitive transcripts, tokens,
credentials, or exploitable details in a public issue.

Use the repository host's private vulnerability-reporting feature when it is
available. Include a minimal reproduction, affected version, impact, and a
sanitized description of any data involved.

## Supported version

This project is experimental. Security fixes are applied to the latest version
on the default branch; older snapshots are not maintained.

## Important deployment note

The included API is not a hardened multi-tenant public service. Its in-memory
rate limiter is not authentication or durable abuse protection. Operators are
responsible for adding access control, quotas, monitoring, budget limits, and
appropriate data-processing disclosures before serving external users.

```

### CONTRIBUTING.md

```markdown
# Contributing

Thanks for helping improve Trajectory.

## Before starting

- Read [README.md](./README.md), [docs/architecture.md](./docs/architecture.md),
  and [docs/ai-development.md](./docs/ai-development.md).
- Keep the product model focused on Arc, Findings, and exact source grounding.
- Do not commit private transcripts, ChatGPT URLs, conversation identifiers,
  API keys, cookies, or generated `.env` files.
- Discuss large schema, extraction, privacy, or product changes before doing a
  broad rewrite.

## Local workflow

```bash
npm ci
npm run sample
```

For live analysis, copy `.env.example` to `.env` and follow the live extension
setup in the README.

Before submitting a change:

```bash
npm run check
```

`npm run check` regenerates the committed `demo/` sandbox. Include those
generated changes when the UI or sample data changes.

Tests must not make live OpenAI calls. Add sanitized fixtures for structural
coverage and keep intentional live evaluations behind explicit commands.

## Change guidelines

- Keep provider DOM assumptions inside `src/providers` or `src/content.ts`.
- Update shared validation whenever the API schema changes.
- Increment analysis, pipeline, or cache versions when incompatible data would
  otherwise render.
- Preserve exact excerpt grounding; never compensate for a bad model reference
  by jumping to a nearby message.
- Add tests for extraction ordering, user agency, source matching, and failure
  behavior when relevant.
- Update the README or focused docs when commands or configuration change.

## Reporting results

Include the commands you ran, relevant test counts, build status, and any manual
Chrome checks that cannot be automated.

```

### package.json

```
{
  "name": "trajectory-chrome-extension",
  "private": true,
  "version": "0.6.0",
  "license": "MIT",
  "type": "module",
  "engines": {
    "node": ">=20.19.0"
  },
  "scripts": {
    "sample": "vite --host 127.0.0.1",
    "demo:serve": "node scripts/serve-demo.mjs",
    "build:sample": "vite build --config vite.demo.config.ts",
    "dev": "node scripts/dev.mjs live",
    "dev:mock": "node scripts/dev.mjs mock",
    "dev:live": "node scripts/dev.mjs live",
    "dev:fixture": "vite --host 127.0.0.1",
    "dev:extension-ui": "vite build --watch",
    "dev:content-script": "vite build --config vite.content.config.ts --watch",
    "server:dev": "tsx watch server/index.ts",
    "test": "vitest run",
    "check": "npm test && npm run build && npm run build:sample",
    "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json && npm run server:typecheck",
    "server:typecheck": "tsc -p tsconfig.server.json --noEmit",
    "build:extension": "vite build && vite build --config vite.content.config.ts",
    "server:build": "tsc -p tsconfig.server.json",
    "build": "npm run typecheck && npm run build:extension && npm run server:build",
    "server:start": "node server-dist/server/index.js",
    "start": "npm run server:start",
    "smoke:openai": "tsx server/smokeOpenAi.ts",
    "evaluate:analysis": "tsx scripts/evaluate-analysis.ts",
    "demo:import-fixture": "tsx scripts/import-demo-fixture.ts",
    "verify:analysis-path": "tsx scripts/verify-analysis-path.ts",
    "verify:demo": "vitest run src/analysis/demoFixture.test.ts src/api/trajectoryAnalysis.demo.test.ts src/App.demo.test.tsx",
    "preview": "vite preview"
  },
  "dependencies": {
    "dotenv": "^17.2.3",
    "express": "^5.2.1",
    "openai": "^6.16.0",
    "react": "^19.1.1",
    "react-dom": "^19.1.1",
    "zod": "^4.3.5"
  },
  "devDependencies": {
    "@types/chrome": "^0.0.326",
    "@types/express": "^5.0.6",
    "@types/jsdom": "^28.0.3",
    "@types/node": "^24.3.0",
    "@types/react": "^19.1.10",
    "@types/react-dom": "^19.1.7",
    "@vitejs/plugin-react": "^5.0.2",
    "jsdom": "^29.1.1",
    "tsx": "^4.21.0",
    "typescript": "~5.9.2",
    "vite": "^7.1.4",
    "vitest": "^4.1.10"
  }
}

```

### src/main.tsx

```typescript
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";

const root = document.getElementById("root");

if (!root) {
  throw new Error("Trajectory side panel root was not found.");
}

createRoot(root).render(
  <StrictMode>
    <App />
  </StrictMode>,
);


```

### server/index.ts

```typescript
import { createServerApp } from "./app.js";
import { loadServerConfig } from "./config.js";
import {
  loadServerEnvironment,
  printSafeOpenAiDiagnostics,
} from "./environment.js";

try {
  const diagnostics = loadServerEnvironment();
  printSafeOpenAiDiagnostics(diagnostics);
  const config = loadServerConfig();
  console.info(
    `Unpacked extension origins: ${
      config.allowChromeExtensionOriginsInDevelopment
        ? "allowed for local development"
        : "explicit allowlist only"
    }`,
  );
  console.info(
    `Analysis limits: ${config.maxTranscriptChars.toLocaleString()} transcript characters; ${config.maxRequestBodyBytes.toLocaleString()} encoded request bytes`,
  );
  console.info(
    `Analysis latency profile: ${config.analysisPipelineMode === "fast" ? 1 : 2} model call${config.analysisPipelineMode === "fast" ? "" : "s"}; mode=${config.analysisPipelineMode}; reasoning=${config.openAiReasoningEffort ?? "model default"}; verbosity=${config.openAiTextVerbosity ?? "model default"}`,
  );
  const app = createServerApp(config);
  app.listen(config.port, () => {
    console.info(
      `[Trajectory analysis] listening on port ${config.port}; transcript contents are not logged.`,
    );
  });
} catch (error) {
  console.error(
    `[Trajectory analysis] startup failed: ${
      error instanceof Error ? error.message : "Unknown configuration error."
    }`,
  );
  process.exitCode = 1;
}

```

### server/app.ts

```typescript
import { randomUUID } from "node:crypto";
import express, {
  type ErrorRequestHandler,
  type NextFunction,
  type Request,
  type Response,
} from "express";
import OpenAI, {
  APIConnectionError,
  APIConnectionTimeoutError,
  APIError,
  RateLimitError,
} from "openai";
import {
  type AnalysisPipelineStage,
  CONVERSATION_TRAJECTORY_ANALYSIS_VERSION,
  type AnalysisErrorCode,
  type AnalysisErrorResponse,
  type AnalyzeConversationRequest,
} from "../shared/analysis.js";
import type { ServerConfig } from "./config.js";
import {
  RequestContractError,
  validateAnalyzeConversationRequest,
} from "./contracts.js";
import { createMemoryRateLimiter } from "./rateLimit.js";
import {
  AnalysisServiceError,
  analyzeConversation,
  type AnalysisServiceResult,
} from "./services/analyzeConversation.js";

type AnalyzeFunction = (
  request: AnalyzeConversationRequest,
  signal: AbortSignal,
  onProgress?: (stage: AnalysisPipelineStage) => void,
) => Promise<AnalysisServiceResult>;

interface SafeLogFields {
  requestId: string;
  messageCount?: number;
  transcriptChars?: number;
  requestBodyBytes?: number;
  phaseCount?: number;
  findingCount?: number;
  openAiResponseId?: string;
  inputTokens?: number;
  outputTokens?: number;
  totalTokens?: number;
  sourceSpanRepairApplied?: boolean;
  stages?: AnalysisServiceResult["telemetry"]["stages"];
  latencyMs?: number;
  resultStatus: "success" | "error";
  category?: string;
}

function safeLog(fields: SafeLogFields) {
  const write = fields.resultStatus === "success" ? console.info : console.warn;
  write("[Trajectory analysis]", JSON.stringify(fields));
}

function safeProgressLog(
  requestId: string,
  stage: AnalysisPipelineStage,
  elapsedMs: number,
) {
  console.info(
    "[Trajectory analysis progress]",
    JSON.stringify({ requestId, stage, elapsedMs }),
  );
}

function requestContentLength(request: Request): number | undefined {
  const value = request.get("Content-Length");
  if (!value) return undefined;
  const parsed = Number(value);
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}

function sendError(
  response: Response,
  status: number,
  code: AnalysisErrorCode,
  message: string,
  requestId: string,
  details?: AnalysisErrorResponse["error"]["details"],
) {
  const body: AnalysisErrorResponse = {
    error: { code, message, requestId, ...(details ? { details } : {}) },
  };
  response.status(status).json(body);
}

const UNPACKED_CHROME_EXTENSION_ORIGIN =
  /^chrome-extension:\/\/[a-p]{32}$/;

export function isCorsOriginAllowed(
  config: ServerConfig,
  origin: string,
): boolean {
  return (
    config.allowedOrigins.has(origin) ||
    config.allowedOrigins.has("*") ||
    (!config.production &&
      config.allowChromeExtensionOriginsInDevelopment &&
      UNPACKED_CHROME_EXTENSION_ORIGIN.test(origin))
  );
}

function corsMiddleware(config: ServerConfig) {
  return (request: Request, response: Response, next: NextFunction) => {
    const origin = request.get("Origin")?.replace(/\/$/, "");
    if (!origin) {
      next();
      return;
    }
    if (!isCorsOriginAllowed(config, origin)) {
      safeLog({
        requestId: response.locals.requestId,
        resultStatus: "error",
        category: "cors_origin_rejected",
      });
      sendError(
        response,
        403,
        "invalid_request",
        origin.startsWith("chrome-extension://")
          ? "This extension origin is not allowed by the analysis service. For local development, restart npm run dev:live. Deployments must add the exact extension origin to ALLOWED_ORIGINS."
          : "This origin is not allowed to use the analysis service.",
        response.locals.requestId,
      );
      return;
    }
    response.setHeader("Access-Control-Allow-Origin", origin);
    response.setHeader("Vary", "Origin");
    response.setHeader("Access-Control-Allow-Headers", "Content-Type");
    response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
    if (request.method === "OPTIONS") {
      response.status(204).end();
      return;
    }
    next();
  };
}

function mapUnknownError(error: unknown): {
  status: number;
  code: AnalysisErrorCode;
  message: string;
  category: string;
} {
  if (error instanceof AnalysisServiceError) {
    return {
      status: error.code === "model_refusal" ? 422 : 502,
      code: error.code,
      message: error.message,
      category: error.code,
    };
  }
  if (error instanceof RateLimitError) {
    return {
      status: 429,
      code: "rate_limited",
      message: "The model service is rate limited. Please retry later.",
      category: "openai_rate_limit",
    };
  }
  if (error instanceof APIConnectionTimeoutError) {
    return {
      status: 504,
      code: "request_timeout",
      message: "The model service timed out before analysis completed.",
      category: "openai_timeout",
    };
  }
  if (error instanceof APIConnectionError) {
    return {
      status: 502,
      code: "backend_unavailable",
      message: "The model service is temporarily unavailable.",
      category: "openai_connection",
    };
  }
  if (error instanceof APIError) {
    return {
      status: error.status && error.status >= 500 ? 502 : 500,
      code: "server_error",
      message: "The model service could not complete this analysis.",
      category: `openai_${error.status ?? "unknown"}`,
    };
  }
  return {
    status: 500,
    code: "server_error",
    message: "The analysis service encountered an unexpected error.",
    category: "unknown",
  };
}

export function createServerApp(
  config: ServerConfig,
  dependencies: { analyze?: AnalyzeFunction } = {},
) {
  const app = express();
  const client = dependencies.analyze
    ? undefined
    : new OpenAI({
        apiKey: config.openAiApiKey,
        timeout: config.requestTimeoutMs,
        maxRetries: 1,
        logLevel: "off",
      });
  const runAnalysis: AnalyzeFunction =
    dependencies.analyze ??
    ((request, 
[truncated — 8430 more characters]
```

### src/providers/index.ts

```typescript
import { chatGptAdapter } from "./chatgpt";
import type { ChatProviderAdapter } from "./types";

const adapters: ChatProviderAdapter[] = [chatGptAdapter];

export function getProviderAdapter(url: URL): ChatProviderAdapter | undefined {
  return adapters.find((adapter) => adapter.matches(url));
}


```

### src/App.tsx

```typescript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  CONVERSATION_TRAJECTORY_ANALYSIS_VERSION,
  CONVERSATION_TRAJECTORY_PIPELINE_VERSION,
  type ConversationFinding,
  type ConversationTrajectoryAnalysis,
  type EvidenceExchange,
} from "../shared/analysis.js";
import {
  deriveCoverage,
  evidenceExcerptMatches,
  validateTrajectoryAnalysis,
} from "../shared/analysisValidation.js";
import type { CachedAnalysisEntry } from "./analysis/cache";
import {
  TRAJECTORY_DEMO_MODE,
  demoConversationMismatchMessage,
  isTrajectoryDemoConversation,
} from "./analysis/demoConfig";
import type { DemoAnalysisFixture } from "./analysis/demoFixture";
import { logAnalysisDiagnostics } from "./analysis/diagnostics";
import {
  buildAnalyzeConversationRequest,
  createTranscriptFingerprint,
  normalizeMessagesForAnalysis,
} from "./analysis/request";
import { validateLocalSourceSpan } from "./analysis/sourceSpans";
import {
  isAnalysisApiConfigured,
  requestTrajectoryAnalysis,
  TrajectoryAnalysisError,
} from "./api/trajectoryAnalysis";
import {
  AnalysisStatus,
  type AnalysisProgressStage,
  type AnalysisStatusKind,
} from "./components/AnalysisStatus";
import { ArcView } from "./components/ArcView";
import { DisplaySettings } from "./components/DisplaySettings";
import {
  FindingDetailOverlay,
  FindingsView,
} from "./components/FindingsView";
import { ViewNavigation, type PanelView } from "./components/ViewNavigation";
import { useAnalysisCache } from "./hooks/useAnalysisCache";
import { useAnalysisConsent } from "./hooks/useAnalysisConsent";
import {
  DISPLAY_SIZE_SCALES,
  usePanelPreferences,
} from "./hooks/usePanelPreferences";
import { useTranscriptFingerprint } from "./hooks/useTranscriptFingerprint";
import type { FindingDetailState } from "./navigation/findingDetail";
import {
  EMPTY_ROLE_COUNTS,
  IDLE_STATE,
  MESSAGE_TYPES,
  isStateUpdatedMessage,
  type CollectCompleteConversationMessage,
  type CollectCompleteConversationResponse,
  type ExtractionState,
  type GetStateMessage,
  type GetStateResponse,
  type ViewSourceMessage,
  type ViewSourceResponse,
} from "./types";

function formatTime(timestamp?: string): string | undefined {
  if (!timestamp) return undefined;
  const date = new Date(timestamp);
  if (Number.isNaN(date.getTime())) return undefined;
  return new Intl.DateTimeFormat(undefined, {
    hour: "numeric",
    minute: "2-digit",
  }).format(date);
}

interface AnalysisAttempt {
  conversationKey: string;
  status: "analyzing" | "failed";
  stage?: AnalysisProgressStage;
  elapsedMs?: number;
  message?: string;
  requestId?: string;
}

declare global {
  interface Window {
    __TRAJECTORY_EXPORT_DEMO_FIXTURE__?: () => Promise<string>;
  }
}

function analysisFailureMessage(error: TrajectoryAnalysisError | undefined): string {
  if (!error) return "Trajectory could not complete or verify this analysis.";
  if (error.code === "invalid_response") {
    return "The analysis service returned invalid structured output. The previous valid analysis was preserved.";
  }
  if (error.code === "invalid_analysis") {
    return `Source grounding validation failed. ${error.message}`;
  }
  return error.message;
}

function entryValidationMessages(
  entry: CachedAnalysisEntry,
  state: ExtractionState,
) {
  const coverage = entry.analysis.coverage;
  return state.messages.filter(
    (message) =>
      message.index >= coverage.firstMessageIndex &&
      message.index <= coverage.lastMessageIndex,
  );
}

function validEntry(
  entry: CachedAnalysisEntry | undefined,
  state: ExtractionState,
  allowFixture = false,
): CachedAnalysisEntry | undefined {
  if (
    !entry ||
    entry.pipelineVersion !== CONVERSATION_TRAJECTORY_PIPELINE_VERSION ||
    entry.analysis.analysisVersion !== CONVERSATION_TRAJECTORY_ANALYSIS_VERSION ||
    (entry.analysis.source === "fixture" && !allowFixture)
  ) {
    return undefined;
  }
  const coveredMessages = entryValidationMessages(entry, state);
  const validated = validateTrajectoryAnalysis(entry.analysis, {
    messages: normalizeMessagesForAnalysis(coveredMessages),
    conversationTitle: entry.analysis.conversationTitle,
    completeConversation: entry.analysis.coverage.completeConversation,
    analysisVersion: CONVERSATION_TRAJECTORY_ANALYSIS_VERSION,
  });
  if (!validated.ok) return undefined;
  const spans = [
    ...validated.value.phases.map((phase) => phase.primarySourceSpan),
    ...validated.value.findings.flatMap((finding) =>
      finding.evidenceExchanges.map((exchange) => exchange.sourceSpan),
    ),
  ];
  if (spans.some((span) => !validateLocalSourceSpan(span, state.messages).ok)) {
    return undefined;
  }
  return { ...entry, analysis: validated.value };
}

export default function App() {
  const allowFixturePreview = Boolean(
    import.meta.env.DEV &&
      document.documentElement.dataset.trajectoryFixture === "true",
  );
  const [state, setState] = useState<ExtractionState>(IDLE_STATE);
  const [activeView, setActiveView] = useState<PanelView>("arc");
  const [selectedPhaseId, setSelectedPhaseId] = useState<string>();
  const [selectedFindingId, setSelectedFindingId] = useState<string>();
  const [findingDetail, setFindingDetail] = useState<FindingDetailState>();
  const [analysisAttempt, setAnalysisAttempt] = useState<AnalysisAttempt>();
  const [demoFixture, setDemoFixture] = useState<DemoAnalysisFixture>();
  const [demoFixtureError, setDemoFixtureError] = useState<string>();
  const [immediateEntry, setImmediateEntry] = useState<{
    conversationKey: string;
    entry: CachedAnalysisEntry;
    origin: "openai" | "cache";
  }>();
  const [navigationPending, setNavigationPending] = useState(false);
  const [navigationFeedback, setNavigationFeedback] = useState<{
    ok: boolean;
    message: string;
  }>();
  const [fixtureExportFeedback, setFixtureExportFeedback] = useState<string>();
  const analysisAbortRef = useRef<AbortController | undefined>(undefined);
  const arcScr
[truncated — 30659 more characters]
```

### sidepanel.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="color-scheme" content="light" />
    <title>Trajectory</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>


```

### vite.demo.config.ts

```typescript
import { resolve } from "node:path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  base: "./",
  publicDir: false,
  plugins: [react()],
  build: {
    target: "es2022",
    outDir: "demo",
    emptyOutDir: true,
    rollupOptions: {
      input: resolve(import.meta.dirname, "responsive-fixture.html"),
    },
  },
});

```

[86 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]