diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..99cc31b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Keep the build context small and reproducible. node_modules in particular is +# hundreds of MB and MUST NOT be copied — the image installs its own via +# `npm ci`, and a host copy would carry platform-specific binaries. +node_modules +**/node_modules + +# Build output — the API runs from source via tsx. +dist +**/dist +build +**/build +out +**/out +.next +**/.next +*.tsbuildinfo +apps/vscode-ext/*.vsix +apps/browser-ext/.plasmo + +# Secrets. GEMINI_API_KEY and friends are injected at runtime by compose, +# never baked into an image layer. +.env +.env.* +!.env.example + +# VCS / tooling / docs — irrelevant to the API image. +.git +.github +.claude +.superpowers +coverage +.cache +.turbo +.parcel-cache +archive +docs +*.log +Dockerfile +docker-compose.yml diff --git a/.env.example b/.env.example index e5be525..5330ac5 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,88 @@ -# Trailhead — root environment template. Copy to `.env` (which is gitignored). -# Every artifact (api, browser-ext, vscode-ext, mcp-server, dashboard) -# reads from this same set. Spec: docs/superpowers/specs/2026-04-25-trailhead-design.md - -# --- Postgres (Neon) ---------------------------------------------------------- -# 1. Sign in at https://console.neon.tech and create a project. -# 2. Copy the pooled connection string here (must include `sslmode=require`). -# 3. Apply schema: psql "$DATABASE_URL" -f packages/db/schema.sql -DATABASE_URL=postgresql://USER:PASSWORD@HOST.neon.tech/trailhead?sslmode=require - -# --- Hardcoded team token ----------------------------------------------------- -# Single shared secret. Every client sends this in the `X-Team-Token` header. -# Demo only — production swaps in Clerk per spec §3 / §16. -TEAM_TOKEN=trailhead_demo_acme_2026 +# Trailhead — root environment template. Copy to `.env` (which is gitignored): +# +# cp .env.example .env +# +# Then set GEMINI_API_KEY below and run `docker compose up`. Everything else +# here has a working default. See SELFHOSTING.md for the full walkthrough. +# +# Trailhead is self-hosted: there is no hosted API. Every client (browser-ext, +# vscode-ext, mcp-server, dashboard) defaults to http://localhost:3000, which +# is what `docker compose up` publishes. + +# ============================================================================= +# REQUIRED +# ============================================================================= -# --- Gemini ----------------------------------------------------------------- -# Required for /score (gemini-3-flash-preview, JSON schema mode) and /diff (gemini-3-flash-preview). -# Get one at https://aistudio.google.com/apikey. +# --- Gemini ------------------------------------------------------------------ +# The only value you must supply. Powers /score, /coach, /improve, /diff and +# the rich wiki bootstrap. The API refuses to start without it. +# Get one at https://aistudio.google.com/apikey GEMINI_API_KEY= +# ============================================================================= +# OPTIONAL — every value below has a working default +# ============================================================================= + +# --- Postgres ---------------------------------------------------------------- +# Leave DATABASE_URL unset to use the `postgres` service in docker-compose.yml. +# Compose builds the connection string from POSTGRES_USER / POSTGRES_PASSWORD / +# POSTGRES_DB and points the API at it automatically, and applies +# packages/db/schema.sql on the first boot of a fresh volume. +# +# Set it only to use an EXTERNAL database instead (Neon, RDS, ...). A hosted +# Postgres generally needs `?sslmode=require`. +# DATABASE_URL=postgresql://USER:PASSWORD@HOST/trailhead?sslmode=require +POSTGRES_USER=trailhead +POSTGRES_PASSWORD=trailhead +POSTGRES_DB=trailhead +# Host port for Postgres — change if 5432 is already in use. Used by psql and +# the scripts in packages/db (seed.mjs, migrate.mjs, check.mjs). +POSTGRES_PORT=5432 + +# --- API --------------------------------------------------------------------- +# Host port the API is published on. The container always listens on 3000; +# this is the host side of the mapping. Changing it means updating every +# client's API URL too (see SELFHOSTING.md). +PORT=3000 + +# Any unrecognised X-Team-Token spawns its own team row. The right default for +# a single-tenant self-host — teammates cloning the repo land in the same team +# with no admin step. Set to false to require teams be registered explicitly. +TRAILHEAD_AUTO_CREATE_TEAMS=true + +# Safety catch on DELETE /team/data for the seeded demo team. Set true only if +# you really want `trailhead-mcp reset` to be able to wipe it. +TRAILHEAD_ALLOW_DEMO_RESET=false + +# --- Team token -------------------------------------------------------------- +# The demo team's token, and the fallback the clients ship with. Real teams get +# a token derived from their git remote by `npx trailhead-mcp init` — this is +# only the demo/seed value. +TEAM_TOKEN=trailhead_demo_acme_2026 + # --- Langfuse (optional) ----------------------------------------------------- -# Hosted observability for every Gemini call made by the API. Keys live at -# https://cloud.langfuse.com → Settings → API Keys. When unset the API still -# works — tracing silently no-ops with a one-line warning at startup. +# Hosted observability for every Gemini call the API makes. Keys at +# https://cloud.langfuse.com -> Settings -> API Keys. When unset the API still +# works — tracing no-ops with a one-line warning at startup. LANGFUSE_PUBLIC_KEY= LANGFUSE_SECRET_KEY= # EU region (default). Use https://us.cloud.langfuse.com for the US region. LANGFUSE_BASEURL=https://cloud.langfuse.com -# --- API --------------------------------------------------------------------- -# Local dev only. Railway injects PORT automatically when deployed. -PORT=3000 +# ============================================================================= +# CLIENTS — only needed when running a client outside its default +# ============================================================================= + +# --- MCP server (apps/mcp-server) -------------------------------------------- +# Base URL of your API. `npx trailhead-mcp init` writes this into the generated +# MCP config. Unset -> the CLIs warn and fall back to http://localhost:3000. +# TRAILHEAD_API_URL=http://localhost:3000 +# Per-repo team token, normally auto-derived from the git remote. +# TRAILHEAD_TEAM_TOKEN= + +# --- Dashboard (apps/dashboard) ---------------------------------------------- +# Baked in at build time (NEXT_PUBLIC_*), so a deployed dashboard must set it +# before `next build`. Unset -> http://localhost:3000, and the Teams page says +# so explicitly rather than failing silently. +# NEXT_PUBLIC_API_URL=http://localhost:3000 +# NEXT_PUBLIC_TEAM_TOKEN=trailhead_demo_acme_2026 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ad855ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bogdan Truta + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ad95085..6c00cd6 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Endpoints implemented in `apps/api/src/index.ts`: | Method + Path | What it does | |---|---| | `GET /` | Health + endpoint catalog (unauth) | -| `GET /teams` | List all teams with tokens (unauth, drives the dashboard team picker) | +| `GET /teams` | Resolves the caller's own team (authenticated). Never returns tokens — `{ name, id }` where `id` is an opaque digest | | `POST /score` | 5-dimension Gemini score; writes `skill_observation` rows with a 30 s per-dimension dedup window | | `POST /coach` | Stateless 3-round teach→reveal coaching loop | | `POST /capture` | Stores a `(prompt, response, outcome)` capture from any surface | @@ -71,18 +71,21 @@ Endpoints implemented in `apps/api/src/index.ts`: | `GET /context?path=` | Ancestor walk: returns every wiki node whose path is a prefix of the file path, plus its durable learnings | | `GET /examples?path=` | Top graduated prompts for an ancestor of a file path | | `GET /wiki/recent?since=ISO` | Polling endpoint for the VS Code wiki-toast surface | -| `POST /diff` | Picks the closest graduated team prompt by topic + ancestry, scores both prompts, asks Gemini Pro to narrate the difference | +| `POST /diff` | Picks the closest graduated team prompt by topic + ancestry, scores both prompts, asks Gemini to narrate the difference | | `POST /improve` | Multi-turn Gemini-driven prompt rewrite, capped at 5 user replies | | `GET /skill-arc` | Time-series of per-dimension scores (powers the dashboard hero chart) | | `GET /team/metrics` | Snapshot: avg overall, reuse rate, durable count, draft count, active users | | `GET /wiki/tree` | Full node + learnings tree | +| `GET /wiki/export` | The whole team wiki as one markdown document (`?drafts=true`, `?format=json`) | | `POST /onboard/repo` | Bulk-upsert one node per path, idempotent, optional `initial_rules[path]` for seeding `body_md` | | `POST /onboard/repo/full` | Async rich bootstrap: accepts a folder + file bundle (capped at 16 MB / 2 000 files / 32 KB per file), enqueues a `wiki_jobs` row, three-pass Gemini fan-out via `setImmediate` | | `GET /onboard/jobs/:id` | Per-path progress for a rich-bootstrap job | | `DELETE /team/data` | Wipes the requesting team's data; demo team is protected unless `TRAILHEAD_ALLOW_DEMO_RESET=true` | -LLM work runs through `apps/api/src/gemini.ts`: Gemini 2.5 Flash for scoring -(JSON-schema mode), Gemini 2.5 Pro for diff narration and rich bootstrap. +LLM work runs through `apps/api/src/gemini.ts`. Model assignments live in +`packages/scoring/src/models.mjs`: `gemini-3-flash-preview` for scoring +(JSON-schema mode), topic extraction and diff narration; `gemma-4-31b-it` for +async learning extraction, where latency is tolerable. Every Gemini call is instrumented with **Langfuse** when `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` are set — one trace per HTTP @@ -92,11 +95,12 @@ Tracing silently no-ops when keys are missing. ### `apps/browser-ext` — Chrome MV3 extension for Claude.ai Vanilla TypeScript + esbuild. Manifest declares `https://claude.ai/*` as the -content-script host and pre-allowlists the deployed Railway API. +content-script host and allowlists `http://localhost/*` for a self-hosted API. +The popup's **API server** row shows and edits that URL. Implemented widgets (`src/widgets/`): -- **Score card** under the textarea — debounced 250 ms hits to `/score`, +- **Score card** under the textarea — scores on send (not on keystroke), per-dimension bars, missing-dimension hints - **Score badge** on each user bubble - **Prompt diff panel** — "Compare to team" expands a `/diff` view inline @@ -104,9 +108,10 @@ Implemented widgets (`src/widgets/`): - **Wiki toast** — drops in when `/wiki/recent` polling sees a new learning - **Improve chat** — multi-turn rewrite using `/improve` - **Context pill + popup** — pick a wiki node to bias scoring -- **Send-intercept** — on send: `≥ 7` lets the native send fire; `< 7` shows a - 5-second nudge with *Have Claude clarify* / *Send as-is*; auto-sends as-is on - timeout. Fail-open on every API error. +- **Send-intercept** — on send: `≥ 7` lets the native send fire; `< 7` keeps the + score card up with *Improve* / *Send as-is* / *Edit* and waits for the user. + There is no timer and nothing is ever sent automatically. Fail-open on every + API error. Kill-switch: `chrome.storage.local.set({ 'trailhead.disabled': true })` halts the extension on next page load. @@ -120,9 +125,8 @@ toasts when `/wiki/recent` reports a new insight. ### `apps/mcp-server` — MCP server for Claude Code + Copilot Chat -STDIO MCP server distributed via `npx trailhead-mcp …`. Four hero tools -deliberately collapsed from a previous seven-tool surface so Copilot's tool -selector picks reliably: +STDIO MCP server. Five hero tools, deliberately collapsed from a previous +seven-tool surface so Copilot's tool selector picks reliably: | Tool | Routes to | |---|---| @@ -130,9 +134,15 @@ selector picks reliably: | `wiki_lookup` | `GET /context` + `GET /examples` (file-path based) and/or `GET /search` (query) | | `wiki_save` | `POST /wiki/propose` with server-side dedup | | `wiki_bootstrap` | `POST /onboard/repo` (skeleton) or `POST /onboard/repo/full` (rich, LLM-populated) | +| `wiki_proven_prompts` | `GET /prompts/proven` — the team's graduated prompts, filterable by score, path and topic | Plus a `ping` for health checks. +> **Not published to npm.** The package is `private: true` and neither +> `trailhead-mcp` nor `@trailhead/mcp-server` exists on the registry, so +> `npx trailhead-mcp` does not work. Run it from a clone — see +> [SELFHOSTING.md](SELFHOSTING.md). + CLI subcommands (`bin/cli.mjs`): - `trailhead-mcp init` — per-repo install. Writes `.mcp.json` + `CLAUDE.md` @@ -151,7 +161,8 @@ CLI subcommands (`bin/cli.mjs`): App router, server components for the team list, SWR for the live charts. Pages (`src/app/`): -- `/` — team picker (lists every team returned by `/teams`) +- `/` — team view (shows the caller's own team; `GET /teams` is authenticated + and returns only the team the configured token resolves to) - `/skill-arc?team=…` — per-dimension team chart driven by `/skill-arc`, polls every 2 s during the demo - `/team?team=…` — L1→L2 metric cards from `/team/metrics` @@ -220,6 +231,26 @@ docs/ ## Quick start +Trailhead is self-hosted. There is no hosted backend to sign up for — you run +the API, and every client points at it. + +### The short way: Docker + +Everything you need is Docker and a Gemini API key from +. + +```bash +cp .env.example .env # then put your Gemini key in it +docker compose up +# → API on http://localhost:3000, Postgres schema applied automatically +``` + +That is the whole setup. See [SELFHOSTING.md](SELFHOSTING.md) for pointing the +browser extension, VS Code extension, MCP server and dashboard at it, and for +running against an external database instead. + +### The long way: local Node + your own Postgres + Prerequisites: - Node `>= 22.6` @@ -262,10 +293,12 @@ npm --workspace=@trailhead/browser-ext run build # VS Code extension — build, then F5 with apps/vscode-ext as the workspace npm --workspace=apps/vscode-ext run build -# MCP server — install into a target repo +# MCP server — install into a target repo. The package is unpublished +# (private: true), so `npx trailhead-mcp` does NOT work — invoke the CLI by +# path from this clone. It operates on the cwd, so cd into the target first. cd /path/to/your/repo -npx trailhead-mcp init -npx trailhead-mcp bootstrap +node /path/to/LearnLoop/apps/mcp-server/bin/cli.mjs init +node /path/to/LearnLoop/apps/mcp-server/bin/cli.mjs bootstrap ``` ### Workspace scripts @@ -285,7 +318,7 @@ Single root `.env.example` — every surface reads from the same set. | Var | Used by | Notes | |---|---|---| | `DATABASE_URL` | api | Postgres connection string, `sslmode=require` | -| `GEMINI_API_KEY` | api | Gemini 2.5 Flash + 2.5 Pro | +| `GEMINI_API_KEY` | api | `gemini-3-flash-preview` + `gemma-4-31b-it` | | `LANGFUSE_PUBLIC_KEY` | api | Optional. Hosted Langfuse public key (`pk-lf-…`) | | `LANGFUSE_SECRET_KEY` | api | Optional. Hosted Langfuse secret key (`sk-lf-…`) | | `LANGFUSE_BASEURL` | api | Defaults to `https://cloud.langfuse.com` (EU). Use `https://us.cloud.langfuse.com` for US | @@ -310,18 +343,20 @@ Single root `.env.example` — every surface reads from the same set. . - **Browser extension** → loaded unpacked from `apps/browser-ext/dist/`. - **VS Code extension** → `vsce package` from `apps/vscode-ext/`. -- **MCP server** → distributed via `npx trailhead-mcp init` (per-repo wiring, +- **MCP server** → not published to npm (`private: true`). Wired into a repo by + running `apps/mcp-server/bin/cli.mjs init` from a clone (per-repo wiring, multi-tenant token derivation from the git remote). --- ## How the pieces fit -1. Engineer types a prompt. Browser extension debounces 250 ms and hits - `/score`. The card mounts under the textarea with five per-dimension bars - and missing-dimension hints. -2. Below 7 → 5-second *Have Claude clarify* nudge, or fall back to *Send - as-is*. Each `/score` writes 5 `skill_observation` rows; the dashboard's +1. Engineer types a prompt and hits send. The extension intercepts the send and + calls `/score`. The card mounts under the textarea with five per-dimension + bars and missing-dimension hints. +2. `≥ 7` sends straight through. Below 7 the card stays up with *Improve* / + *Send as-is* / *Edit* and waits for an explicit choice — no timer, no + auto-send. Each `/score` writes 5 `skill_observation` rows; the dashboard's `/skill-arc` chart polls every 2 s, so the rightmost bucket climbs as the user prompts. 3. In Claude Code or Copilot Chat, the MCP server's `coach` tool is called @@ -346,7 +381,7 @@ The project was specced before it was built. Source of truth for *why*: - `docs/superpowers/specs/2026-04-25-trailhead-design.md` — master spec - `docs/superpowers/specs/2026-04-25-mcp-plugin-ux-design.md` — MCP install - story and four-tool surface + story and (then) four-tool surface - `docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md` — Claude.ai content-script architecture - `docs/superpowers/specs/2026-04-25-demo-completion-design.md` — dashboard @@ -368,7 +403,7 @@ contracts, builds, and tests. - **Backend:** Hono, TypeScript, Node 22, `@hono/node-server`, raw `pg` - **DB:** Postgres on Neon, no ORM -- **LLMs:** Gemini 2.5 Flash (scoring, JSON-schema mode), Gemini 2.5 Pro +- **LLMs:** `gemini-3-flash-preview` (scoring, JSON-schema mode), `gemma-4-31b-it` (diff narration, rich bootstrap) - **Observability:** Langfuse (hosted) — one trace per request, one generation per LLM call @@ -376,5 +411,6 @@ contracts, builds, and tests. TS + esbuild (extensions); React via CDN (landing page) - **MCP:** `@modelcontextprotocol/sdk`, STDIO transport - **Build:** npm workspaces; per-package `tsc` / `esbuild` -- **Hosts:** Railway (API), Vercel (dashboard + landing page), per-repo MCP - install via `npx trailhead-mcp` +- **Hosts:** self-hosted API (see SELFHOSTING.md; `railway.json` remains for + anyone who wants a Railway deploy), Vercel (dashboard + landing page), per-repo + MCP wired from a clone via `apps/mcp-server/bin/cli.mjs init` (unpublished) diff --git a/SELFHOSTING.md b/SELFHOSTING.md new file mode 100644 index 0000000..5d94bff --- /dev/null +++ b/SELFHOSTING.md @@ -0,0 +1,180 @@ +# Self-hosting Trailhead + +Trailhead is self-hosted. There is no hosted API and no account to sign up for — +you run the backend, and it is yours. The only external dependency is a Gemini +API key. + +Everything below assumes you are at the repo root. + +--- + +## Prerequisites + +| | | +|---|---| +| **Docker** | Docker Desktop, OrbStack, or Docker Engine with the Compose v2 plugin (`docker compose version`). | +| **A Gemini API key** | Free at . | +| **Node 22.6+** | Only needed to run the *clients* (browser extension, VS Code extension, MCP server, dashboard). The API itself runs entirely inside Docker. | + +--- + +## Start the API + +```bash +cp .env.example .env # then open .env and set GEMINI_API_KEY=... +docker compose up +``` + +That's it. On the first run Compose will: + +1. Start Postgres 16 on a named volume (`trailhead-pgdata`), so your data + survives restarts. +2. Apply `packages/db/schema.sql` automatically — Postgres runs anything in + `/docker-entrypoint-initdb.d` the first time it initialises a data + directory, so there is no manual `psql` step. +3. Wait for Postgres to pass `pg_isready`, then start the API. + +The API is then on ****. Check it: + +```bash +curl http://localhost:3000 +# {"name":"trailhead-api","status":"ok",...} +``` + +If `GEMINI_API_KEY` is missing, `docker compose up` stops immediately and tells +you so — it will not start a stack that cannot score a prompt. + +Run it in the background with `docker compose up -d`, and stop it with +`docker compose down`. + +### Configuration + +Every variable lives in `.env`, and every one except `GEMINI_API_KEY` has a +working default. See [`.env.example`](.env.example) for the annotated list. The +ones you are most likely to touch: + +| Variable | Default | Why you'd change it | +|---|---|---| +| `GEMINI_API_KEY` | *(required)* | — | +| `PORT` | `3000` | Something else already owns port 3000. Changing this means updating each client's API URL too. | +| `POSTGRES_PORT` | `5432` | You already run Postgres locally. | +| `DATABASE_URL` | *(the bundled Postgres)* | Use an external database (Neon, RDS) instead of the container. | +| `TRAILHEAD_AUTO_CREATE_TEAMS` | `true` | Set `false` to stop unknown team tokens from creating teams on the fly. | + +### Data management + +```bash +docker compose down # stop; data kept +docker compose down -v # stop and DELETE the database volume +docker compose logs -f api # follow API logs +docker compose up --build # rebuild the API image after changing its source +``` + +The schema is only applied to a *fresh* volume. `schema.sql` is idempotent, so +if you need to re-apply it to an existing database: + +```bash +docker compose exec -T postgres psql -U trailhead -d trailhead < packages/db/schema.sql +``` + +--- + +## Point the clients at it + +Every client defaults to `http://localhost:3000`, so if you kept the default +`PORT` there is nothing to configure. If you changed `PORT`, or the API runs on +another machine, substitute your URL below. + +### Browser extension + +```bash +npm install +npm run build --workspace=apps/browser-ext +``` + +Then in Chrome: `chrome://extensions` → enable **Developer mode** → **Load +unpacked** → select `apps/browser-ext/dist`. + +The extension popup has an **API server** row showing exactly which URL it is +talking to, with a live connection check. Edit it there and press **Save** — the +change takes effect immediately on any open Claude.ai tab, no reload needed. + +The manifest ships permission for `localhost` and `127.0.0.1`. Pointing the +extension at any other host triggers a one-time Chrome permission prompt when +you save. + +### VS Code extension + +Settings → search `trailhead.apiUrl` (or edit `settings.json`): + +```jsonc +{ + "trailhead.apiUrl": "http://localhost:3000" +} +``` + +If the API is unreachable, the extension raises a notification naming the +setting rather than showing an empty sidebar. + +### MCP server (Claude Code / Copilot) + +From the repo you want coached: + +```bash +npx trailhead-mcp init --api-url http://localhost:3000 +``` + +That writes `.mcp.json` (and `.vscode/mcp.json` for Copilot) with +`TRAILHEAD_API_URL` set, and derives a team token from your git remote so +teammates cloning the same repo land in the same team. You can also set +`TRAILHEAD_API_URL` in your environment instead. + +Then seed the wiki from the repo: + +```bash +npx trailhead-mcp bootstrap +``` + +### Dashboard + +```bash +npm run dev --workspace=apps/dashboard +``` + +Runs on and reads the API at `NEXT_PUBLIC_API_URL`, +defaulting to `http://localhost:3000`. To point elsewhere: + +```bash +NEXT_PUBLIC_API_URL=http://localhost:8080 npm run dev --workspace=apps/dashboard +``` + +`NEXT_PUBLIC_*` values are baked in at build time, so a **deployed** dashboard +must set `NEXT_PUBLIC_API_URL` before `next build`, and the API must be +reachable from the visitor's browser. When it isn't set, the Teams page says so +and names the variable. + +--- + +## Troubleshooting + +**`docker compose up` exits with "required variable GEMINI_API_KEY is missing"** +You skipped `cp .env.example .env`, or left the key blank. `.env` must be at the +repo root, next to `docker-compose.yml`. + +**API restarts in a loop / `DATABASE_URL not set`** +`DATABASE_URL` is set in your `.env` but points somewhere unreachable. Comment +it out to fall back to the bundled Postgres. + +**Port already in use** +Change `PORT` (API) or `POSTGRES_PORT` (Postgres) in `.env`, then re-run +`docker compose up`. Remember to update the clients if you changed `PORT`. + +**Clients show no data / "can't reach the API"** +Confirm `curl http://localhost:3000` answers. Each client names the exact +setting to fix in its own error message: the popup's **API server** row, +`trailhead.apiUrl`, `TRAILHEAD_API_URL`, or `NEXT_PUBLIC_API_URL`. + +**Schema changes didn't apply** +`docker-entrypoint-initdb.d` only runs on a fresh volume. Either +`docker compose down -v` (destroys data) or pipe `schema.sql` through `psql` as +shown above. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..15b1219 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,54 @@ +# Trailhead API image. +# +# Build context is the REPO ROOT (not apps/api) — this is an npm workspace +# monorepo, so the install needs the root package.json, the lockfile, and every +# workspace manifest before `npm ci` can reproduce the lockfile exactly. +# +# docker build -f apps/api/Dockerfile -t trailhead-api . +# +# The API runs TypeScript directly through tsx (see apps/api package.json +# "start"), so there is no compile step — but tsx is a devDependency, which is +# why the install must include dev deps. + +FROM node:22-alpine + +WORKDIR /app + +# --- Dependency layer ------------------------------------------------------- +# Manifests only, so this layer is cached until a package.json actually changes. +# `npm ci` refuses to run unless the lockfile matches the full workspace set, +# so every workspace manifest has to be present — even ones the API never +# imports. apps/landing-page has no package.json and is not a workspace. +COPY package.json package-lock.json ./ +COPY apps/api/package.json apps/api/ +COPY apps/browser-ext/package.json apps/browser-ext/ +COPY apps/dashboard/package.json apps/dashboard/ +COPY apps/mcp-server/package.json apps/mcp-server/ +COPY apps/vscode-ext/package.json apps/vscode-ext/ +COPY packages/score-card/package.json packages/score-card/ +COPY packages/scoring/package.json packages/scoring/ +COPY packages/shared/package.json packages/shared/ + +# --include=dev is explicit rather than implied: tsx is a devDependency and the +# runtime needs it. NODE_ENV is deliberately not set to production until after +# the install so npm doesn't silently prune it. +RUN npm ci --include=dev + +# --- Source layer ----------------------------------------------------------- +# Only what the API actually needs at runtime. packages/ carries the raw .ts +# that @trailhead/shared and @trailhead/scoring resolve to via the workspace +# symlinks npm ci created above. +COPY packages/ packages/ +COPY apps/api/ apps/api/ + +ENV NODE_ENV=production +# The container always listens on 3000; docker-compose.yml maps a host port to +# it. DATABASE_URL and GEMINI_API_KEY are injected by compose — the API throws +# at startup naming them if they are missing. +ENV PORT=3000 +EXPOSE 3000 + +# Run as the image's built-in non-root user. +USER node + +CMD ["npm", "--workspace=apps/api", "start"] diff --git a/apps/api/package.json b/apps/api/package.json index 4283732..4f5ac9c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,13 +1,15 @@ { "name": "@trailhead/api", "version": "0.0.0", + "license": "MIT", "private": true, "type": "module", "main": "src/index.ts", "scripts": { "dev": "tsx watch src/index.ts", "start": "tsx src/index.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsx --test src/gemini.test.ts src/coach-degraded.test.ts src/wiki-export.test.ts" }, "dependencies": { "@google/genai": "^1.50.1", diff --git a/apps/api/src/coach-degraded.test.ts b/apps/api/src/coach-degraded.test.ts new file mode 100644 index 0000000..f67153f --- /dev/null +++ b/apps/api/src/coach-degraded.test.ts @@ -0,0 +1,80 @@ +// Regression tests for the /coach silent-failure blocker. +// +// The bug these pin down: when scoring failed, /coach returned +// { proceed: true, text: '', overall: 0 }. The MCP coach tool renders an +// empty `text` on a proceed=true score-mode response as +// "(coach overall: 0/10 — no coaching needed)" — byte-identical to what it +// prints for a perfect prompt. The tool therefore ran indefinitely, never +// coaching and never erroring, and looked healthy the whole time. +// +// The contract asserted here is deliberately narrow and behavioural: +// 1. proceed stays true (an outage must not block the user's real work) +// 2. text is NON-EMPTY (this is what makes the failure visible) +// 3. degraded/error are set (this is what makes it machine-detectable) +// +// Run: npm --workspace=apps/api test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DIMENSIONS } from '@trailhead/shared'; +import type { DimensionScores } from '@trailhead/shared'; +import { degradedCoachResponse, degradeDetail } from './coach-degraded.ts'; + +const ZEROS = Object.fromEntries(DIMENSIONS.map((d) => [d, 0])) as DimensionScores; + +test('degraded response still proceeds — an outage must not block the user', () => { + const res = degradedCoachResponse('score', ZEROS, 'score_failed', new Error('boom')); + assert.equal(res.proceed, true); +}); + +test('degraded response never returns an empty text — that is the whole bug', () => { + for (const reason of ['score_failed', 'score_unparseable'] as const) { + const res = degradedCoachResponse('score', ZEROS, reason, new Error('boom')); + assert.notEqual(res.text, '', `${reason} must not produce an empty text`); + assert.ok(res.text.trim().length > 0); + // The MCP tool branches on `res.text && res.text.trim()` before it falls + // through to the "no coaching needed" string. A non-blank text is exactly + // what keeps it out of that branch. + assert.ok(!res.text.includes('no coaching needed')); + } +}); + +test('degraded response is machine-detectable via degraded + error', () => { + const res = degradedCoachResponse('score', ZEROS, 'score_unparseable'); + assert.equal(res.degraded, true); + assert.equal(res.error, 'score_unparseable'); +}); + +test('a healthy response is distinguishable from a degraded one', () => { + // The pre-fix shape: what a genuine "great prompt, nothing to coach" + // response looks like. It must not be confusable with the degraded shape. + const healthy = { proceed: true, text: '', degraded: undefined }; + const degraded = degradedCoachResponse('score', ZEROS, 'score_failed'); + assert.notEqual(Boolean(healthy.degraded), Boolean(degraded.degraded)); + assert.notEqual(healthy.text === '', degraded.text === ''); +}); + +test('the underlying error message is surfaced, not swallowed', () => { + const res = degradedCoachResponse('score', ZEROS, 'score_failed', new Error('429 quota exceeded')); + assert.match(res.text, /429 quota exceeded/); +}); + +test('degradeDetail handles non-Error throwables without producing "undefined"', () => { + assert.equal(degradeDetail('score_failed', 'plain string'), 'plain string'); + assert.equal(degradeDetail('score_failed', undefined), 'unknown error'); + assert.match(degradeDetail('score_unparseable'), /could not be parsed/); +}); + +test('the placeholder 0/10 is labelled as a placeholder, not a verdict', () => { + // A 0/10 that looks like a real score is its own kind of lie — it tells the + // user their prompt was terrible when in fact it was never read. + const res = degradedCoachResponse('score', ZEROS, 'score_failed'); + assert.equal(res.overall, 0); + assert.match(res.text, /placeholder/i); +}); + +test('mode is preserved so the caller can still branch on it', () => { + for (const mode of ['score', 'skip_reveal', 'augment'] as const) { + assert.equal(degradedCoachResponse(mode, ZEROS, 'score_failed').mode, mode); + } +}); diff --git a/apps/api/src/coach-degraded.ts b/apps/api/src/coach-degraded.ts new file mode 100644 index 0000000..9a5d217 --- /dev/null +++ b/apps/api/src/coach-degraded.ts @@ -0,0 +1,51 @@ +// The "coaching is unavailable this turn" response. +// +// Extracted from index.ts so it can be unit-tested: importing index.ts boots +// an HTTP listener and a Postgres pool, which a test has no business doing. +// +// Background. /coach used to handle a scoring failure by returning +// { proceed: true, overall: 0, dimensions: , missing: {}, text: '' }. +// The MCP coach tool renders an empty `text` on a proceed=true score-mode +// response as "(coach overall: 0/10 — no coaching needed)" — character for +// character what it prints for a flawless prompt. So when Gemini was down, +// or returned something unparseable, the tool ran forever: never coaching, +// never erroring, and reporting success. Silence is a worse failure than a +// crash, because nobody ever goes looking for it. +// +// The fix is not to stop failing open. `proceed` stays true on purpose: an +// outage in a coaching sidecar must never block someone's actual work. The +// fix is to stop failing *silent* — say plainly, in the field that gets +// relayed to the caller, that this turn was not scored and why. + +import type { CoachMode, CoachResponse, DimensionScores } from '@trailhead/shared'; + +export type DegradeReason = 'score_failed' | 'score_unparseable'; + +export function degradeDetail(reason: DegradeReason, err?: unknown): string { + if (reason === 'score_unparseable') { + return 'the model returned output that could not be parsed as a score'; + } + const msg = (err as { message?: string } | undefined)?.message; + return String(msg ?? err ?? 'unknown error'); +} + +export function degradedCoachResponse( + mode: CoachMode, + dimensions: DimensionScores, + reason: DegradeReason, + err?: unknown, +): CoachResponse { + return { + proceed: true, + mode, + overall: 0, + dimensions, + missing: {}, + degraded: true, + error: reason, + text: + `⚠️ Trailhead could not score this prompt, so it was not coached this turn ` + + `(${reason}: ${degradeDetail(reason, err)}). The 0/10 below is a placeholder, ` + + `not a judgement of the prompt. Proceed with the original prompt as written.`, + }; +} diff --git a/apps/api/src/gemini.test.ts b/apps/api/src/gemini.test.ts new file mode 100644 index 0000000..6083dcc --- /dev/null +++ b/apps/api/src/gemini.test.ts @@ -0,0 +1,219 @@ +// Integration tests for the Gemini wrapper — the 4,500-line backend's only +// path to an LLM, and until now the only major module in the repo with zero +// tests. That gap is exactly why the `tracedGenerate` self-recursion +// (gemini.ts:57) shipped to production: it was type-valid, so `tsc --noEmit` +// and CI stayed green while every single LLM call died with a RangeError +// before it ever reached the network. +// +// These tests stub `globalThis.fetch`, so nothing here touches the real +// Gemini API or needs a key that works. The stub is the whole point: it +// proves a request actually reaches the transport layer, which is precisely +// what infinite recursion prevents. +// +// Run: npm --workspace=apps/api test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DIMENSIONS } from '@trailhead/shared'; + +// GEMINI_API_KEY must be set BEFORE ./gemini.ts is evaluated — it throws at +// module scope when the key is absent. Static `import` declarations are +// hoisted above any statement in the module body, so this has to be a +// dynamic import after the assignment. The value is a placeholder: every +// request in this file is intercepted by the fetch stub, so it is never +// sent anywhere and is not a credential. +process.env.GEMINI_API_KEY ??= 'test-key-not-a-real-credential'; +const { extractTopic, overallScore, scorePrompt } = await import('./gemini.ts'); + +interface FetchCall { + url: string; + init: RequestInit | undefined; + body: unknown; +} + +/** + * Swap globalThis.fetch for a recorder that returns a canned Gemini + * generateContent response body. Returns the recorded calls plus a restore(). + */ +function withFetchStub( + responder: (call: FetchCall) => unknown, +): { calls: FetchCall[]; restore: () => void } { + const original = globalThis.fetch; + const calls: FetchCall[] = []; + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { + let body: unknown; + try { + body = typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body; + } catch { + body = init?.body; + } + const call: FetchCall = { url: String(input), init, body }; + calls.push(call); + const payload = responder(call); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof globalThis.fetch; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +/** Shape a Gemini generateContent response around a single text part. */ +function geminiTextResponse(text: string, usage?: Record) { + return { + candidates: [{ content: { role: 'model', parts: [{ text }] }, finishReason: 'STOP' }], + usageMetadata: usage ?? { + promptTokenCount: 100, + candidatesTokenCount: 50, + totalTokenCount: 150, + }, + }; +} + +const WELL_FORMED_SCORE = JSON.stringify({ + dimensions: { + goal_clarity: 8, + specificity: 6, + context_loading: 4, + constraint_articulation: 9, + output_specification: 3, + }, + missing: { + constraint_articulation: 'no libraries ruled out', + output_specification: 'no success criterion given', + }, +}); + +// --------------------------------------------------------------------------- +// The regression test for the blocker. Before the fix, `tracedGenerate` called +// itself instead of `ai.models.generateContent`, so this test would fail with +// "Maximum call stack size exceeded" and `calls.length` would be 0 — the +// request never left the process. +// --------------------------------------------------------------------------- +test('scorePrompt returns all five dimensions from a well-formed model response', async () => { + const stub = withFetchStub(() => geminiTextResponse(WELL_FORMED_SCORE)); + try { + const result = await scorePrompt({ prompt: 'fix the retry logic' }); + + // Five dimensions, all present, all integers in 0..10. + assert.equal( + Object.keys(result.dimensions).length, + 5, + 'expected exactly five scored dimensions', + ); + assert.equal(DIMENSIONS.length, 5); + for (const d of DIMENSIONS) { + const v = result.dimensions[d]; + assert.equal(typeof v, 'number', `${d} should be a number`); + assert.ok(Number.isInteger(v), `${d} should be an integer`); + assert.ok(v >= 0 && v <= 10, `${d}=${v} should be within 0..10`); + } + + assert.deepEqual(result.dimensions, { + goal_clarity: 8, + specificity: 6, + context_loading: 4, + constraint_articulation: 9, + output_specification: 3, + }); + + // The missing-hint block survives the isCleanHint filter. + assert.equal(result.missing.constraint_articulation, 'no libraries ruled out'); + assert.equal(result.missing.output_specification, 'no success criterion given'); + + // The point of the whole exercise: exactly one request actually reached + // the transport. Infinite recursion produces zero. + assert.equal(stub.calls.length, 1, 'expected exactly one HTTP request to Gemini'); + assert.match(stub.calls[0]!.url, /generativelanguage\.googleapis\.com/); + assert.match(stub.calls[0]!.url, /gemini-3-flash-preview/); + } finally { + stub.restore(); + } +}); + +test('scorePrompt sends the prompt and the five-dimension responseSchema on the wire', async () => { + const stub = withFetchStub(() => geminiTextResponse(WELL_FORMED_SCORE)); + try { + await scorePrompt({ prompt: 'add a webhook handler', file_path: 'src/api/hooks.ts' }); + const body = stub.calls[0]!.body as { + contents?: unknown; + generationConfig?: { responseSchema?: { properties?: { dimensions?: { required?: string[] } } } }; + }; + const wire = JSON.stringify(body); + assert.ok(wire.includes('add a webhook handler'), 'prompt must reach the model'); + assert.ok(wire.includes('src/api/hooks.ts'), 'file_path must reach the model'); + const required = body.generationConfig?.responseSchema?.properties?.dimensions?.required; + assert.deepEqual( + [...(required ?? [])].sort(), + [...DIMENSIONS].sort(), + 'responseSchema must require all five dimensions', + ); + } finally { + stub.restore(); + } +}); + +test('scorePrompt prepends team_context to the system instruction', async () => { + const stub = withFetchStub(() => geminiTextResponse(WELL_FORMED_SCORE)); + try { + await scorePrompt({ prompt: 'refactor the queue', team_context: 'TEAM CONVENTIONS: use pg pools' }); + const wire = JSON.stringify(stub.calls[0]!.body); + assert.ok(wire.includes('TEAM CONVENTIONS: use pg pools')); + } finally { + stub.restore(); + } +}); + +test('scorePrompt salvages the dimensions block from truncated JSON', async () => { + // The 2026-04-26 repetition failure mode: `missing` loops until + // maxOutputTokens, so the object never closes — but `dimensions` landed + // first and is intact. + const truncated = + '{\n "dimensions": {"goal_clarity": 7, "specificity": 2, "context_loading": 5, ' + + '"constraint_articulation": 6, "output_specification": 1},\n "missing": {"context_loading": "The prompt does not specify'; + const stub = withFetchStub(() => geminiTextResponse(truncated)); + try { + const result = await scorePrompt({ prompt: 'make it faster' }); + assert.equal(result.dimensions.goal_clarity, 7); + assert.equal(result.dimensions.output_specification, 1); + assert.equal(Object.keys(result.dimensions).length, 5); + } finally { + stub.restore(); + } +}); + +test('scorePrompt fails closed to all-zero dimensions on unparseable output', async () => { + const stub = withFetchStub(() => geminiTextResponse('I am afraid I cannot do that.')); + try { + const result = await scorePrompt({ prompt: 'do the thing' }); + for (const d of DIMENSIONS) assert.equal(result.dimensions[d], 0); + // Empty `missing` + all-zero is the fingerprint /coach uses to detect + // "the model failed" as distinct from "a genuinely terrible prompt". + assert.deepEqual(result.missing, {}); + } finally { + stub.restore(); + } +}); + +test('extractTopic reaches the network and returns the parsed topic', async () => { + const stub = withFetchStub(() => geminiTextResponse(JSON.stringify({ topic: 'retry' }))); + try { + assert.equal(await extractTopic('the fetch keeps failing on 503'), 'retry'); + assert.equal(stub.calls.length, 1); + } finally { + stub.restore(); + } +}); + +test('overallScore averages the five dimensions', () => { + assert.equal( + overallScore({ + goal_clarity: 8, + specificity: 6, + context_loading: 4, + constraint_articulation: 9, + output_specification: 3, + }), + 6, // 30 / 5 + ); +}); diff --git a/apps/api/src/gemini.ts b/apps/api/src/gemini.ts index e35da6d..134a5e0 100644 --- a/apps/api/src/gemini.ts +++ b/apps/api/src/gemini.ts @@ -54,7 +54,7 @@ async function tracedGenerate(params: GenContentParams): Promise }, }); try { - const resp = await tracedGenerate(params); + const resp = await ai.models.generateContent(params); if (gen) { const u = (resp as unknown as { usageMetadata?: { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f8f7318..14aa656 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -29,6 +29,7 @@ import type { ProvenPromptsResponse, ScoreRequest, ScoreResponse, + SearchResponse, SkillArcObservation, SkillArcResponse, TeamMetricsResponse, @@ -56,6 +57,10 @@ import { renderTeachBlock, } from '@trailhead/scoring'; import { applyTeamNameIfPlaceholder, DEMO_TEAM_TOKEN, q, ensureTeam, upsertNode, wipeTeamData } from './db.ts'; +import { createHash } from 'node:crypto'; +import { degradedCoachResponse } from './coach-degraded.ts'; +import { loadWikiTree } from './wiki-tree.ts'; +import { exportFilename, renderWikiMarkdown } from './wiki-export.ts'; import { acknowledgeProgress, extractTopic, @@ -74,11 +79,12 @@ import { bundleFromRequest, runJob } from './wiki-bootstrap-job.ts'; if (!process.env.DATABASE_URL) { console.error('DATABASE_URL not set'); process.exit(1); } if (!process.env.GEMINI_API_KEY) { console.error('GEMINI_API_KEY not set'); process.exit(1); } -// Multi-tenant policy. Defaults to ON for the hackathon-grade open demo -// posture: any X-Team-Token spawns its own teams row on first write. -// Production deploys should set TRAILHEAD_AUTO_CREATE_TEAMS=false and -// register teams explicitly. -const AUTO_CREATE_TEAMS = process.env.TRAILHEAD_AUTO_CREATE_TEAMS !== 'false'; +// Multi-tenant policy. Defaults to OFF: an unknown X-Team-Token is rejected +// with 401 rather than silently provisioning a tenant. It used to default ON, +// which meant any string anyone sent spawned a real teams row — unauthenticated +// tenant creation, and an unbounded write amplifier for anyone who found the +// host. Opt in with TRAILHEAD_AUTO_CREATE_TEAMS=true for open demo deploys. +const AUTO_CREATE_TEAMS = process.env.TRAILHEAD_AUTO_CREATE_TEAMS === 'true'; // Hono context typing — the auth middleware sets `team_token` so every // downstream handler can pull it via c.get('team_token') with type safety. @@ -122,9 +128,11 @@ app.use('*', async (c, next) => { // existing clients carrying the old token continue to land on the demo team. app.use('*', async (c, next) => { if (c.req.method === 'OPTIONS' || c.req.path === '/') return next(); - // /teams is unauthenticated so the popup can populate a Select-team - // dropdown before any token is configured. - if (c.req.path === '/teams') return next(); + // /teams used to be exempt here so the popup could populate a Select-team + // dropdown before any token was configured. That exemption published every + // tenant's credential to the open internet. Clients now resolve their own + // team by sending the token they already hold; GET / remains the + // unauthenticated reachability probe. const token = c.req.header('x-team-token'); if (!token) return c.json({ error: 'unauthorized', detail: 'missing X-Team-Token' }, 401); const teamToken = await ensureTeam(token, { autoCreate: AUTO_CREATE_TEAMS }); @@ -138,6 +146,15 @@ app.use('*', async (c, next) => { await next(); }); +// A stable, opaque handle for a team that is safe to hand to a client. +// +// SHA-256 of the token, truncated to 16 hex chars. Not reversible, not +// replayable as an X-Team-Token, and stable across requests so it works as a +// React key or a client-side lookup handle. +function opaqueTeamId(token: string): string { + return createHash('sha256').update(token).digest('hex').slice(0, 16); +} + app.get('/', (c) => c.json({ name: 'trailhead-api', @@ -156,10 +173,11 @@ app.get('/', (c) => 'GET /wiki/recent?since=ISO', 'POST /diff', 'POST /improve', - 'GET /teams (unauthenticated)', + 'GET /teams (your team only; never returns tokens)', 'GET /skill-arc?user_id=&since=ISO', 'GET /team/metrics', 'GET /wiki/tree', + 'GET /wiki/export?drafts=&format=', 'POST /onboard/repo', 'POST /onboard/repo/full', 'GET /onboard/jobs/:id', @@ -469,17 +487,14 @@ app.post('/coach', async (c) => { }); scoreResult = { dimensions: result.dimensions, missing: result.missing as Record }; } catch (err) { - console.warn('[api] /coach scorePrompt failed', err); + // Fail-open on `proceed`, but NEVER fail silent. A coaching outage must + // not block the user's real work, so proceed stays true — but the caller + // is told plainly that this turn was not coached, and why. Returning + // text:'' here (the old behaviour) made an outage look identical to a + // perfect prompt, so the MCP tool ran indefinitely without ever coaching. + console.error('[api] /coach scorePrompt failed', err); const zeros = Object.fromEntries(DIMENSIONS.map((d) => [d, 0])) as DimensionScores; - const res: CoachResponse = { - proceed: true, - mode, - overall: 0, - dimensions: zeros, - missing: {}, - text: '', - }; - return c.json(res); + return c.json(degradedCoachResponse(mode, zeros, 'score_failed', err)); } const overall = overallScore(scoreResult.dimensions); @@ -493,15 +508,10 @@ app.post('/coach', async (c) => { const allZero = DIMENSIONS.every((d) => scoreResult.dimensions[d] === 0); const noMissing = Object.keys(scoreResult.missing).length === 0; if (allZero && noMissing) { - const res: CoachResponse = { - proceed: true, - mode, - overall: 0, - dimensions: scoreResult.dimensions, - missing: {}, - text: '', - }; - return c.json(res); + console.error('[api] /coach scorePrompt returned unparseable output (zero+empty fingerprint)'); + return c.json( + degradedCoachResponse(mode, scoreResult.dimensions, 'score_unparseable'), + ); } // 2. Skill_observation writes (same dedup as /score). @@ -1212,7 +1222,8 @@ app.get('/search', async (c) => { [teamToken, pattern, ancestors, limit], ); - return c.json({ items: rows }); + const res: SearchResponse = { items: rows }; + return c.json(res); }); // ----- GET /wiki/recent?since=ISO -------------------------------------------- @@ -1449,118 +1460,68 @@ app.get('/team/metrics', async (c) => { // learnings split into durable vs draft. Sort by path (prefix-friendly). app.get('/wiki/tree', async (c) => { + const nodes = await loadWikiTree(c.get('team_token')); + const res: WikiTreeResponse = { nodes }; + return c.json(res); +}); + +// ----- GET /wiki/export ----------------------------------------------------- +// Markdown export of the whole team wiki. Teams will not pour knowledge into +// a store they cannot get it back out of, so this is a trust signal as much +// as a backup story. +// +// GET /wiki/export -> text/markdown, as a download +// GET /wiki/export?drafts=true -> include draft learnings too +// GET /wiki/export?format=json -> { filename, markdown } for browser clients +app.get('/wiki/export', async (c) => { const teamToken = c.get('team_token'); - // Two queries instead of a three-way LEFT JOIN to avoid the cartesian - // row explosion (nodes × learnings × prompts). Run in parallel — the - // round-trip overhead is negligible at hackathon scale. - const [rows, promptRows] = await Promise.all([ - q<{ - node_id: string; - path: string; - body_md: string; - learning_id: string | null; - learning_body: string | null; - learning_status: 'draft' | 'durable' | null; - reinforcement_count: number | null; - }>( - `SELECT n.id AS node_id, n.path, n.body_md, - l.id AS learning_id, l.body AS learning_body, - l.status AS learning_status, l.reinforcement_count - FROM nodes n - LEFT JOIN learnings l ON l.node_id = n.id - WHERE n.team_token = $1 - ORDER BY n.path ASC, - COALESCE(l.reinforcement_count, 0) DESC`, - [teamToken], - ), - q<{ - path: string; - prompt_id: string; - template: string; - topic: string | null; - reuse_count: number; - author_user_id: string | null; - }>( - `SELECT n.path, - p.id AS prompt_id, - p.template, - p.topic, - p.reuse_count, - p.author_user_id - FROM prompts p - JOIN nodes n ON n.id = p.node_id - WHERE n.team_token = $1 - AND p.status = 'graduated' - ORDER BY n.path ASC, - p.reuse_count DESC, - p.created_at DESC`, - [teamToken], - ), + const [nodes, teamRows] = await Promise.all([ + loadWikiTree(teamToken), + q<{ name: string }>('SELECT name FROM teams WHERE token = $1', [teamToken]), ]); + const teamName = teamRows[0]?.name; + const now = new Date(); + const markdown = renderWikiMarkdown(nodes, { + teamName, + generatedAt: now, + includeDrafts: c.req.query('drafts') === 'true', + }); + const filename = exportFilename(teamName, now); - const byPath = new Map(); - for (const r of rows) { - let node = byPath.get(r.path); - if (!node) { - node = { - path: r.path, - body_md: r.body_md, - durable_learnings: [], - draft_learnings: [], - graduated_prompts: [], - }; - byPath.set(r.path, node); - } - if (r.learning_id && r.learning_body && r.learning_status) { - const learning: WikiTreeLearning = { - id: r.learning_id, - body: r.learning_body, - status: r.learning_status, - reinforcement_count: r.reinforcement_count ?? 0, - }; - if (r.learning_status === 'durable') node.durable_learnings.push(learning); - else node.draft_learnings.push(learning); - } + if (c.req.query('format') === 'json') { + return c.json({ filename, markdown }); } - for (const r of promptRows) { - // Fallback init covers the rare case where a node carries prompts but - // never appeared in the learnings query (shouldn't happen since the - // first query LEFT JOINs every node, but defense-in-depth). - let node = byPath.get(r.path); - if (!node) { - node = { - path: r.path, - body_md: '', - durable_learnings: [], - draft_learnings: [], - graduated_prompts: [], - }; - byPath.set(r.path, node); - } - node.graduated_prompts.push({ - id: r.prompt_id, - template: r.template, - topic: r.topic, - reuse_count: r.reuse_count, - author_user_id: r.author_user_id, - }); - } - - const res: WikiTreeResponse = { nodes: Array.from(byPath.values()) }; - return c.json(res); + return new Response(markdown, { + status: 200, + headers: { + 'content-type': 'text/markdown; charset=utf-8', + 'content-disposition': `attachment; filename="${filename}"`, + }, + }); }); // ----- GET /teams ------------------------------------------------------------ -// Lists every team with a usable token. Unauthenticated (the popup needs to -// populate a Select-team dropdown before any token is configured). Demo -// simplicity: no per-user permission filter. +// Resolves the CALLER's team. Authenticated, and it never returns a token. +// +// This used to be unauthenticated and return every team on the server together +// with its token — with CORS `*`, so any web page could read it. The team token +// is the only credential in this system: it grants read on the wiki (which +// summarises private source code) and write on everything. A single unauth GET +// therefore compromised every tenant at once. The browser popup's convenience +// of pre-populating a team dropdown before any token was configured is what +// paid for that, and it is nowhere near worth the price. +// +// The response is a list of one so the TeamsListResponse shape (and every +// caller that maps over `teams`) keeps working. app.get('/teams', async (c) => { + const teamToken = c.get('team_token'); const rows = await q<{ name: string; token: string }>( - 'SELECT name, token FROM teams ORDER BY name ASC', + 'SELECT name, token FROM teams WHERE token = $1', + [teamToken], ); const teams: TeamSummary[] = rows.map((r) => ({ name: r.name, - token: r.token, + id: opaqueTeamId(r.token), })); const res: TeamsListResponse = { teams }; return c.json(res); diff --git a/apps/api/src/wiki-export.test.ts b/apps/api/src/wiki-export.test.ts new file mode 100644 index 0000000..c271495 --- /dev/null +++ b/apps/api/src/wiki-export.test.ts @@ -0,0 +1,177 @@ +// Tests for the wiki → markdown export. +// +// The renderer is pure, so these are real tests of the output contract rather +// than smoke tests around a DB. The interesting cases are the ones where a +// naive implementation silently corrupts the export: prompt templates that +// contain their own fences, learnings with embedded newlines, and duplicate +// anchor slugs. +// +// Run: npm --workspace=apps/api test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { WikiTreeNode } from '@trailhead/shared'; +import { + exportFilename, + fence, + renderWikiMarkdown, + slugify, + sortNodes, +} from './wiki-export.ts'; + +const AT = new Date('2026-08-17T12:00:00.000Z'); + +function node(partial: Partial & { path: string }): WikiTreeNode { + return { + body_md: '', + durable_learnings: [], + draft_learnings: [], + graduated_prompts: [], + ...partial, + }; +} + +test('empty wiki renders a valid document, not a crash or a blank string', () => { + const md = renderWikiMarkdown([], { teamName: 'Acme', generatedAt: AT }); + assert.match(md, /^# Acme — Trailhead wiki export/); + assert.match(md, /_This wiki is empty\._/); +}); + +test('header tallies nodes, learnings and prompts', () => { + const md = renderWikiMarkdown( + [ + node({ + path: 'src/api/', + durable_learnings: [ + { id: '1', body: 'Always use the pg pool', status: 'durable', reinforcement_count: 3 }, + ], + graduated_prompts: [ + { id: 'p1', template: 'do a thing', topic: 'retry', reuse_count: 2, author_user_id: 'bo' }, + ], + }), + node({ path: 'src/web/' }), + ], + { generatedAt: AT }, + ); + assert.match(md, /2 nodes, 1 learning, 1 graduated prompt/); + assert.match(md, /Exported 2026-08-17T12:00:00\.000Z/); +}); + +test('drafts are excluded by default and included on request', () => { + const nodes = [ + node({ + path: 'a/', + draft_learnings: [{ id: 'd1', body: 'unconfirmed hunch', status: 'draft', reinforcement_count: 1 }], + }), + ]; + const without = renderWikiMarkdown(nodes, { generatedAt: AT }); + assert.ok(!without.includes('unconfirmed hunch')); + assert.ok(!without.includes('Draft learnings')); + + const with_ = renderWikiMarkdown(nodes, { generatedAt: AT, includeDrafts: true }); + assert.ok(with_.includes('unconfirmed hunch')); + assert.match(with_, /### Draft learnings/); +}); + +// --------------------------------------------------------------------------- +// The case a naive implementation gets wrong. Prompt templates routinely +// contain ``` blocks; a hardcoded three-backtick fence closes early and +// truncates the export mid-document. +// --------------------------------------------------------------------------- +test('a prompt containing a fenced code block does not terminate its own fence', () => { + const template = 'Rewrite this:\n```ts\nconst x = 1;\n```\nMake it faster.'; + const md = renderWikiMarkdown( + [node({ path: 'src/', graduated_prompts: [{ id: 'p', template, topic: null, reuse_count: 0, author_user_id: null }] })], + { generatedAt: AT }, + ); + assert.ok(md.includes(template), 'the template must survive verbatim'); + // The opening fence must be longer than the longest run inside the body. + assert.ok(md.includes('````\nRewrite this:'), 'expected a four-backtick fence'); +}); + +test('fence widens past the longest backtick run', () => { + assert.equal(fence('plain'), '```\nplain\n```'); + assert.equal(fence('a ``` b'), '````\na ``` b\n````'); + assert.equal(fence('a ````` b').split('\n')[0], '``````'); +}); + +test('fence does not double a trailing newline', () => { + assert.equal(fence('x\n'), '```\nx\n```'); +}); + +test('multi-line learnings are collapsed so they cannot break the bullet list', () => { + const md = renderWikiMarkdown( + [ + node({ + path: 'a/', + durable_learnings: [ + { id: '1', body: 'line one\n\nline two', status: 'durable', reinforcement_count: 1 }, + ], + }), + ], + { generatedAt: AT }, + ); + assert.ok(md.includes('- line one line two')); + assert.ok(!md.includes('- line one\n\nline two')); +}); + +test('reinforcement count is shown only when it is above one', () => { + const mk = (n: number) => + renderWikiMarkdown( + [node({ path: 'a/', durable_learnings: [{ id: '1', body: 'x', status: 'durable', reinforcement_count: n }] })], + { generatedAt: AT }, + ); + assert.ok(!mk(1).includes('reinforced')); + assert.ok(mk(4).includes('reinforced 4×')); +}); + +test('duplicate slugs get distinct anchors so the table of contents works', () => { + // 'src/api/' and 'src.api.' both slugify to 'src-api'. + const md = renderWikiMarkdown([node({ path: 'src/api/' }), node({ path: 'src.api.' })], { + generatedAt: AT, + }); + assert.ok(md.includes('')); + assert.ok(md.includes('')); + assert.ok(md.includes('(#src-api)')); + assert.ok(md.includes('(#src-api-1)')); +}); + +test('slugify never returns an empty anchor', () => { + assert.equal(slugify('///'), 'node'); + assert.equal(slugify('!!!'), 'node'); + assert.equal(slugify('src/api/auth.ts'), 'src-api-auth-ts'); +}); + +test('nodes are emitted in stable path order regardless of input order', () => { + const sorted = sortNodes([node({ path: 'b/' }), node({ path: 'a/' }), node({ path: 'a/c.ts' })]); + assert.deepEqual(sorted.map((n) => n.path), ['a/', 'a/c.ts', 'b/']); +}); + +test('node body_md is included verbatim', () => { + const md = renderWikiMarkdown([node({ path: 'a/', body_md: '## Handwritten\n\nSome prose.' })], { + generatedAt: AT, + }); + assert.ok(md.includes('## Handwritten')); + assert.ok(md.includes('Some prose.')); +}); + +test('prompt metadata is rendered when present and omitted when not', () => { + const withMeta = renderWikiMarkdown( + [node({ path: 'a/', graduated_prompts: [{ id: 'p', template: 't', topic: 'retry', reuse_count: 3, author_user_id: 'bo' }] })], + { generatedAt: AT }, + ); + assert.match(withMeta, /\*\*Prompt\*\* — topic: retry, reused 3×, by bo/); + + const bare = renderWikiMarkdown( + [node({ path: 'a/', graduated_prompts: [{ id: 'p', template: 't', topic: null, reuse_count: 0, author_user_id: null }] })], + { generatedAt: AT }, + ); + assert.ok(bare.includes('**Prompt**\n')); + assert.ok(!bare.includes('**Prompt** —')); +}); + +test('exportFilename is filesystem-safe and date-stamped', () => { + assert.equal(exportFilename('Acme Fintech', AT), 'acme-fintech-wiki-2026-08-17.md'); + assert.equal(exportFilename(undefined, AT), 'trailhead-wiki-2026-08-17.md'); + assert.ok(!exportFilename('a/b:c*d', AT).match(/[/:*]/)); +}); diff --git a/apps/api/src/wiki-export.ts b/apps/api/src/wiki-export.ts new file mode 100644 index 0000000..a9b384a --- /dev/null +++ b/apps/api/src/wiki-export.ts @@ -0,0 +1,189 @@ +// Wiki → Markdown export. +// +// Why this exists: a team pours accumulated knowledge into this wiki, and +// until now there was no way to get it back out. That makes the store a +// roach motel — knowledge checks in, it doesn't check out — which is a +// reasonable thing for a team to refuse to adopt. Export is both the trust +// signal and the backup story. +// +// Everything here is pure: nodes in, markdown string out. No DB, no network, +// no clock unless one is passed in. That is what makes it testable, and the +// tests are the spec. + +import type { WikiTreeNode } from '@trailhead/shared'; + +export interface WikiExportOptions { + /** Team display name for the document header. */ + teamName?: string; + /** Timestamp for the header. Injected so tests are deterministic. */ + generatedAt?: Date; + /** Include draft (un-reinforced) learnings. Default false — drafts are noise. */ + includeDrafts?: boolean; +} + +/** + * Fence a block of text so that any backticks inside it cannot terminate the + * fence early. CommonMark: a fenced block is closed only by a run of at least + * as many backticks as opened it, so we open with (longest inner run + 1), + * minimum 3. + * + * Prompt templates routinely contain ``` blocks, so a naive three-backtick + * fence would truncate the export mid-prompt and silently corrupt the output. + */ +export function fence(body: string, info = ''): string { + let longest = 0; + for (const run of body.match(/`+/g) ?? []) { + if (run.length > longest) longest = run.length; + } + const ticks = '`'.repeat(Math.max(3, longest + 1)); + // A trailing newline keeps the closing fence on its own line even when the + // body does not end in one. + const sep = body.endsWith('\n') ? '' : '\n'; + return `${ticks}${info}\n${body}${sep}${ticks}`; +} + +/** + * Turn a wiki path into a GitHub-style anchor slug, for the table of contents. + * Deliberately simple and deterministic; collisions get a numeric suffix from + * the caller. + */ +export function slugify(path: string): string { + return ( + path + .toLowerCase() + .replace(/[^a-z0-9/_. -]/g, '') + .replace(/[/_. ]+/g, '-') + .replace(/^-+|-+$/g, '') || 'node' + ); +} + +/** Sort nodes by path, folders (trailing '/') before files at the same level. */ +export function sortNodes(nodes: readonly WikiTreeNode[]): WikiTreeNode[] { + return [...nodes].sort((a, b) => a.path.localeCompare(b.path)); +} + +/** + * Render the whole team wiki as one self-contained markdown document. + */ +export function renderWikiMarkdown( + nodes: readonly WikiTreeNode[], + opts: WikiExportOptions = {}, +): string { + const { teamName, generatedAt, includeDrafts = false } = opts; + const sorted = sortNodes(nodes); + const out: string[] = []; + + out.push(`# ${teamName ? `${teamName} — ` : ''}Trailhead wiki export`); + out.push(''); + const stamp = generatedAt ? generatedAt.toISOString() : null; + const counts = tally(sorted, includeDrafts); + out.push( + `> ${counts.nodes} node${counts.nodes === 1 ? '' : 's'}, ` + + `${counts.learnings} learning${counts.learnings === 1 ? '' : 's'}, ` + + `${counts.prompts} graduated prompt${counts.prompts === 1 ? '' : 's'}` + + (stamp ? `. Exported ${stamp}.` : '.'), + ); + out.push(''); + + if (sorted.length === 0) { + out.push('_This wiki is empty._'); + out.push(''); + return out.join('\n'); + } + + // Table of contents. + out.push('## Contents'); + out.push(''); + const seen = new Map(); + const anchors: string[] = []; + for (const n of sorted) { + const base = slugify(n.path); + const dup = seen.get(base) ?? 0; + seen.set(base, dup + 1); + const anchor = dup === 0 ? base : `${base}-${dup}`; + anchors.push(anchor); + out.push(`- [${n.path}](#${anchor})`); + } + out.push(''); + + sorted.forEach((n, i) => { + out.push('---'); + out.push(''); + out.push(`## ${n.path}`); + out.push(''); + // An explicit anchor keeps the TOC links working regardless of how the + // renderer derives heading ids. + out.push(``); + out.push(''); + + if (n.body_md.trim()) { + out.push(n.body_md.trim()); + out.push(''); + } + + const durable = n.durable_learnings ?? []; + if (durable.length) { + out.push('### Durable learnings'); + out.push(''); + for (const l of durable) { + const rc = l.reinforcement_count ?? 0; + out.push(`- ${oneLine(l.body)}${rc > 1 ? ` _(reinforced ${rc}×)_` : ''}`); + } + out.push(''); + } + + const drafts = n.draft_learnings ?? []; + if (includeDrafts && drafts.length) { + out.push('### Draft learnings'); + out.push(''); + for (const l of drafts) { + out.push(`- ${oneLine(l.body)}`); + } + out.push(''); + } + + const prompts = n.graduated_prompts ?? []; + if (prompts.length) { + out.push('### Graduated prompts'); + out.push(''); + for (const p of prompts) { + const bits: string[] = []; + if (p.topic) bits.push(`topic: ${p.topic}`); + if (p.reuse_count) bits.push(`reused ${p.reuse_count}×`); + if (p.author_user_id) bits.push(`by ${p.author_user_id}`); + out.push(`**Prompt**${bits.length ? ` — ${bits.join(', ')}` : ''}`); + out.push(''); + out.push(fence(p.template)); + out.push(''); + } + } + }); + + return out.join('\n'); +} + +function tally(nodes: readonly WikiTreeNode[], includeDrafts: boolean) { + let learnings = 0; + let prompts = 0; + for (const n of nodes) { + learnings += (n.durable_learnings ?? []).length; + if (includeDrafts) learnings += (n.draft_learnings ?? []).length; + prompts += (n.graduated_prompts ?? []).length; + } + return { nodes: nodes.length, learnings, prompts }; +} + +/** + * Collapse a learning body to a single line so it cannot break out of the + * bullet it is rendered in. + */ +function oneLine(s: string): string { + return s.replace(/\s*\n\s*/g, ' ').trim(); +} + +/** Filename for the downloaded export. */ +export function exportFilename(teamName: string | undefined, at: Date): string { + const slug = slugify(teamName ?? 'trailhead') || 'trailhead'; + const day = at.toISOString().slice(0, 10); + return `${slug}-wiki-${day}.md`; +} diff --git a/apps/api/src/wiki-tree.ts b/apps/api/src/wiki-tree.ts new file mode 100644 index 0000000..5021d2c --- /dev/null +++ b/apps/api/src/wiki-tree.ts @@ -0,0 +1,108 @@ +// Loads a team's whole wiki tree. +// +// Extracted verbatim from the GET /wiki/tree handler so that the markdown +// export (wiki-export.ts) reads the same rows through the same query rather +// than growing a second, subtly-different copy that drifts. + +import type { WikiTreeLearning, WikiTreeNode } from '@trailhead/shared'; +import { q } from './db.ts'; + +export async function loadWikiTree(teamToken: string): Promise { + // Two queries instead of a three-way LEFT JOIN to avoid the cartesian + // row explosion (nodes × learnings × prompts). Run in parallel — the + // round-trip overhead is negligible at hackathon scale. + const [rows, promptRows] = await Promise.all([ + q<{ + node_id: string; + path: string; + body_md: string; + learning_id: string | null; + learning_body: string | null; + learning_status: 'draft' | 'durable' | null; + reinforcement_count: number | null; + }>( + `SELECT n.id AS node_id, n.path, n.body_md, + l.id AS learning_id, l.body AS learning_body, + l.status AS learning_status, l.reinforcement_count + FROM nodes n + LEFT JOIN learnings l ON l.node_id = n.id + WHERE n.team_token = $1 + ORDER BY n.path ASC, + COALESCE(l.reinforcement_count, 0) DESC`, + [teamToken], + ), + q<{ + path: string; + prompt_id: string; + template: string; + topic: string | null; + reuse_count: number; + author_user_id: string | null; + }>( + `SELECT n.path, + p.id AS prompt_id, + p.template, + p.topic, + p.reuse_count, + p.author_user_id + FROM prompts p + JOIN nodes n ON n.id = p.node_id + WHERE n.team_token = $1 + AND p.status = 'graduated' + ORDER BY n.path ASC, + p.reuse_count DESC, + p.created_at DESC`, + [teamToken], + ), + ]); + + const byPath = new Map(); + for (const r of rows) { + let node = byPath.get(r.path); + if (!node) { + node = { + path: r.path, + body_md: r.body_md, + durable_learnings: [], + draft_learnings: [], + graduated_prompts: [], + }; + byPath.set(r.path, node); + } + if (r.learning_id && r.learning_body && r.learning_status) { + const learning: WikiTreeLearning = { + id: r.learning_id, + body: r.learning_body, + status: r.learning_status, + reinforcement_count: r.reinforcement_count ?? 0, + }; + if (r.learning_status === 'durable') node.durable_learnings.push(learning); + else node.draft_learnings.push(learning); + } + } + for (const r of promptRows) { + // Fallback init covers the rare case where a node carries prompts but + // never appeared in the learnings query (shouldn't happen since the + // first query LEFT JOINs every node, but defense-in-depth). + let node = byPath.get(r.path); + if (!node) { + node = { + path: r.path, + body_md: '', + durable_learnings: [], + draft_learnings: [], + graduated_prompts: [], + }; + byPath.set(r.path, node); + } + node.graduated_prompts.push({ + id: r.prompt_id, + template: r.template, + topic: r.topic, + reuse_count: r.reuse_count, + author_user_id: r.author_user_id, + }); + } + + return Array.from(byPath.values()); +} diff --git a/apps/browser-ext/README.md b/apps/browser-ext/README.md index b8b1707..95ddbd4 100644 --- a/apps/browser-ext/README.md +++ b/apps/browser-ext/README.md @@ -40,15 +40,13 @@ extension: ## Pinned demo Chrome (spec §19) -Lock the demo machine to a specific Chrome build. After verifying the -selectors at hour 6, capture `chrome://version` to: +The original plan was to lock the demo machine to a specific Chrome build by +capturing `chrome://version` into `apps/browser-ext/PINNED_CHROME.txt`. That +file was never created and no build is pinned — treat the selectors as +unverified against any particular Chrome version. -``` -apps/browser-ext/PINNED_CHROME.txt -``` - -Use a separate Chrome profile for the demo (`chrome://settings/manageProfile`) -so extension state doesn't drift. +Using a separate Chrome profile (`chrome://settings/manageProfile`) is still +worthwhile so extension state doesn't drift between runs. ## Smoke test diff --git a/apps/browser-ext/manifest.json b/apps/browser-ext/manifest.json index f50e39d..1a25c53 100644 --- a/apps/browser-ext/manifest.json +++ b/apps/browser-ext/manifest.json @@ -7,7 +7,12 @@ "permissions": ["storage"], "host_permissions": [ "https://claude.ai/*", - "https://trailheadapi-production.up.railway.app/*" + "http://localhost/*", + "http://127.0.0.1/*" + ], + "optional_host_permissions": [ + "http://*/*", + "https://*/*" ], "action": { "default_title": "LearnLoop", diff --git a/apps/browser-ext/package.json b/apps/browser-ext/package.json index 784c30d..3e94e4b 100644 --- a/apps/browser-ext/package.json +++ b/apps/browser-ext/package.json @@ -1,25 +1,26 @@ -{ - "name": "@trailhead/browser-ext", - "version": "0.0.1", - "private": true, - "type": "module", - "description": "LearnLoop browser extension — live 5-dimension score-card on Claude.ai", - "scripts": { - "build": "node esbuild.config.mjs", - "watch": "node esbuild.config.mjs --watch", - "typecheck": "tsc --noEmit", - "test": "npm run build && node --test --experimental-strip-types src/hash.test.mts src/augment.test.mts src/api.test.mts src/diff-parse.test.mts src/widgets/wiki-toast.test.mts test/bundle-load.test.mjs", - "smoke": "bash scripts/smoke.sh" - }, - "dependencies": { - "@trailhead/scoring": "*", - "@trailhead/score-card": "*", - "@trailhead/shared": "*" - }, - "devDependencies": { - "@types/chrome": "^0.0.287", - "@types/node": "^22.10.0", - "esbuild": "^0.24.0", - "typescript": "^5.7.2" - } -} +{ + "name": "@trailhead/browser-ext", + "version": "0.0.1", + "license": "MIT", + "private": true, + "type": "module", + "description": "LearnLoop browser extension — live 5-dimension score-card on Claude.ai", + "scripts": { + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test --experimental-strip-types src/hash.test.mts src/augment.test.mts src/api.test.mts src/diff-parse.test.mts src/selectors-classify.test.mts src/widgets/wiki-toast.test.mts test/bundle-load.test.mjs", + "smoke": "bash scripts/smoke.sh" + }, + "dependencies": { + "@trailhead/scoring": "*", + "@trailhead/score-card": "*", + "@trailhead/shared": "*" + }, + "devDependencies": { + "@types/chrome": "^0.0.287", + "@types/node": "^22.10.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.2" + } +} diff --git a/apps/browser-ext/scripts/smoke.sh b/apps/browser-ext/scripts/smoke.sh index bc515ad..3172b6e 100644 --- a/apps/browser-ext/scripts/smoke.sh +++ b/apps/browser-ext/scripts/smoke.sh @@ -1,21 +1,41 @@ #!/usr/bin/env bash # Live API smoke test (spec §7.4). Hand-runnable: covers /score, /capture, -# /diff, /wiki/recent against the deployed Railway endpoint with the demo -# team token. Pass --local to point at http://localhost:3000. +# /diff, /wiki/recent against a self-hosted Trailhead API with the demo team +# token. +# +# Trailhead has no hosted API — bring one up with `docker compose up` from the +# repo root (see SELFHOSTING.md). Set TRAILHEAD_API_URL to point elsewhere. set -euo pipefail -API_URL="${TRAILHEAD_API_URL:-https://trailheadapi-production.up.railway.app}" +DEFAULT_API_URL="http://localhost:3000" +API_URL="${TRAILHEAD_API_URL:-}" TOKEN="${TRAILHEAD_TEAM_TOKEN:-trailhead_demo_acme_2026}" if [[ "${1:-}" == "--local" ]]; then - API_URL="http://localhost:3000" + API_URL="$DEFAULT_API_URL" +fi + +if [[ -z "$API_URL" ]]; then + API_URL="$DEFAULT_API_URL" + echo "! TRAILHEAD_API_URL not set — defaulting to ${API_URL}" >&2 + echo " Start a self-hosted API with \`docker compose up\` from the repo root," >&2 + echo " or export TRAILHEAD_API_URL=." >&2 + echo >&2 fi if ! command -v curl >/dev/null 2>&1; then echo "smoke.sh requires curl" >&2 exit 1 fi + +# Fail loudly and early rather than emitting six confusing curl errors. +if ! curl -sSf -o /dev/null --max-time 5 "${API_URL}/teams" 2>/dev/null; then + echo "! Cannot reach a Trailhead API at ${API_URL}" >&2 + echo " Start one with \`docker compose up\` (see SELFHOSTING.md), or set" >&2 + echo " TRAILHEAD_API_URL to the base URL of your server." >&2 + exit 1 +fi JQ="cat" if command -v jq >/dev/null 2>&1; then JQ="jq ." diff --git a/apps/browser-ext/src/api-url-state.ts b/apps/browser-ext/src/api-url-state.ts new file mode 100644 index 0000000..3eea26f --- /dev/null +++ b/apps/browser-ext/src/api-url-state.ts @@ -0,0 +1,98 @@ +// Cached, synchronously-readable view of the popup-controlled API base URL. +// +// Mirrors team-state.ts: seeded from chrome.storage.local at init and kept +// live via chrome.storage.onChanged, so editing the URL in the popup takes +// effect on an already-open Claude.ai tab without a reload. +// +// Storage key: 'trailhead.apiUrl' — string. Falls back to DEFAULT_API_URL +// (a self-hosted API on this machine) when nothing is stored. Trailhead has +// no hosted API: see SELFHOSTING.md at the repo root. + +import { API_URL_KEY, DEFAULT_API_URL, TRAILHEAD_ERROR_TAG } from './config.ts'; + +let currentUrl = DEFAULT_API_URL; +const subscribers = new Set<() => void>(); + +/** Trim whitespace and any trailing slash so callers can append '/score' + * without producing a double slash. Returns '' for unusable input, which + * callers treat as "not configured". */ +export function normalizeApiUrl(raw: unknown): string { + if (typeof raw !== 'string') return ''; + const trimmed = raw.trim().replace(/\/+$/, ''); + if (!trimmed) return ''; + try { + const u = new URL(trimmed); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; + } catch { + return ''; + } + return trimmed; +} + +/** The API base URL every request should use, with no trailing slash. */ +export function getApiUrl(): string { + return currentUrl; +} + +/** True when the user has never edited the URL — i.e. we're on the built-in + * localhost default. Used to tailor the "can't reach the API" hint. */ +export function isDefaultApiUrl(): boolean { + return currentUrl === DEFAULT_API_URL; +} + +/** One actionable sentence naming exactly what the user must do. Logged on + * every network-level failure so a dead/unconfigured API is never silent. */ +export function apiUnreachableHint(): string { + return isDefaultApiUrl() + ? `${TRAILHEAD_ERROR_TAG} cannot reach the Trailhead API at ${currentUrl}. ` + + `Trailhead is self-hosted — start it with \`docker compose up\` (see SELFHOSTING.md), ` + + `or open the extension popup and set "API server" to your API's URL.` + : `${TRAILHEAD_ERROR_TAG} cannot reach the Trailhead API at ${currentUrl}. ` + + `Check that the server is running, or correct "API server" in the extension popup.`; +} + +export function subscribeApiUrl(cb: () => void): () => void { + subscribers.add(cb); + return () => { + subscribers.delete(cb); + }; +} + +function notify(): void { + for (const cb of subscribers) { + try { + cb(); + } catch { + /* swallow — one bad subscriber can't break the others */ + } + } +} + +export function initApiUrlState(): void { + try { + const get = (chrome as any)?.storage?.local?.get; + if (typeof get !== 'function') return; + get.call((chrome as any).storage.local, API_URL_KEY, (out: Record) => { + const stored = normalizeApiUrl(out?.[API_URL_KEY]); + if (stored) { + currentUrl = stored; + console.info(`${TRAILHEAD_ERROR_TAG} API URL loaded from storage: ${currentUrl}`); + } else { + console.info(`${TRAILHEAD_ERROR_TAG} API URL not configured — using default ${currentUrl}`); + } + }); + const onChanged = (chrome as any)?.storage?.onChanged?.addListener; + if (typeof onChanged !== 'function') return; + onChanged.call( + (chrome as any).storage.onChanged, + (changes: Record, area: string) => { + if (area !== 'local' || !(API_URL_KEY in changes)) return; + currentUrl = normalizeApiUrl(changes[API_URL_KEY]?.newValue) || DEFAULT_API_URL; + console.info(`${TRAILHEAD_ERROR_TAG} API URL changed → ${currentUrl}`); + notify(); + }, + ); + } catch { + // chrome.* unavailable — leave the default in place. + } +} diff --git a/apps/browser-ext/src/api.ts b/apps/browser-ext/src/api.ts index cd704c7..bd428a3 100644 --- a/apps/browser-ext/src/api.ts +++ b/apps/browser-ext/src/api.ts @@ -20,7 +20,8 @@ import type { ScoreResponse, WikiRecentResponse, } from '@trailhead/shared'; -import { API_URL, FETCH_TIMEOUT_MS, TRAILHEAD_ERROR_TAG } from './config.ts'; +import { FETCH_TIMEOUT_MS, TRAILHEAD_ERROR_TAG } from './config.ts'; +import { apiUnreachableHint, getApiUrl } from './api-url-state.ts'; import { getTeamToken } from './team-state.ts'; import { getContextPath } from './context-state.ts'; @@ -44,6 +45,20 @@ function withTimeout(ac: AbortController, ms: number): () => void { return () => clearTimeout(id); } +// Failure logging. A self-hosted API that isn't running (or is configured to +// the wrong host) fails at the network layer, which fetch reports as TypeError +// — distinct from a 4xx/5xx, which resolves normally and returns null. Those +// get the actionable "here is what to fix" message rather than an opaque +// stack, so an unconfigured API is never a silent no-op. +function logFailure(path: string, err: unknown): void { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (err instanceof TypeError) { + console.warn(`${apiUnreachableHint()} (request: ${path})`, err); + return; + } + console.warn(`${TRAILHEAD_ERROR_TAG} ${path} failed`, err); +} + function headers(): Record { return { 'Content-Type': 'application/json', @@ -59,7 +74,7 @@ async function call( const ac = abortPrev(key); const stop = withTimeout(ac, FETCH_TIMEOUT_MS); try { - const res = await fetch(`${API_URL}${path}`, { + const res = await fetch(`${getApiUrl()}${path}`, { method: init.method, headers: headers(), body: init.body !== undefined ? JSON.stringify(init.body) : undefined, @@ -70,9 +85,7 @@ async function call( } catch (err) { // Aborts and network errors share a single failure path. We log so the // demonstrator can `console.warn` to debug; we never re-throw. - if (!(err instanceof DOMException && err.name === 'AbortError')) { - console.warn(`${TRAILHEAD_ERROR_TAG} ${path} failed`, err); - } + logFailure(path, err); return null; } finally { stop(); @@ -113,7 +126,7 @@ export async function coach(body: CoachRequest): Promise { const ac = new AbortController(); const stop = setTimeout(() => ac.abort(), 25_000); try { - const res = await fetch(`${API_URL}/coach`, { + const res = await fetch(`${getApiUrl()}/coach`, { method: 'POST', headers: headers(), body: JSON.stringify(enriched), @@ -122,9 +135,7 @@ export async function coach(body: CoachRequest): Promise { if (!res.ok) return null; return (await res.json()) as CoachResponse; } catch (err) { - if (!(err instanceof DOMException && err.name === 'AbortError')) { - console.warn(`${TRAILHEAD_ERROR_TAG} /coach failed`, err); - } + logFailure('/coach', err); return null; } finally { clearTimeout(stop); @@ -140,7 +151,7 @@ export async function improve(body: ImproveRequest): Promise ac.abort(), 25_000); try { - const res = await fetch(`${API_URL}/improve`, { + const res = await fetch(`${getApiUrl()}/improve`, { method: 'POST', headers: headers(), body: JSON.stringify(enriched), @@ -149,9 +160,7 @@ export async function improve(body: ImproveRequest): Promise('div, article, li')) { + for (const node of root.querySelectorAll(BUBBLE_HINT_SELECTOR)) { handleNode(node, sel); } } @@ -182,6 +188,10 @@ async function main(): Promise { // Subscribe to the coaching toggle so the popup switch takes effect // live — no page reload needed. initCoachingState(); + // Trailhead is self-hosted: the API base URL is user config, edited in + // the popup's "API server" row. Seed it before any fetch so requests go + // to the user's server rather than the localhost default. + initApiUrlState(); // Same pattern for the popup's Select-team dropdown — every fetch // after the user picks a team uses that team's X-Team-Token. initTeamState(); diff --git a/apps/browser-ext/src/context-bundle.ts b/apps/browser-ext/src/context-bundle.ts index 8989a4e..3b62e44 100644 Binary files a/apps/browser-ext/src/context-bundle.ts and b/apps/browser-ext/src/context-bundle.ts differ diff --git a/apps/browser-ext/src/popup/popup.html b/apps/browser-ext/src/popup/popup.html index bc546ed..dd12d75 100644 --- a/apps/browser-ext/src/popup/popup.html +++ b/apps/browser-ext/src/popup/popup.html @@ -316,6 +316,58 @@ transition: background 120ms var(--ease); } .team-dropdown li:hover { background: rgba(255,255,255,0.08); } + + /* Team-token editor. Replaced the pick-a-team list, which was built from + an unauthenticated /teams that handed out every tenant's token. */ + .team-dropdown li.team-editor, + .team-dropdown li.team-editor:hover { + display: block; + background: none; + cursor: default; + padding: 10px 12px; + } + .team-editor label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.6; + margin-bottom: 4px; + } + .team-editor input { + width: 100%; + box-sizing: border-box; + font: inherit; + font-size: 12px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.18); + background: rgba(0,0,0,0.25); + color: inherit; + } + .team-editor input:focus-visible { + outline: none; + border-color: rgba(255,255,255,0.45); + } + .team-editor button { + margin-top: 8px; + width: 100%; + font: inherit; + font-size: 12px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.18); + background: rgba(255,255,255,0.08); + color: inherit; + cursor: pointer; + } + .team-editor button:hover { background: rgba(255,255,255,0.14); } + .team-editor .team-hint { + margin: 8px 0 0; + font-size: 11px; + line-height: 1.4; + opacity: 0.55; + } .team-dropdown li.is-current { background: var(--accent-bg); color: var(--text); @@ -374,6 +426,65 @@ } .team-dropdown::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.20); } + /* ===== API server row ===== */ + .api-input-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + align-items: stretch; + } + .api-input { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; + color: var(--text); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 10px; + min-width: 0; + width: 100%; + transition: border-color 150ms var(--ease), background 150ms var(--ease); + } + .api-input::placeholder { color: var(--text-faint); } + .api-input:hover { border-color: var(--border-strong); } + .api-input:focus { + outline: none; + background: var(--surface-2); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); + } + .api-save-btn { + padding: 0 12px; + font-size: 11.5px; + font-weight: 500; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + transition: background 120ms var(--ease), border-color 120ms var(--ease); + white-space: nowrap; + } + .api-save-btn:hover:not(:disabled) { + background: var(--accent-bg); + border-color: var(--accent-border); + } + .api-save-btn:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); + } + .api-status { + font-size: 10.5px; + line-height: 1.4; + color: var(--text-muted); + padding: 0 2px; + display: flex; + align-items: baseline; + gap: 5px; + } + .api-status.is-error { color: var(--bad); } + .api-status.is-ok { color: var(--good); } + /* ===== Footer hint ===== */ .hint { font-size: 11.5px; @@ -490,6 +601,26 @@ + +
+ +
+ + +
+
+
+
When off, the extension stops intercepting sends.
diff --git a/apps/browser-ext/src/popup/popup.ts b/apps/browser-ext/src/popup/popup.ts index 7e4256a..22c37e4 100644 --- a/apps/browser-ext/src/popup/popup.ts +++ b/apps/browser-ext/src/popup/popup.ts @@ -1,22 +1,35 @@ -// Popup script. Surfaces three controls: +// Popup script. Surfaces four controls: // - Coaching on/off switch +// - API server — the base URL of the self-hosted Trailhead API. Trailhead +// ships no hosted backend, so this is required config; it defaults to +// http://localhost:3000 (what `docker compose up` publishes) and is +// persisted to chrome.storage.local.. The content script +// watches that key, so a change lands on open tabs without a reload. // - Select context — fetches GET /wiki/tree and lets the user pick a // subtree root; persisted to chrome.storage.local. // so the content script prepends the rendered subtree to every send. -// - Select team — fetches GET /teams and persists the chosen team's -// X-Team-Token to chrome.storage.local.. +// - Select team — takes the team token and persists it to +// chrome.storage.local., then resolves the team's display +// name via the authenticated GET /teams. // -// Both pickers cache their first fetch in popup memory so re-opening the +// That last control used to be a dropdown listing every team on the server, +// built from an unauthenticated GET /teams that returned each team's token. +// Clicking a row adopted another tenant's credential, and merely opening the +// popup fetched all of them. The endpoint now authenticates and returns only +// the caller's own team, without a token, so switching teams means supplying +// the token for a team you are actually entitled to. +// +// The context picker caches its first fetch in popup memory so re-opening the // dropdown is instant. A team change implicitly invalidates the wiki tree // (different team → different nodes), so cachedTree is dropped on token // change. -import { API_URL } from '../config.ts'; +import { API_URL_KEY, DEFAULT_API_URL } from '../config.ts'; +import { normalizeApiUrl } from '../api-url-state.ts'; import { TEAM_TOKEN_KEY, TEAM_NAME_KEY } from '../team-state.ts'; import { CONTEXT_PATH_KEY } from '../context-state.ts'; import { TEAM_TOKEN as DEFAULT_TEAM_TOKEN } from '../config.ts'; import type { - TeamSummary, TeamsListResponse, WikiTreeNode, WikiTreeResponse, @@ -37,8 +50,15 @@ const contextStatusEl = document.getElementById('context-status') as HTMLDivElem const contextTreeEl = document.getElementById('context-tree') as HTMLUListElement; const hintEl = document.getElementById('coaching-hint') as HTMLDivElement; const toastEl = document.getElementById('toast') as HTMLDivElement; +const apiUrlInputEl = document.getElementById('api-url-input') as HTMLInputElement; +const apiUrlSaveEl = document.getElementById('api-url-save') as HTMLButtonElement; +const apiUrlStatusEl = document.getElementById('api-url-status') as HTMLDivElement; + +// Resolved API base URL for this popup session (no trailing slash). Seeded +// from storage when the popup opens; the Save button rewrites both this and +// storage. Every fetch below reads it rather than a build-time constant. +let apiUrl = DEFAULT_API_URL; -let cachedTeams: TeamSummary[] | null = null; let cachedTree: WikiTreeNode[] | null = null; // Tree cache is keyed by the team token under which it was fetched. A team // switch must drop the tree (different wiki) — we compare against this on @@ -86,6 +106,114 @@ async function setStoredTeamName(name: string): Promise { }); } +async function getStoredTeamName(): Promise { + return new Promise((resolve) => { + (chrome as any).storage.local.get(TEAM_NAME_KEY, (v: Record) => { + const stored = v[TEAM_NAME_KEY]; + resolve(typeof stored === 'string' && stored ? stored : null); + }); + }); +} + +// ----- API server ------------------------------------------------------------ + +async function getStoredApiUrl(): Promise { + return new Promise((resolve) => { + (chrome as any).storage.local.get(API_URL_KEY, (v: Record) => { + resolve(normalizeApiUrl(v?.[API_URL_KEY])); + }); + }); +} + +async function setStoredApiUrl(url: string): Promise { + return new Promise((resolve) => { + (chrome as any).storage.local.set({ [API_URL_KEY]: url }, () => resolve()); + }); +} + +function setApiStatus(text: string, kind: 'neutral' | 'ok' | 'error' = 'neutral'): void { + apiUrlStatusEl.textContent = text; + apiUrlStatusEl.classList.toggle('is-error', kind === 'error'); + apiUrlStatusEl.classList.toggle('is-ok', kind === 'ok'); +} + +// The manifest ships host_permissions for localhost / 127.0.0.1 only. Pointing +// the extension at a remote self-hosted API needs that origin granted, which +// MV3 exposes through optional_host_permissions. Requested from the Save click +// because chrome.permissions.request requires a user gesture. Returns true when +// we either hold the permission or can't tell — we let the fetch be the judge +// rather than blocking the user on a guess. +async function ensureHostPermission(url: string): Promise { + try { + const perms = (chrome as any)?.permissions; + if (!perms?.request || !perms?.contains) return true; + const origin = `${new URL(url).origin}/*`; + const has = await new Promise((resolve) => { + perms.contains({ origins: [origin] }, (r: boolean) => resolve(Boolean(r))); + }); + if (has) return true; + return await new Promise((resolve) => { + perms.request({ origins: [origin] }, (granted: boolean) => resolve(Boolean(granted))); + }); + } catch { + return true; + } +} + +// Cheap reachability probe against a no-auth endpoint. Turns "nothing works and +// I don't know why" into a one-line diagnosis naming the fix. +async function probeApi(): Promise { + setApiStatus(`Checking ${apiUrl}…`); + const ac = new AbortController(); + const timer = window.setTimeout(() => ac.abort(), 4000); + try { + // GET / is the API's unauthenticated status endpoint. (This used to probe + // /teams, which only worked because /teams required no auth — the very + // thing that leaked every tenant's token.) + const res = await fetch(`${apiUrl}/`, { signal: ac.signal }); + if (!res.ok) { + setApiStatus(`${apiUrl} responded HTTP ${res.status}.`, 'error'); + return; + } + setApiStatus(`Connected to ${apiUrl}`, 'ok'); + } catch { + setApiStatus( + apiUrl === DEFAULT_API_URL + ? `No API at ${apiUrl}. Trailhead is self-hosted — run \`docker compose up\` (see SELFHOSTING.md), or enter your server's URL above.` + : `Can't reach ${apiUrl}. Check the server is running, or correct the URL above.`, + 'error', + ); + } finally { + window.clearTimeout(timer); + } +} + +async function saveApiUrl(): Promise { + const next = normalizeApiUrl(apiUrlInputEl.value); + if (!next) { + setApiStatus('Enter a full base URL, e.g. http://localhost:3000', 'error'); + return; + } + apiUrlSaveEl.disabled = true; + try { + const granted = await ensureHostPermission(next); + if (!granted) { + setApiStatus(`Permission for ${next} denied — the extension can't call it.`, 'error'); + return; + } + await setStoredApiUrl(next); + apiUrl = next; + apiUrlInputEl.value = next; + // A different server means a different team and a different wiki. + cachedTree = null; + cachedTreeForToken = null; + showToast('API server saved'); + await probeApi(); + } finally { + apiUrlSaveEl.disabled = false; + } +} + async function getStoredContextPath(): Promise { return new Promise((resolve) => { (chrome as any).storage.local.get(CONTEXT_PATH_KEY, (v: Record) => { @@ -107,15 +235,37 @@ async function clearStoredContextPath(): Promise { }); } +/** + * Ask the API which team the stored token belongs to. + * + * GET /teams is authenticated and returns only the caller's own team. It used + * to be unauthenticated and return every team on the server *with its token*, + * which is what let this popup show a pick-a-team list — and also handed every + * tenant's credential to anyone who asked. Resolving your own team from the + * token you already hold is the same convenience without the giveaway. + */ +async function resolveTeamName(token: string): Promise { + try { + const res = await fetch(`${apiUrl}/teams`, { headers: { 'X-Team-Token': token } }); + if (!res.ok) return null; + const data = (await res.json()) as TeamsListResponse; + return data.teams?.[0]?.name ?? null; + } catch { + return null; + } +} + async function refreshCurrentTeamName(): Promise { const token = await getStoredToken(); - const team = cachedTeams?.find((t) => t.token === token); - // Backfill the cached display name whenever the popup discovers it via - // /teams — handles users who picked a team before this feature existed. - if (team) await setStoredTeamName(team.name); - currentTeamNameEl.textContent = team - ? team.name - : token === DEFAULT_TEAM_TOKEN ? 'Acme (default)' : token.slice(0, 16) + '…'; + const cached = await getStoredTeamName(); + currentTeamNameEl.textContent = + cached ?? (token === DEFAULT_TEAM_TOKEN ? 'Acme (default)' : token.slice(0, 16) + '…'); + + const name = await resolveTeamName(token); + if (name) { + await setStoredTeamName(name); + currentTeamNameEl.textContent = name; + } } async function refreshCurrentContextName(): Promise { @@ -123,52 +273,94 @@ async function refreshCurrentContextName(): Promise { currentContextNameEl.textContent = displayPath(path); } -function renderTeamList(teams: TeamSummary[], currentToken: string): void { - teamListEl.replaceChildren(); - for (const team of teams) { - const li = document.createElement('li'); - if (team.token === currentToken) { - li.classList.add('is-current'); - const check = document.createElement('span'); - check.className = 'check'; - check.textContent = '✓'; - li.appendChild(check); +/** + * Team switcher. + * + * This was a list of every team on the server, each row carrying that team's + * token, populated from an unauthenticated GET /teams. Clicking a row adopted + * someone else's credential. The endpoint no longer discloses tokens, so + * switching teams means entering the token for the team you are entitled to — + * which is what "switching teams" should always have meant. + */ +async function applyTeamToken(next: string): Promise { + const token = next.trim(); + if (!token) { + showTeamError('Enter a team token.'); + return; + } + const current = await getStoredToken(); + teamStatusEl.hidden = false; + teamStatusEl.classList.remove('is-error'); + teamStatusEl.textContent = 'Checking token…'; + + const name = await resolveTeamName(token); + if (!name) { + showTeamError(`${apiUrl} rejected that token, or is unreachable.`); + return; + } + + await setStoredToken(token); + // Persist the display name alongside the token so the in-page pill can show + // "Acme Fintech · Root" instead of just "Root". + await setStoredTeamName(name); + // A different team invalidates the wiki tree cache and any active context + // (the path may not exist for the new team). + if (token !== current) { + cachedTree = null; + cachedTreeForToken = null; + const oldPath = await getStoredContextPath(); + if (oldPath) { + await clearStoredContextPath(); + await refreshCurrentContextName(); } - const name = document.createElement('span'); - name.textContent = team.name; - li.appendChild(name); - li.addEventListener('click', async () => { - await setStoredToken(team.token); - // Persist the team's display name alongside the token so the - // in-page pill can show "Acme Fintech · Root" instead of just "Root". - await setStoredTeamName(team.name); - // Picking a different team invalidates the wiki tree cache and - // any active context (the path may not exist for the new team). - if (cachedTreeForToken !== team.token) { - cachedTree = null; - cachedTreeForToken = null; - } - const oldPath = await getStoredContextPath(); - if (oldPath) { - await clearStoredContextPath(); - await refreshCurrentContextName(); - } - cachedTeams && renderTeamList(cachedTeams, team.token); - await refreshCurrentTeamName(); - closeTeamDropdown(); - showToast(`Switched to ${team.name}`); - }); - teamListEl.appendChild(li); } - teamListEl.hidden = false; - teamStatusEl.hidden = true; + await refreshCurrentTeamName(); + closeTeamDropdown(); + showToast(`Switched to ${name}`); } -function showTeamLoading(): void { - teamStatusEl.textContent = 'Loading teams…'; - teamStatusEl.classList.remove('is-error'); - teamStatusEl.hidden = false; - teamListEl.hidden = true; +async function renderTeamEditor(): Promise { + teamListEl.replaceChildren(); + + const li = document.createElement('li'); + li.className = 'team-editor'; + + const label = document.createElement('label'); + label.textContent = 'Team token'; + label.htmlFor = 'team-token-input'; + li.appendChild(label); + + const input = document.createElement('input'); + input.id = 'team-token-input'; + input.type = 'text'; + input.spellcheck = false; + input.autocomplete = 'off'; + input.placeholder = 'e.g. repo_9d01… or trailhead_demo_acme_2026'; + input.value = await getStoredToken(); + li.appendChild(input); + + const save = document.createElement('button'); + save.type = 'button'; + save.textContent = 'Use this team'; + save.addEventListener('click', () => void applyTeamToken(input.value)); + li.appendChild(save); + + input.addEventListener('keydown', (e) => { + if ((e as KeyboardEvent).key === 'Enter') { + e.preventDefault(); + void applyTeamToken(input.value); + } + }); + + const hint = document.createElement('p'); + hint.className = 'team-hint'; + hint.textContent = + 'Your token is your team’s credential. `trailhead-mcp init` derives one per repo and writes it into your MCP config.'; + li.appendChild(hint); + + teamListEl.appendChild(li); + teamListEl.hidden = false; + teamStatusEl.hidden = true; } function showTeamError(msg: string): void { @@ -186,25 +378,7 @@ function closeTeamDropdown(): void { async function openTeamDropdown(): Promise { teamDropdownEl.hidden = false; selectTeamBtn.setAttribute('aria-expanded', 'true'); - if (cachedTeams) { - renderTeamList(cachedTeams, await getStoredToken()); - return; - } - showTeamLoading(); - try { - const res = await fetch(`${API_URL}/teams`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = (await res.json()) as TeamsListResponse; - if (!Array.isArray(data.teams) || data.teams.length === 0) { - showTeamError('No teams returned by the API.'); - return; - } - cachedTeams = data.teams; - renderTeamList(cachedTeams, await getStoredToken()); - } catch (err) { - console.warn('[trailhead-popup] /teams fetch failed', err); - showTeamError('Couldn’t load teams. Check the API.'); - } + await renderTeamEditor(); } // ----- Wiki context picker --------------------------------------------------- @@ -358,7 +532,7 @@ async function openContextDropdown(): Promise { } showContextLoading(); try { - const res = await fetch(`${API_URL}/wiki/tree`, { + const res = await fetch(`${apiUrl}/wiki/tree`, { headers: { 'X-Team-Token': token }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -372,7 +546,8 @@ async function openContextDropdown(): Promise { renderContextTree(cachedTree, await getStoredContextPath()); } catch (err) { console.warn('[trailhead-popup] /wiki/tree fetch failed', err); - showContextError('Couldn’t load wiki. Check the API.'); + showContextError(`Couldn’t reach ${apiUrl}. Check the API server below.`); + void probeApi(); } } @@ -391,12 +566,36 @@ function showToast(text: string, ms = 1600): void { } catch { render(true); } + // Resolve the API server first — the team/context fetches below depend on + // it, and the user must be able to SEE which server they're pointed at. + try { + const stored = await getStoredApiUrl(); + apiUrl = stored || DEFAULT_API_URL; + apiUrlInputEl.value = apiUrl; + if (!stored) { + setApiStatus(`Using the default ${DEFAULT_API_URL} — no server configured yet.`); + } + } catch { + apiUrlInputEl.value = apiUrl; + } + void probeApi(); // Show the current team + context in the button rows even before the // user opens either dropdown. await refreshCurrentTeamName(); await refreshCurrentContextName(); })(); +apiUrlSaveEl.addEventListener('click', () => { + void saveApiUrl(); +}); + +apiUrlInputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void saveApiUrl(); + } +}); + switchEl.addEventListener('click', async () => { try { const out = await new Promise>((resolve) => { diff --git a/apps/browser-ext/src/selectors-classify.test.mts b/apps/browser-ext/src/selectors-classify.test.mts new file mode 100644 index 0000000..7030437 --- /dev/null +++ b/apps/browser-ext/src/selectors-classify.test.mts @@ -0,0 +1,99 @@ +// Regression tests for the bubble classifier. +// +// The bug: classifyBubble used to return a role when the selector matched the +// node ITSELF *or* anything in its subtree (`node.querySelector(sel)`). Since +// the content script walked the DOM outermost-first and deliberately let the +// outermost match win, the first wrapper div that happened to contain a user +// message classified as a user bubble — and swallowed the whole conversation. +// On claude.ai that wrapper is somewhere around the thread container, so in +// practice the entire thread got tagged as one bubble and every per-message +// widget (score badge, outcome rating, prompt diff) mounted once, on the wrong +// element. +// +// classifyBubble only calls `node.matches(...)`, so these tests use a tiny +// stub element rather than pulling in a DOM implementation. +// +// Run: npm --workspace=apps/browser-ext test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BUBBLE_HINT_SELECTOR, classifyBubble } from './selectors.ts'; + +/** + * Minimal Element stand-in. `matches` returns true for any selector in + * `selfMatches`; `querySelector` returns a truthy value for anything in + * `descendantMatches` — which is exactly the trap the old implementation fell + * into and the new one must ignore. + */ +function stubElement(selfMatches: string[], descendantMatches: string[] = []) { + return { + matches: (sel: string) => selfMatches.includes(sel), + querySelector: (sel: string) => (descendantMatches.includes(sel) ? {} : null), + } as unknown as Element; +} + +test('classifies a user bubble by its own attribute', () => { + assert.equal(classifyBubble(stubElement(['[data-testid="user-message"]'])), 'user'); +}); + +test('classifies an assistant bubble by its own attribute', () => { + assert.equal( + classifyBubble(stubElement(['[data-testid="assistant-message"]'])), + 'assistant', + ); +}); + +// --------------------------------------------------------------------------- +// The regression. A container that merely CONTAINS a user message is not a +// user bubble. Under the old implementation this returned 'user'. +// --------------------------------------------------------------------------- +test('a wrapper containing a user bubble is NOT itself classified as one', () => { + const wrapper = stubElement([], ['[data-testid="user-message"]']); + assert.equal( + classifyBubble(wrapper), + 'unknown', + 'a container that merely contains a user message must not be tagged as a bubble', + ); +}); + +test('a wrapper containing an assistant bubble is NOT itself classified as one', () => { + const wrapper = stubElement([], ['[data-testid="assistant-message"]']); + assert.equal(classifyBubble(wrapper), 'unknown'); +}); + +test('the thread container — which contains both roles — stays unknown', () => { + // This is the element that used to swallow the entire conversation. + const thread = stubElement([], [ + '[data-testid="user-message"]', + '[data-testid="assistant-message"]', + ]); + assert.equal(classifyBubble(thread), 'unknown'); +}); + +test('an unrelated element is unknown', () => { + assert.equal(classifyBubble(stubElement(['div.sidebar'])), 'unknown'); +}); + +test('user wins over assistant when an element somehow matches both', () => { + // Deterministic precedence matters: the outcome-rating widget looks + // backwards for the previous *user* bubble, so a flapping classification + // would attach ratings to the wrong message. + const both = stubElement(['[data-testid*="user"]', '[data-testid*="assistant"]']); + assert.equal(classifyBubble(both), 'user'); +}); + +test('classifyBubble tolerates an element without matches()', () => { + // Text nodes and some SVG elements reach this path in the wild. + const bare = {} as unknown as Element; + assert.equal(classifyBubble(bare), 'unknown'); +}); + +test('BUBBLE_HINT_SELECTOR is a valid non-empty selector list covering both roles', () => { + assert.ok(BUBBLE_HINT_SELECTOR.length > 0); + assert.ok(BUBBLE_HINT_SELECTOR.includes('[data-testid="user-message"]')); + assert.ok(BUBBLE_HINT_SELECTOR.includes('[data-testid="assistant-message"]')); + // Comma-joined so it can be handed straight to querySelectorAll. + const parts = BUBBLE_HINT_SELECTOR.split(','); + assert.ok(parts.length >= 12, `expected all hints, got ${parts.length}`); + for (const p of parts) assert.ok(p.trim().length > 0, 'no empty selector fragments'); +}); diff --git a/apps/browser-ext/src/selectors.ts b/apps/browser-ext/src/selectors.ts index 72dee6f..8b07288 100644 --- a/apps/browser-ext/src/selectors.ts +++ b/apps/browser-ext/src/selectors.ts @@ -130,12 +130,32 @@ const ASSISTANT_HINTS = [ '[class*="assistant-turn"]', ]; +/** + * Every selector that can identify a bubble, as one comma-joined list. + * + * The content script queries with this directly instead of walking every + * `div, article, li` on the page and asking each one whether it looks like a + * bubble. Same results, a fraction of the work, and no ambiguity about which + * element in a nesting chain is "the" bubble. + */ +export const BUBBLE_HINT_SELECTOR = [...USER_HINTS, ...ASSISTANT_HINTS].join(','); + +/** + * Classify an element that is already known to be a bubble candidate. + * + * Matches on the element ITSELF only. This used to also accept + * `node.querySelector(sel)` — a match anywhere in the subtree — which meant + * every ancestor of a user message classified as a user bubble, all the way up + * to the conversation container and `body`. Since the caller walked the DOM + * outermost-first and let the outermost match win, a single wrapper div + * swallowed the entire thread and got tagged as one giant "user bubble". + */ export function classifyBubble(node: Element): BubbleRole { for (const sel of USER_HINTS) { - if (node.matches?.(sel) || node.querySelector?.(sel)) return 'user'; + if (node.matches?.(sel)) return 'user'; } for (const sel of ASSISTANT_HINTS) { - if (node.matches?.(sel) || node.querySelector?.(sel)) return 'assistant'; + if (node.matches?.(sel)) return 'assistant'; } return 'unknown'; } diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 7f653d0..3a8505e 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -21,22 +21,28 @@ companion `2026-04-25-demo-completion-design.md` (A — full dashboard). From the repo root: +Trailhead is self-hosted: there is no hosted API. Bring one up first — +`docker compose up` from the repo root (see [`SELFHOSTING.md`](../../SELFHOSTING.md)) +— then start the dashboard: + ```bash -NEXT_PUBLIC_API_URL=https://trailheadapi-production.up.railway.app \ -NEXT_PUBLIC_TEAM_TOKEN=trailhead_demo_acme_2026 \ - npm --workspace=apps/dashboard run dev +npm --workspace=apps/dashboard run dev ``` Server runs on **http://localhost:3001** (port 3000 is the API). -To point at a local API instead: +`NEXT_PUBLIC_API_URL` defaults to `http://localhost:3000`, which is what +`docker compose up` publishes. To point at a different server: ```bash -NEXT_PUBLIC_API_URL=http://localhost:3000 \ +NEXT_PUBLIC_API_URL=https://trailhead.internal.example.com \ NEXT_PUBLIC_TEAM_TOKEN=trailhead_demo_acme_2026 \ npm --workspace=apps/dashboard run dev ``` +If the API is unreachable, the Teams page says so and names +`NEXT_PUBLIC_API_URL` explicitly rather than showing an empty list. + ## Build ```bash @@ -44,8 +50,15 @@ npm --workspace=apps/dashboard run build # ~5s, static export npm --workspace=apps/dashboard run typecheck # tsc --noEmit ``` -All four routes prerender as static (`○` in the build output) — they hydrate -on the client and SWR drives the live data. +There are five app routes: `/`, `/onboarding`, `/skill-arc`, `/team` and +`/wiki`. All five are server-rendered on demand (`ƒ` in the build output) — +`/` because it declares `export const dynamic = 'force-dynamic'`, the other +four because they read `searchParams` (`?team=`), which opts a route out of +prerendering in Next 15. The only statically prerendered route is the +framework's own `/_not-found`, which is why the build reports 7 pages. + +SWR still drives the live data on the client; "dynamic" here means the initial +HTML is rendered per request, not that the data is fetched at build time. ## Deploy to Vercel @@ -53,8 +66,12 @@ One-time setup: 1. `npm i -g vercel` (or `npx vercel` per command) 2. From `apps/dashboard/`: `vercel link` — picks a project, writes `.vercel/` -3. Set env vars in the Vercel dashboard (or `vercel env add`): - - `NEXT_PUBLIC_API_URL=https://trailheadapi-production.up.railway.app` +3. Set env vars in the Vercel dashboard (or `vercel env add`). A deployed + dashboard **must** set `NEXT_PUBLIC_API_URL` — the `http://localhost:3000` + default only makes sense on a developer's machine, and a Vercel deployment + left unset will fail every request from the visitor's browser: + - `NEXT_PUBLIC_API_URL=https://` (must be + publicly reachable from the browser, and serve CORS for the dashboard origin) - `NEXT_PUBLIC_TEAM_TOKEN=trailhead_demo_acme_2026` Deploy: diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 5265072..2d0cf0a 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,35 +1,36 @@ -{ - "name": "@trailhead/dashboard", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "next dev -p 3001", - "build": "next build", - "start": "next start -p 3001", - "typecheck": "tsc --noEmit", - "lint": "next lint" - }, - "dependencies": { - "@trailhead/shared": "*", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^0.469.0", - "next": "^15.1.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "recharts": "^2.15.0", - "swr": "^2.3.0", - "tailwind-merge": "^2.6.0" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.17", - "tailwindcss-animate": "^1.0.7", - "typescript": "^5.7.2" - } -} +{ + "name": "@trailhead/dashboard", + "version": "0.0.0", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -p 3001", + "typecheck": "tsc --noEmit", + "lint": "next lint" + }, + "dependencies": { + "@trailhead/shared": "*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.0", + "swr": "^2.3.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.2" + } +} diff --git a/apps/dashboard/src/app/page.tsx b/apps/dashboard/src/app/page.tsx index 3711171..1a1eaf5 100644 --- a/apps/dashboard/src/app/page.tsx +++ b/apps/dashboard/src/app/page.tsx @@ -1,11 +1,22 @@ -// Team picker — server component. Fetches /teams at request time so -// every team auto-created via /onboard/repo or wiki_bootstrap shows up -// without a code change. Each card links into the team-aware detail -// pages via `?team=`. +// Team view — server component. +// +// This was a picker over every team on the server, built from an +// unauthenticated GET /teams that returned each team's token. That endpoint +// published every tenant's only credential, so it now authenticates and +// returns just the caller's team, without the token. +// +// The dashboard therefore shows the team its own NEXT_PUBLIC_TEAM_TOKEN +// resolves to. To view a different team, configure that team's token — which +// is the point: viewing a team's wiki should require holding its credential. import Link from 'next/link'; import type { TeamsListResponse, TeamSummary } from '@trailhead/shared'; -import { RESOLVED_API_URL } from '@/lib/api'; +import { + apiConfigHint, + DEFAULT_TEAM_TOKEN, + IS_API_URL_CONFIGURED, + RESOLVED_API_URL, +} from '@/lib/api'; export const dynamic = 'force-dynamic'; @@ -13,12 +24,14 @@ const DEMO_TOKEN = 'trailhead_demo_acme_2026'; const DEMO_DESCRIPTION = "Backend services in Postgres + Hono, webhooks via signed callbacks, PCI-scoped audit logging. Coaching seeded from the team's actual repo conventions."; -function describe(team: TeamSummary): string { - if (team.token === DEMO_TOKEN) return DEMO_DESCRIPTION; - if (team.token.startsWith('repo_local_')) { +// Described from the token this dashboard is configured with, since the API +// no longer discloses tokens. +function describe(token: string): string { + if (token === DEMO_TOKEN) return DEMO_DESCRIPTION; + if (token.startsWith('repo_local_')) { return 'Local-only repo (no git remote). Token persisted in .trailhead-team, gitignored. Wiki and skill arc are isolated to this machine.'; } - if (team.token.startsWith('repo_')) { + if (token.startsWith('repo_')) { return 'Repo-derived team (token = SHA-256 of git remote). Teammates cloning the same repo land in the same team automatically.'; } return 'Custom team token. Wiki, skill arc, and metrics are scoped to this token only.'; @@ -30,7 +43,11 @@ type LoadResult = async function loadTeams(): Promise { try { - const res = await fetch(`${RESOLVED_API_URL}/teams`, { cache: 'no-store' }); + // /teams is authenticated now — it resolves the caller's own team. + const res = await fetch(`${RESOLVED_API_URL}/teams`, { + cache: 'no-store', + headers: { 'X-Team-Token': DEFAULT_TEAM_TOKEN }, + }); if (!res.ok) { const body = await res.text().catch(() => ''); return { ok: false, error: `HTTP ${res.status} ${body.slice(0, 160)}` }; @@ -38,7 +55,10 @@ async function loadTeams(): Promise { const json = (await res.json()) as TeamsListResponse; return { ok: true, teams: json.teams }; } catch (err) { - return { ok: false, error: (err as Error).message ?? String(err) }; + // Network-layer failure: the self-hosted API isn't running, or + // NEXT_PUBLIC_API_URL points somewhere wrong. Say which, and name the + // variable — an opaque "fetch failed" here is what sends people hunting. + return { ok: false, error: `${apiConfigHint()} (${(err as Error).message ?? String(err)})` }; } } @@ -60,6 +80,18 @@ export default async function HomePage() {
No teams returned by the API. Check that {RESOLVED_API_URL}/teams is reachable. + {!IS_API_URL_CONFIGURED && ( +
+
NEXT_PUBLIC_API_URL is not set.
+

+ Trailhead is self-hosted — there is no default server. Start one + with docker compose up from the + repo root (see SELFHOSTING.md), + or set NEXT_PUBLIC_API_URL to + your server's base URL and rebuild. +

+
+ )} {!result.ok && (
{result.error} @@ -69,10 +101,12 @@ export default async function HomePage() { ) : (
{teams.map((team) => { - const qs = `?team=${encodeURIComponent(team.token)}`; + // The token comes from this dashboard's own configuration, not + // from the API response — the API no longer discloses it. + const qs = `?team=${encodeURIComponent(DEFAULT_TEAM_TOKEN)}`; return (
@@ -80,10 +114,12 @@ export default async function HomePage() {
{team.name}

- {describe(team)} + {describe(DEFAULT_TEAM_TOKEN)}

+ {/* The team token is a credential; it is not printed here. + `id` is an opaque digest, safe to show. */}
- token: {team.token} + id: {team.id}
diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index a09d6da..3f13a55 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -1,7 +1,8 @@ // Thin SWR-friendly client for the Trailhead API. Every fetcher takes a -// `token` so the dashboard can render any team's data — the team picker -// fetches /teams (no auth) and routes each team card to ?team=; -// downstream pages read that param and pass it into these calls. +// `token` and sends it as X-Team-Token — the whole API (including GET /teams) +// authenticates on that header. The home page resolves the caller's own team +// via GET /teams and routes each link to ?team=; downstream pages read +// that param and pass it into these calls. // // Tokens aren't secrets in this design — they're derived from public git // remotes. See master spec §3 for the trust model. @@ -12,14 +13,47 @@ import type { ScoreResponse, SkillArcResponse, TeamMetricsResponse, - TeamsListResponse, WikiRecentResponse, WikiTreeResponse, } from '@trailhead/shared'; -const RAW_API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'https://trailheadapi-production.up.railway.app'; +// Trailhead ships no hosted API — the backend is self-hosted, so +// NEXT_PUBLIC_API_URL is required config. The default matches the port +// apps/api listens on (PORT ?? 3000) and the port the root +// docker-compose.yml publishes, so a local `docker compose up` just works. +// +// Deliberately NOT a module-scope throw: `next build` evaluates this file +// while prerendering, and a hard failure there would break the build for +// anyone without env set. We fall back, record that we fell back, and fail +// loudly at request time instead (see assertConfigured / fetcher below). +export const DEFAULT_API_URL = 'http://localhost:3000'; + +const RAW_API_URL = process.env.NEXT_PUBLIC_API_URL ?? DEFAULT_API_URL; const API_URL = RAW_API_URL.replace(/\/$/, ''); +/** False when NEXT_PUBLIC_API_URL was never set and we're on the localhost + * default. Pages use it to explain a failure instead of showing a bare + * "fetch failed". */ +export const IS_API_URL_CONFIGURED = Boolean(process.env.NEXT_PUBLIC_API_URL); + +/** Actionable, self-contained message naming the exact variable to set. */ +export function apiConfigHint(): string { + return IS_API_URL_CONFIGURED + ? `Could not reach the Trailhead API at ${API_URL}. Check that the server is running and that NEXT_PUBLIC_API_URL is correct.` + : `Could not reach the Trailhead API at ${API_URL}. NEXT_PUBLIC_API_URL is not set, so the dashboard fell back to the local default. Trailhead is self-hosted — start the API with \`docker compose up\` from the repo root (see SELFHOSTING.md), or set NEXT_PUBLIC_API_URL to your server's base URL.`; +} + +// Wraps a fetch so a network-layer failure (server down, wrong host) becomes +// the actionable message above rather than an opaque TypeError. Non-2xx +// responses are the caller's business and pass straight through. +async function guardedFetch(input: string, init?: RequestInit): Promise { + try { + return await fetch(input, init); + } catch (err) { + throw new Error(apiConfigHint(), { cause: err }); + } +} + // Default fallback when no `?team=` param is present in the URL. Keeps the // existing demo-team links (`/wiki`, `/skill-arc`) working without changes. export const DEFAULT_TEAM_TOKEN = @@ -34,7 +68,7 @@ function headers(token: string): HeadersInit { // SWR-friendly fetcher. Throws on non-2xx so SWR's `error` channel fires. async function fetcher(path: string, token: string): Promise { - const res = await fetch(`${API_URL}${path}`, { headers: headers(token) }); + const res = await guardedFetch(`${API_URL}${path}`, { headers: headers(token) }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`trailhead-api ${path} ${res.status}: ${text.slice(0, 200)}`); @@ -42,21 +76,15 @@ async function fetcher(path: string, token: string): Promise { return (await res.json()) as T; } -// Public team enumeration — no auth header required server-side. -async function fetchListTeams(): Promise { - const res = await fetch(`${API_URL}/teams`); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`trailhead-api /teams ${res.status}: ${text.slice(0, 200)}`); - } - return (await res.json()) as TeamsListResponse; -} - // Typed convenience wrappers. Each page imports the one it needs and // passes it as the SWR fetcher; this keeps useSWR generics inferred // without each page restating the path string. +// +// There is no listTeams() here: GET /teams is authenticated and returns only +// the caller's own team, so the home page fetches it directly with its +// configured token (see app/page.tsx) rather than through an unauthenticated +// enumeration helper. export const api = { - listTeams: (): Promise => fetchListTeams(), skillArc: (token: string, since?: string, userId?: string): Promise => { const params = new URLSearchParams(); if (since) params.set('since', since); @@ -81,7 +109,7 @@ export async function scorePrompt( token: string, args: { prompt: string; user_id: string; file_path?: string }, ): Promise { - const res = await fetch(`${API_URL}/score`, { + const res = await guardedFetch(`${API_URL}/score`, { method: 'POST', headers: headers(token), body: JSON.stringify(args), diff --git a/apps/landing-page/index.html b/apps/landing-page/index.html index 3b0276d..d1c2292 100644 --- a/apps/landing-page/index.html +++ b/apps/landing-page/index.html @@ -128,8 +128,21 @@ .accent-italic { font-style: italic; } - - + + + diff --git a/apps/mcp-server/bin/cli.mjs b/apps/mcp-server/bin/cli.mjs index 047e8f7..b8fa0b6 100644 --- a/apps/mcp-server/bin/cli.mjs +++ b/apps/mcp-server/bin/cli.mjs @@ -26,6 +26,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runInit } from './init.mjs'; import { deriveRepoToken } from '../src/token.mjs'; +import { resolveApiUrl } from '../src/api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const cmd = process.argv[2] ?? 'help'; @@ -50,10 +51,7 @@ function resolveToken({ cwd }) { if (cmd === 'init') { const cwd = process.cwd(); const { token, source, remoteUrl } = resolveToken({ cwd }); - const apiUrl = - flagValue('--api-url') ?? - process.env.TRAILHEAD_API_URL ?? - 'https://trailheadapi-production.up.railway.app'; + const apiUrl = resolveApiUrl(flagValue('--api-url')); const sourceLabel = { flag: '--team-token', @@ -64,6 +62,7 @@ if (cmd === 'init') { }[source]; console.log(`Token: ${token}`); console.log(`Source: ${sourceLabel}`); + console.log(`API: ${apiUrl}`); console.log(''); await runInit({ diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index dcf5472..adf5b44 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -1,32 +1,34 @@ -{ - "name": "@trailhead/mcp-server", - "version": "0.1.0", - "private": true, - "type": "module", - "main": "src/index.ts", - "bin": { - "trailhead-mcp": "bin/cli.mjs" - }, - "scripts": { - "dev": "tsx src/index.ts", - "start": "tsx src/index.ts", - "typecheck": "tsc --noEmit", - "test": "node --test bin/init.test.mjs bin/cli-smoke.test.mjs", - "smoke": "tsx src/smoke-test.mjs", - "verify": "tsx src/verify-all-tools.mjs", - "try": "tsx src/harness/try.ts", - "try:matrix": "tsx src/harness/matrix.ts" - }, - "dependencies": { - "@google/genai": "^1.50.1", - "@modelcontextprotocol/sdk": "^1.0.0", - "@trailhead/shared": "*", - "@trailhead/scoring": "*", - "zod": "^3.23.8" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "tsx": "^4.19.2", - "typescript": "^5.7.2" - } -} +{ + "name": "@trailhead/mcp-server", + "version": "0.1.0", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/Bogzx/LearnLoop.git", "directory": "apps/mcp-server" }, + "private": true, + "type": "module", + "main": "src/index.ts", + "bin": { + "trailhead-mcp": "bin/cli.mjs" + }, + "scripts": { + "dev": "tsx src/index.ts", + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit", + "test": "node --test bin/init.test.mjs bin/cli-smoke.test.mjs", + "smoke": "tsx src/smoke-test.mjs", + "verify": "tsx src/verify-all-tools.mjs", + "try": "tsx src/harness/try.ts", + "try:matrix": "tsx src/harness/matrix.ts" + }, + "dependencies": { + "@google/genai": "^1.50.1", + "@modelcontextprotocol/sdk": "^1.0.0", + "@trailhead/shared": "*", + "@trailhead/scoring": "*", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/mcp-server/src/api-client.ts b/apps/mcp-server/src/api-client.ts index 590fd7d..4a2e23c 100644 --- a/apps/mcp-server/src/api-client.ts +++ b/apps/mcp-server/src/api-client.ts @@ -1,9 +1,18 @@ // Thin client for the Trailhead Hono API. Used by every MCP tool handler. // Reads URL + token from env so the same module works in MCP context (env // from .mcp.json) and in standalone tests (env from .env). +// These shapes are the API's response contract, so they live in +// @trailhead/shared next to the endpoint types rather than being re-declared +// here. They used to be local copies that happened to be field-identical to +// the server's — with nothing linking the two, so a server-side change would +// have compiled cleanly on both sides and broken only at runtime. import type { CoachRequest, CoachResponse, + ContextNode, + ContextResponse, + ExamplesItem, + ExamplesResponse, OnboardRepoFullRequest, OnboardRepoFullResponse, OnboardRepoRequest, @@ -11,43 +20,27 @@ import type { ProvenPromptsResponse, ScoreRequest, ScoreResponse, + SearchItem, + SearchResponse, WikiJobStatusResponse, WikiProposeRequest, WikiProposeResponse, + WikiRecentItem, + WikiRecentResponse, } from '@trailhead/shared'; -export interface ContextNode { - path: string; - body_md: string; - durable_learnings: { body: string; reinforcement_count: number }[]; -} -export interface ContextResponse { nodes: ContextNode[]; } - -export interface ExamplesItem { - template: string; - topic: string | null; - reuse_count: number; - node_path: string; -} -export interface ExamplesResponse { items: ExamplesItem[]; } - -export interface WikiRecentItem { - id: string; - node_path: string; - body: string; - status: 'draft' | 'durable'; - reinforcement_count: number; - last_seen_at: string; - created_at: string; -} -export interface WikiRecentResponse { items: WikiRecentItem[]; } - -export interface SearchItem { - kind: 'learning' | 'rule' | 'prompt'; - body: string; - node_path: string; -} -export interface SearchResponse { items: SearchItem[]; } +// Re-exported so existing importers of these names from './api-client.ts' +// keep working. +export type { + ContextNode, + ContextResponse, + ExamplesItem, + ExamplesResponse, + SearchItem, + SearchResponse, + WikiRecentItem, + WikiRecentResponse, +}; export interface ApiClientConfig { apiUrl: string; @@ -154,7 +147,23 @@ export class ApiClient { export function clientFromEnv(): ApiClient { const apiUrl = process.env.TRAILHEAD_API_URL; const teamToken = process.env.TRAILHEAD_TEAM_TOKEN; - if (!apiUrl) throw new Error('TRAILHEAD_API_URL not set'); - if (!teamToken) throw new Error('TRAILHEAD_TEAM_TOKEN not set'); + // Trailhead ships no hosted API. The MCP server is launched by an agent + // host (Claude Code, Copilot) from a generated config, so an unset value + // here means that config is wrong — name the variable and the fix rather + // than failing with a bare "not set". + if (!apiUrl) { + throw new Error( + 'TRAILHEAD_API_URL is not set. Trailhead is self-hosted: start an API with ' + + '`docker compose up` from the repo root (see SELFHOSTING.md), then set ' + + 'TRAILHEAD_API_URL to its base URL (e.g. http://localhost:3000). ' + + '`npx trailhead-mcp init` writes this into your MCP config for you.', + ); + } + if (!teamToken) { + throw new Error( + 'TRAILHEAD_TEAM_TOKEN is not set. Run `npx trailhead-mcp init` in your repo to ' + + 'derive and wire one, or set it explicitly.', + ); + } return new ApiClient({ apiUrl, teamToken }); } diff --git a/apps/mcp-server/src/api-url.d.mts b/apps/mcp-server/src/api-url.d.mts new file mode 100644 index 0000000..cf9f00f --- /dev/null +++ b/apps/mcp-server/src/api-url.d.mts @@ -0,0 +1,11 @@ +export const DEFAULT_API_URL: string; + +export interface ResolveApiUrlOptions { + /** Suppress the "TRAILHEAD_API_URL not set" fallback warning. */ + quiet?: boolean; +} + +export function resolveApiUrl( + flagUrl?: string | undefined, + opts?: ResolveApiUrlOptions, +): string; diff --git a/apps/mcp-server/src/api-url.mjs b/apps/mcp-server/src/api-url.mjs new file mode 100644 index 0000000..496e7b0 --- /dev/null +++ b/apps/mcp-server/src/api-url.mjs @@ -0,0 +1,36 @@ +// Shared resolution of the Trailhead API base URL for every mcp-server CLI. +// +// Trailhead ships no hosted API — the backend is self-hosted, so +// TRAILHEAD_API_URL is required config. We fall back to the port apps/api +// listens on (PORT ?? 3000), which is also the port the root +// docker-compose.yml publishes, and warn loudly on every fallback so an +// unconfigured run is never silently mistaken for a configured one. +// +// The warning goes to stderr (console.warn), leaving CLI stdout — which the +// smoke tests assert against — untouched. + +export const DEFAULT_API_URL = 'http://localhost:3000'; + +/** Strip whitespace and any trailing slash so callers can append '/score'. */ +function normalize(url) { + return String(url).trim().replace(/\/+$/, ''); +} + +/** + * Resolve the API base URL from (in order) an explicit --api-url flag value, + * TRAILHEAD_API_URL, then the self-host default. Pass `{ quiet: true }` to + * suppress the fallback warning when the caller prints its own banner. + */ +export function resolveApiUrl(flagUrl, { quiet = false } = {}) { + const explicit = flagUrl || process.env.TRAILHEAD_API_URL; + if (explicit && String(explicit).trim()) return normalize(explicit); + if (!quiet) { + console.warn( + `! TRAILHEAD_API_URL is not set — falling back to ${DEFAULT_API_URL}\n` + + ' Trailhead is self-hosted; there is no hosted API to fall back to.\n' + + ' Start one with `docker compose up` from the repo root (see SELFHOSTING.md),\n' + + ' or set TRAILHEAD_API_URL (or pass --api-url ) to point at your server.', + ); + } + return DEFAULT_API_URL; +} diff --git a/apps/mcp-server/src/bootstrap-cli.ts b/apps/mcp-server/src/bootstrap-cli.ts index 0fec7c9..f7ed95e 100644 --- a/apps/mcp-server/src/bootstrap-cli.ts +++ b/apps/mcp-server/src/bootstrap-cli.ts @@ -37,6 +37,7 @@ import { } from './bootstrap.ts'; import type { WikiJobStatusResponse } from '@trailhead/shared'; import { deriveRepoToken } from './token.mjs'; +import { resolveApiUrl } from './api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -158,10 +159,7 @@ const explicitToken = flagValues.get('--team-token'); const tokenInfo = explicitToken ? { token: explicitToken, source: 'flag' as const, remoteUrl: undefined as string | undefined } : deriveRepoToken(cwd); -const apiUrl = - flagValues.get('--api-url') ?? - process.env.TRAILHEAD_API_URL ?? - 'https://trailheadapi-production.up.railway.app'; +const apiUrl = resolveApiUrl(flagValues.get('--api-url')); const seed = seedFromFiles ? readSeedRules(cwd) : {}; diff --git a/apps/mcp-server/src/reset-cli.ts b/apps/mcp-server/src/reset-cli.ts index 2d13d8b..f1539a6 100644 --- a/apps/mcp-server/src/reset-cli.ts +++ b/apps/mcp-server/src/reset-cli.ts @@ -12,6 +12,7 @@ import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; import { ApiClient } from './api-client.ts'; import { deriveRepoToken } from './token.mjs'; +import { resolveApiUrl } from './api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -61,10 +62,7 @@ const explicitToken = flagValues.get('--team-token'); const tokenInfo = explicitToken ? { token: explicitToken, source: 'flag' as const } : deriveRepoToken(cwd); -const apiUrl = - flagValues.get('--api-url') ?? - process.env.TRAILHEAD_API_URL ?? - 'https://trailheadapi-production.up.railway.app'; +const apiUrl = resolveApiUrl(flagValues.get('--api-url')); console.log(`API: ${apiUrl}`); console.log(`Token: ${tokenInfo.token}`); diff --git a/apps/mcp-server/src/smoke-test.mjs b/apps/mcp-server/src/smoke-test.mjs index c2a811b..92a182b 100644 --- a/apps/mcp-server/src/smoke-test.mjs +++ b/apps/mcp-server/src/smoke-test.mjs @@ -10,6 +10,7 @@ import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync } from 'node:fs'; import process from 'node:process'; +import { resolveApiUrl } from './api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SERVER = resolve(__dirname, 'index.ts'); @@ -25,7 +26,7 @@ for (const candidate of ['../../../.env', '../../.env', '.env']) { const env = { ...process.env, - TRAILHEAD_API_URL: process.env.TRAILHEAD_API_URL ?? 'https://trailheadapi-production.up.railway.app', + TRAILHEAD_API_URL: resolveApiUrl(), TRAILHEAD_TEAM_TOKEN: process.env.TRAILHEAD_TEAM_TOKEN ?? 'trailhead_demo_acme_2026', }; diff --git a/apps/mcp-server/src/tools.ts b/apps/mcp-server/src/tools.ts index 0fac501..3a8ccaf 100644 --- a/apps/mcp-server/src/tools.ts +++ b/apps/mcp-server/src/tools.ts @@ -303,6 +303,8 @@ export function registerCoach(server: McpServer, client: ApiClient): void { dimensions: dimensionScoresSchema(), missing: z.record(z.string(), z.string()), text: z.string(), + degraded: z.boolean().optional(), + error: z.string().optional(), next_round_inputs: z .object({ original_prompt: z.string(), @@ -334,7 +336,13 @@ export function registerCoach(server: McpServer, client: ApiClient): void { // tells the LLM to read structuredContent (`proceed`, `text`); this // text is mostly for debug. let logText: string; - if (res.text && res.text.trim()) { + if (res.degraded) { + // Never let a scoring outage render as "no coaching needed" — that + // is exactly how it stayed invisible before. + logText = res.text?.trim() + ? res.text + : `(coach unavailable: ${res.error ?? 'unknown error'} — prompt was NOT scored)`; + } else if (res.text && res.text.trim()) { logText = res.text; } else if (res.proceed && res.mode === 'score') { logText = `(coach overall: ${res.overall}/10 — no coaching needed)`; diff --git a/apps/mcp-server/src/verify-all-tools.mjs b/apps/mcp-server/src/verify-all-tools.mjs index 1c3f807..96601f2 100644 --- a/apps/mcp-server/src/verify-all-tools.mjs +++ b/apps/mcp-server/src/verify-all-tools.mjs @@ -1,9 +1,13 @@ -// Exercise every MCP hero tool against the live Railway API and report what +// Exercise every MCP hero tool against a running Trailhead API and report what // behaves correctly vs. what surfaces an upstream 404. Used as a verification // harness — not a unit test. +// +// Trailhead is self-hosted: bring the API up with `docker compose up` from the +// repo root (see SELFHOSTING.md), or point TRAILHEAD_API_URL at your server. import { spawn } from 'node:child_process'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveApiUrl } from './api-url.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SERVER = resolve(__dirname, 'index.ts'); @@ -11,7 +15,7 @@ const SERVER = resolve(__dirname, 'index.ts'); const child = spawn('npx', ['--yes', 'tsx', SERVER], { env: { ...process.env, - TRAILHEAD_API_URL: process.env.TRAILHEAD_API_URL ?? 'https://trailheadapi-production.up.railway.app', + TRAILHEAD_API_URL: resolveApiUrl(), TRAILHEAD_TEAM_TOKEN: process.env.TRAILHEAD_TEAM_TOKEN ?? 'trailhead_demo_acme_2026', }, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/apps/vscode-ext/README.md b/apps/vscode-ext/README.md index 5aea16d..4f8cf9e 100644 --- a/apps/vscode-ext/README.md +++ b/apps/vscode-ext/README.md @@ -1,19 +1,22 @@ # vscode-ext — VS Code extension The IDE-side coaching surface. Sidebar webview with score-card, team-anchored -examples, articulation scaffold, and the wiki-update toast that closes the -autonomous demo loop. +examples, and the wiki-update toast. **Tech:** TypeScript + VS Code API + WebView for sidebar. -**Surfaces (spec §7 A):** -- Sidebar webview — same score-card UI as browser-ext -- Pre-prompt panel — 2-3 team-anchored examples for current file path -- `Cmd+Shift+K` — articulation scaffold (3-field thinking helper) -- Post-prompt outcome rating widget (one keystroke) -- Polls `/wiki/propose` results → toast + wiki view refresh - (this is what makes the autonomous demo moment audience-visible - when Claude Code calls the MCP tool) +**Actually implemented:** +- Sidebar webview (`trailhead.coach`, in the Trailhead activity-bar container) + — same score-card UI as browser-ext +- Team-anchored examples for the current file path +- Outcome rating widget (`src/outcome-rating.ts`) +- Polls `/wiki/recent` → toast + wiki view refresh (`src/wiki-diff.ts`) +- One command: `trailhead.refresh` ("Trailhead: Refresh sidebar") + +**Specced but never built:** +- The `Cmd+Shift+K` articulation scaffold (the 3-field thinking helper). It was + deferred during the original build and never picked up. The manifest + contributes no keybindings at all, so the shortcut does nothing. **Talks to:** `apps/api` only (HTTP + polling). diff --git a/apps/vscode-ext/media/trailhead.svg b/apps/vscode-ext/media/trailhead.svg new file mode 100644 index 0000000..24d3f0c --- /dev/null +++ b/apps/vscode-ext/media/trailhead.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/apps/vscode-ext/package.json b/apps/vscode-ext/package.json index b5fabcd..e12ecb9 100644 --- a/apps/vscode-ext/package.json +++ b/apps/vscode-ext/package.json @@ -1,76 +1,78 @@ -{ - "name": "trailhead-vscode", - "displayName": "Trailhead", - "description": "Prompt-skill coach in your sidebar — score-card, team-anchored examples, autonomous wiki updates.", - "version": "0.0.1", - "private": true, - "publisher": "trailhead", - "engines": { - "vscode": "^1.85.0" - }, - "categories": ["Other"], - "activationEvents": ["onView:trailhead.coach"], - "main": "./dist/extension.js", - "contributes": { - "viewsContainers": { - "activitybar": [ - { - "id": "trailhead", - "title": "Trailhead", - "icon": "$(rocket)" - } - ] - }, - "views": { - "trailhead": [ - { - "type": "webview", - "id": "trailhead.coach", - "name": "Coach" - } - ] - }, - "commands": [ - { - "command": "trailhead.refresh", - "title": "Trailhead: Refresh sidebar" - } - ], - "configuration": { - "title": "Trailhead", - "properties": { - "trailhead.apiUrl": { - "type": "string", - "default": "https://trailheadapi-production.up.railway.app", - "description": "Trailhead API base URL." - }, - "trailhead.teamToken": { - "type": "string", - "default": "trailhead_demo_acme_2026", - "description": "Team token sent in the X-Team-Token header." - }, - "trailhead.userId": { - "type": "string", - "default": "demo", - "description": "User ID stamped on score / capture writes." - } - } - } - }, - "scripts": { - "build": "node esbuild.config.mjs", - "watch": "node esbuild.config.mjs --watch", - "typecheck": "tsc --noEmit", - "test": "npm run build && node --test --experimental-strip-types src/paths.test.mts src/wiki-diff.test.mts test/bundle-load.test.mjs", - "vscode:prepublish": "node esbuild.config.mjs --production" - }, - "dependencies": { - "@trailhead/shared": "*" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "@types/vscode": "^1.85.0", - "esbuild": "^0.24.0", - "typescript": "^5.7.2" - } -} +{ + "name": "trailhead-vscode", + "displayName": "Trailhead", + "description": "Prompt-skill coach in your sidebar — score-card, team-anchored examples, autonomous wiki updates.", + "version": "0.0.1", + "license": "MIT", + "repository": { "type": "git", "url": "https://github.com/Bogzx/LearnLoop.git", "directory": "apps/vscode-ext" }, + "private": true, + "publisher": "trailhead", + "engines": { + "vscode": "^1.85.0" + }, + "categories": ["Other"], + "activationEvents": ["onView:trailhead.coach"], + "main": "./dist/extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "trailhead", + "title": "Trailhead", + "icon": "media/trailhead.svg" + } + ] + }, + "views": { + "trailhead": [ + { + "type": "webview", + "id": "trailhead.coach", + "name": "Coach" + } + ] + }, + "commands": [ + { + "command": "trailhead.refresh", + "title": "Trailhead: Refresh sidebar" + } + ], + "configuration": { + "title": "Trailhead", + "properties": { + "trailhead.apiUrl": { + "type": "string", + "default": "http://localhost:3000", + "description": "Base URL of your self-hosted Trailhead API. Trailhead ships no hosted backend — bring one up with `docker compose up` from the repo root (see SELFHOSTING.md), then point this at it. Defaults to the port that compose publishes." + }, + "trailhead.teamToken": { + "type": "string", + "default": "trailhead_demo_acme_2026", + "description": "Team token sent in the X-Team-Token header." + }, + "trailhead.userId": { + "type": "string", + "default": "demo", + "description": "User ID stamped on score / capture writes." + } + } + } + }, + "scripts": { + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test --experimental-strip-types src/paths.test.mts src/wiki-diff.test.mts test/bundle-load.test.mjs", + "vscode:prepublish": "node esbuild.config.mjs --production" + }, + "dependencies": { + "@trailhead/shared": "*" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/vscode": "^1.85.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.2" + } +} diff --git a/apps/vscode-ext/src/api.ts b/apps/vscode-ext/src/api.ts index 18ca34f..1bf4dba 100644 --- a/apps/vscode-ext/src/api.ts +++ b/apps/vscode-ext/src/api.ts @@ -1,29 +1,20 @@ // Tiny HTTP client used by the extension host. Matches the locked shapes from // packages/shared and the Phase-2 endpoints from the roadmap §2. +// Response shapes come from @trailhead/shared, where the API declares them. +// They were previously re-declared here as field-identical local copies with +// no compile-time link to the server, so drift would have been invisible. import type { + ExamplesItem, + ExamplesResponse, ScoreRequest, ScoreResponse, WikiProposeResponse, + WikiRecentItem, + WikiRecentResponse, } from '@trailhead/shared'; -export interface ExamplesItem { - template: string; - topic: string | null; - reuse_count: number; - node_path: string; -} -export interface ExamplesResponse { items: ExamplesItem[]; } - -export interface WikiRecentItem { - id: string; - node_path: string; - body: string; - status: 'draft' | 'durable'; - reinforcement_count: number; - last_seen_at: string; - created_at: string; -} -export interface WikiRecentResponse { items: WikiRecentItem[]; } +// Re-exported so existing importers of these names from './api.ts' keep working. +export type { ExamplesItem, ExamplesResponse, WikiRecentItem, WikiRecentResponse }; export interface ApiConfig { apiUrl: string; diff --git a/apps/vscode-ext/src/extension.ts b/apps/vscode-ext/src/extension.ts index 6143ee9..c5e2c35 100644 --- a/apps/vscode-ext/src/extension.ts +++ b/apps/vscode-ext/src/extension.ts @@ -13,15 +13,51 @@ import { activeFilePath, activeFolderPath } from './paths.ts'; import { getNonce, getWebviewHtml } from './webview.ts'; import { applyWikiSnapshot, diffWikiItems, maxSince } from './wiki-diff.ts'; +// Trailhead ships no hosted API — the backend is self-hosted, so `trailhead.apiUrl` +// is required config. This default matches the port apps/api listens on +// (PORT ?? 3000) and the port the root docker-compose.yml publishes. +const DEFAULT_API_URL = 'http://localhost:3000'; + function readConfig(): api.ApiConfig & { userId: string } { const cfg = vscode.workspace.getConfiguration('trailhead'); + const configured = (cfg.get('apiUrl') ?? '').trim().replace(/\/+$/, ''); return { - apiUrl: cfg.get('apiUrl') ?? 'https://trailheadapi-production.up.railway.app', + apiUrl: configured || DEFAULT_API_URL, teamToken: cfg.get('teamToken') ?? 'trailhead_demo_acme_2026', userId: cfg.get('userId') ?? 'demo', }; } +// A self-hosted API that isn't running fails at the network layer. Undici +// surfaces that as a TypeError / "fetch failed" — distinct from a 4xx, which +// resolves normally. Those get a visible, actionable notification instead of +// an empty sidebar, because silence here reads as "the extension is broken". +function isUnreachable(err: unknown): boolean { + if (err instanceof TypeError) return true; + const msg = err instanceof Error ? err.message : String(err); + return /fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|network/i.test(msg); +} + +// Latched so a 2s wiki poll against a down server doesn't produce a +// notification storm. Cleared by noteApiReachable() on the next success, so a +// server that goes away again re-notifies. +let apiUnreachableNotified = false; + +function noteApiReachable(): void { + apiUnreachableNotified = false; +} + +function notifyApiUnreachable(cfg: { apiUrl: string }, err: unknown): void { + if (!isUnreachable(err) || apiUnreachableNotified) return; + apiUnreachableNotified = true; + const isDefault = cfg.apiUrl === DEFAULT_API_URL; + const detail = isDefault + ? `Trailhead can't reach an API at ${cfg.apiUrl}. Trailhead is self-hosted: start one with \`docker compose up\` from the repo root (see SELFHOSTING.md), or set "trailhead.apiUrl" in Settings to your server's URL.` + : `Trailhead can't reach the API at ${cfg.apiUrl}. Check that the server is running, or correct "trailhead.apiUrl" in Settings.`; + // Optional-called: the bundle-load test stubs `vscode` with a minimal window. + vscode.window.showErrorMessage?.(detail); +} + class CoachViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'trailhead.coach'; @@ -77,9 +113,12 @@ class CoachViewProvider implements vscode.WebviewViewProvider { const cfg = readConfig(); try { const res = await api.examples(cfg, folder); + noteApiReachable(); this.view?.webview.postMessage({ type: 'examples', items: res.items }); - } catch { + } catch (e) { // /examples may not be deployed yet — show empty state, no toast spam. + // An unreachable server is a different problem and does get surfaced. + notifyApiUnreachable(cfg, e); this.view?.webview.postMessage({ type: 'examples', items: [] }); } } @@ -105,10 +144,14 @@ class CoachViewProvider implements vscode.WebviewViewProvider { ac.signal, ); if (ac.signal.aborted) return; + noteApiReachable(); this.view.webview.postMessage({ type: 'score', seq, payload: res }); } catch (e) { if (ac.signal.aborted) return; - const msg = (e as Error).message; + notifyApiUnreachable(cfg, e); + const msg = isUnreachable(e) + ? `can't reach the Trailhead API at ${cfg.apiUrl} — check "trailhead.apiUrl" in Settings` + : (e as Error).message; this.view.webview.postMessage({ type: 'score', seq, error: `score failed: ${msg}` }); } } @@ -133,8 +176,12 @@ class CoachViewProvider implements vscode.WebviewViewProvider { let res: api.WikiRecentResponse; try { res = await api.wikiRecent(cfg, this.wikiSince); - } catch { - return; // endpoint may not be deployed; fail silently + noteApiReachable(); + } catch (e) { + // Endpoint may not be deployed; that stays silent. An unreachable + // server surfaces once (latched) rather than every 2s poll. + notifyApiUnreachable(cfg, e); + return; } const toasts = diffWikiItems(this.wikiState, res.items); for (const t of toasts) { diff --git a/pitch-before-after.html b/archive/pitch-before-after.html similarity index 100% rename from pitch-before-after.html rename to archive/pitch-before-after.html diff --git a/c.txt b/c.txt deleted file mode 100644 index 81dd310..0000000 --- a/c.txt +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..90c4f78 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,103 @@ +# Trailhead — self-hosted stack: Postgres + the API. +# +# cp .env.example .env # then put your Gemini key in it +# docker compose up +# +# That is the whole setup. The schema is applied to a fresh database +# automatically, and the API comes up on http://localhost:3000. +# See SELFHOSTING.md for pointing the clients at it. + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-trailhead} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-trailhead} + POSTGRES_DB: ${POSTGRES_DB:-trailhead} + volumes: + # Named volume: data survives `docker compose down`. Use + # `docker compose down -v` to wipe it (see SELFHOSTING.md). + - trailhead-pgdata:/var/lib/postgresql/data + # Anything in this directory runs, in filename order, the FIRST time the + # data directory is initialised — i.e. on a fresh volume only. That is + # how the schema gets applied without a manual psql step. schema.sql is + # idempotent (every CREATE uses IF NOT EXISTS) so re-running is safe. + - ./packages/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + healthcheck: + # The API waits on this, so it must mean "ready for queries", not just + # "process started". -U/-d avoid a false pass from the bootstrap phase. + test: + ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-trailhead} -d ${POSTGRES_DB:-trailhead}'] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + ports: + # Exposed for psql / the seed + migrate scripts in packages/db. Change + # POSTGRES_PORT if 5432 is already taken on your machine. + # + # Bound to 127.0.0.1 deliberately. A bare '5432:5432' publishes on every + # interface, which on a laptop means the database is reachable by anyone + # on the same café / office network. + - '127.0.0.1:${POSTGRES_PORT:-5432}:5432' + restart: unless-stopped + + api: + build: + # Root context: this is an npm workspace monorepo and the install needs + # the root lockfile plus every workspace manifest. + context: . + dockerfile: apps/api/Dockerfile + environment: + # Defaults to the postgres service above. Set DATABASE_URL in .env to + # point at an external database instead (Neon, RDS, ...). + DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-trailhead}:${POSTGRES_PASSWORD:-trailhead}@postgres:5432/${POSTGRES_DB:-trailhead}} + # The one thing a stranger must supply. `:?` fails the command up front + # with this message rather than building an image that dies on boot. + GEMINI_API_KEY: ${GEMINI_API_KEY:?set GEMINI_API_KEY in .env (cp .env.example .env) — get one at https://aistudio.google.com/apikey} + # Container-internal port. The host mapping below is what you change. + PORT: '3000' + # Any unknown X-Team-Token spawns its own team. + # + # The API's own default is FALSE (unauthenticated tenant creation is not + # something a reachable server should do). Compose overrides it to true + # because `trailhead-mcp init` derives a fresh per-repo token and expects + # the server to accept it — without this, local onboarding 401s. + # + # That trade is only safe because the port below is bound to 127.0.0.1. + # If you change that binding to expose this stack on a network, set this + # to false and register teams explicitly. + TRAILHEAD_AUTO_CREATE_TEAMS: ${TRAILHEAD_AUTO_CREATE_TEAMS:-true} + # Guard on DELETE /team/data for the seeded demo team. + TRAILHEAD_ALLOW_DEMO_RESET: ${TRAILHEAD_ALLOW_DEMO_RESET:-false} + # Optional tracing. Unset = silently disabled with a startup warning. + LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY:-} + LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY:-} + LANGFUSE_BASEURL: ${LANGFUSE_BASEURL:-https://cloud.langfuse.com} + ports: + # host:container — set PORT in .env to publish somewhere other than 3000. + # Every client default (browser ext, VS Code ext, MCP server, dashboard) + # is http://localhost:3000, so changing this means updating those too. + # + # Bound to 127.0.0.1: the API's only auth is a bearer team token, and + # with TRAILHEAD_AUTO_CREATE_TEAMS on (above) any token is accepted. That + # combination must not be reachable from the network. Drop the 127.0.0.1 + # prefix only together with TRAILHEAD_AUTO_CREATE_TEAMS=false. + - '127.0.0.1:${PORT:-3000}:3000' + depends_on: + postgres: + # Don't start until Postgres answers pg_isready — the API opens its + # pool and runs a startup migration check immediately. + condition: service_healthy + healthcheck: + # GET / is the API's status endpoint (no auth). wget ships with the + # busybox userland in node:22-alpine. + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:3000/'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + +volumes: + trailhead-pgdata: diff --git a/docs/roadmaps/2026-04-25-roadmap-browser-extension.md b/docs/roadmaps/2026-04-25-roadmap-browser-extension.md index fd876d9..4f70448 100644 --- a/docs/roadmaps/2026-04-25-roadmap-browser-extension.md +++ b/docs/roadmaps/2026-04-25-roadmap-browser-extension.md @@ -1,5 +1,10 @@ # Roadmap — Browser Extension on Claude.ai (Person B) +> **Historical document.** The host `trailheadapi-production.up.railway.app` +> referenced below is DELETED and returns 404. Trailhead is self-hosted now: +> bring an API up with `docker compose up` (see `SELFHOSTING.md`) and use +> `http://localhost:3000`. Do not copy the URLs below into a config. + **Date:** 2026-04-25 (PoliHack 24h hackathon) **Spec:** [`docs/superpowers/specs/2026-04-25-trailhead-design.md`](../superpowers/specs/2026-04-25-trailhead-design.md) — read §6 (Socratic Mode) first **Status as of writing:** unblocker complete (shared types locked, Hono stub deployed at `https://trailheadapi-production.up.railway.app`, CORS verified open) diff --git a/docs/roadmaps/2026-04-25-roadmap-mcp-vscode-hook.md b/docs/roadmaps/2026-04-25-roadmap-mcp-vscode-hook.md index 9d1ef61..e2b868a 100644 --- a/docs/roadmaps/2026-04-25-roadmap-mcp-vscode-hook.md +++ b/docs/roadmaps/2026-04-25-roadmap-mcp-vscode-hook.md @@ -1,5 +1,10 @@ # Roadmap — VS Code Extension + MCP Server (Person C) +> **Historical document.** The host `trailheadapi-production.up.railway.app` +> referenced below is DELETED and returns 404. Trailhead is self-hosted now: +> bring an API up with `docker compose up` (see `SELFHOSTING.md`) and use +> `http://localhost:3000`. Do not copy the URLs below into a config. + **Date:** 2026-04-25 (PoliHack 24h hackathon) **Spec:** [`docs/superpowers/specs/2026-04-25-trailhead-design.md`](../superpowers/specs/2026-04-25-trailhead-design.md) — read §3, §7, §8 first **Status as of writing:** unblocker complete (shared types locked, schema written, Hono stub deployed at `https://trailheadapi-production.up.railway.app`) diff --git a/docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md b/docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md index 6437a42..bbd3ec6 100644 --- a/docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md +++ b/docs/superpowers/specs/2026-04-25-trailhead-browser-ext-design.md @@ -1,5 +1,10 @@ # Trailhead Browser Extension — Design Spec +> **Historical document.** The host `trailheadapi-production.up.railway.app` +> referenced below is DELETED and returns 404. Trailhead is self-hosted now: +> bring an API up with `docker compose up` (see `SELFHOSTING.md`) and use +> `http://localhost:3000`. Do not copy the URLs below into a config. + **Date:** 2026-04-25 **Scope:** `apps/browser-ext/` (new) + `packages/score-card/` (new shared lib) **Parent spec:** [`2026-04-25-trailhead-design.md`](./2026-04-25-trailhead-design.md) (read §2, §6, §13, §19 first) diff --git a/package.json b/package.json index f723a7d..d2e5fcf 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,21 @@ -{ - "name": "trailhead", - "version": "0.0.0", - "private": true, - "description": "Trailhead — prompt-skill coach. PoliHack 2026-04-25. Spec: docs/superpowers/specs/2026-04-25-trailhead-design.md", - "workspaces": [ - "apps/*", - "packages/*" - ], - "scripts": { - "dev": "npm --workspace=apps/api run dev", - "start": "npm --workspace=apps/api run start", - "typecheck": "npm --workspaces --if-present run typecheck", - "test": "npm --workspaces --if-present run test", - "build": "npm --workspaces --if-present run build" - }, - "engines": { - "node": ">=22.6" - } -} +{ + "name": "trailhead", + "version": "0.0.0", + "license": "MIT", + "private": true, + "description": "Trailhead — prompt-skill coach. PoliHack 2026-04-25. Spec: docs/superpowers/specs/2026-04-25-trailhead-design.md", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev": "npm --workspace=apps/api run dev", + "start": "npm --workspace=apps/api run start", + "typecheck": "npm --workspaces --if-present run typecheck", + "test": "npm --workspaces --if-present run test", + "build": "npm --workspaces --if-present run build" + }, + "engines": { + "node": ">=22.6" + } +} diff --git a/packages/db/migrations/2026-08-17-hot-path-indexes.sql b/packages/db/migrations/2026-08-17-hot-path-indexes.sql new file mode 100644 index 0000000..8d08fe5 --- /dev/null +++ b/packages/db/migrations/2026-08-17-hot-path-indexes.sql @@ -0,0 +1,38 @@ +-- Hot-path indexes. Idempotent; safe to re-run. +-- +-- Apply: psql "$DATABASE_URL" -f packages/db/migrations/2026-08-17-hot-path-indexes.sql +-- +-- Three problems this fixes, all found by reading the actual query predicates +-- in apps/api/src/index.ts against the indexes that existed: +-- +-- 1. `captures` had no index beyond its primary key, yet GET /team/metrics +-- filters it on (team_token, created_at) and the dashboard polls that +-- endpoint every 30 seconds for every open viewer. Every poll was a full +-- sequential scan of the table. +-- +-- 2. The /score dedup probe filters skill_observations on +-- (team_token, user_id, dimension, prompt_hash, ts). The only index led +-- with (team_token, dimension) and carried neither user_id nor +-- prompt_hash, so it could not be used — and that probe runs once per +-- dimension, five times per /score call. +-- +-- 3. `idx_nodes_team_token_path` duplicated the UNIQUE (team_token, path) +-- constraint on the same table, which already maintains an index with the +-- same leading columns. Two indexes, one useful. +-- +-- Note on production: these are plain CREATE INDEX statements, which take an +-- ACCESS EXCLUSIVE-blocking-writes lock for the duration of the build. At the +-- current data volume that is milliseconds. On a large table, run these with +-- CREATE INDEX CONCURRENTLY instead (which cannot run inside a transaction +-- block, so it must be executed statement-by-statement). + +CREATE INDEX IF NOT EXISTS idx_captures_team_created + ON captures(team_token, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_skill_obs_dedup + ON skill_observations(team_token, user_id, dimension, prompt_hash, ts DESC); + +CREATE INDEX IF NOT EXISTS idx_skill_obs_team_ts + ON skill_observations(team_token, ts); + +DROP INDEX IF EXISTS idx_nodes_team_token_path; diff --git a/packages/db/schema.sql b/packages/db/schema.sql index 7f11dd7..0279d11 100644 --- a/packages/db/schema.sql +++ b/packages/db/schema.sql @@ -1,5 +1,8 @@ -- Trailhead schema — spec §4 (docs/superpowers/specs/2026-04-25-trailhead-design.md) --- Six tables. No events table (the Claude Code Stop hook replaces NOTIFY/LISTEN). +-- Eight tables: teams, nodes, learnings, prompts, captures, skill_observations, +-- wiki_jobs, wiki_job_paths. (The header said "six" from the original spec and +-- was never updated when the rich-bootstrap job tables landed.) +-- No events table (the Claude Code Stop hook replaces NOTIFY/LISTEN). -- -- Apply against your Neon database: -- psql "$DATABASE_URL" -f packages/db/schema.sql @@ -35,7 +38,11 @@ CREATE TABLE IF NOT EXISTS nodes ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (team_token, path) ); -CREATE INDEX IF NOT EXISTS idx_nodes_team_token_path ON nodes(team_token, path); +-- NOTE: no separate (team_token, path) index here. The UNIQUE constraint above +-- already creates one with identical leading columns, so a second was pure +-- write amplification and wasted space. Dropped — see +-- migrations/2026-08-17-hot-path-indexes.sql. +DROP INDEX IF EXISTS idx_nodes_team_token_path; -- Accumulated learnings (the "AI-managed" content) CREATE TABLE IF NOT EXISTS learnings ( @@ -79,6 +86,11 @@ CREATE TABLE IF NOT EXISTS captures ( scored_dimensions JSONB, -- {goal_clarity: 8, ...} created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- GET /team/metrics runs +-- SELECT ... FROM captures WHERE team_token = $1 AND created_at > $2 +-- and the dashboard polls it every 30s per open viewer. Without this the +-- table had no index beyond the PK, so every poll was a full sequential scan. +CREATE INDEX IF NOT EXISTS idx_captures_team_created ON captures(team_token, created_at DESC); -- Skill arc data — driven by real /score writes (every browser/VS Code prompt). -- prompt_hash backs the per-(user, dim, prompt-hash) 30s dedup window from @@ -95,6 +107,20 @@ CREATE TABLE IF NOT EXISTS skill_observations ( ); CREATE INDEX IF NOT EXISTS idx_skill_obs_team_token_dim_ts ON skill_observations(team_token, dimension, ts); +-- The 30s dedup NOT EXISTS probe in writeSkillObservations (apps/api/src/index.ts) +-- filters on (team_token, user_id, dimension, prompt_hash, ts). The index above +-- leads with (team_token, dimension) and carries neither user_id nor +-- prompt_hash, so it could not serve that probe — and the probe runs five times +-- (once per dimension) on every single /score. This index matches it column for +-- column. +CREATE INDEX IF NOT EXISTS idx_skill_obs_dedup + ON skill_observations(team_token, user_id, dimension, prompt_hash, ts DESC); + +-- GET /skill-arc (WHERE team_token AND ts > $, ORDER BY ts) and the +-- COUNT(DISTINCT user_id) in GET /team/metrics. Neither filters on dimension, +-- so neither could use idx_skill_obs_team_token_dim_ts. +CREATE INDEX IF NOT EXISTS idx_skill_obs_team_ts ON skill_observations(team_token, ts); + -- Cheap-insurance index for the GET /wiki/recent polling query. CREATE INDEX IF NOT EXISTS idx_learnings_last_seen_at ON learnings(last_seen_at DESC); diff --git a/packages/score-card/package.json b/packages/score-card/package.json index 005b909..b5ac81a 100644 --- a/packages/score-card/package.json +++ b/packages/score-card/package.json @@ -1,27 +1,28 @@ -{ - "name": "@trailhead/score-card", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./render": "./src/render.ts", - "./render-pure": "./src/render-pure.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "node --test --experimental-strip-types src/render-pure.test.mts" - }, - "dependencies": { - "@trailhead/shared": "*" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "typescript": "^5.7.2" - } -} +{ + "name": "@trailhead/score-card", + "version": "0.0.0", + "license": "MIT", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./render": "./src/render.ts", + "./render-pure": "./src/render-pure.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "node --test --experimental-strip-types src/render-pure.test.mts" + }, + "dependencies": { + "@trailhead/shared": "*" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.2" + } +} diff --git a/packages/scoring/package.json b/packages/scoring/package.json index 2ed29a6..3d3f3d8 100644 --- a/packages/scoring/package.json +++ b/packages/scoring/package.json @@ -1,6 +1,7 @@ { "name": "@trailhead/scoring", "version": "0.0.0", + "license": "MIT", "private": true, "type": "module", "main": "./src/index.ts", @@ -20,7 +21,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "node --test src/normalize.test.mjs src/extract-prompt.test.mjs src/dedup-invariant.test.mjs" + "test": "node --test src/normalize.test.mjs src/extract-prompt.test.mjs src/dedup-invariant.test.mjs src/declarations-match.test.mjs" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/scoring/src/declarations-match.test.mjs b/packages/scoring/src/declarations-match.test.mjs new file mode 100644 index 0000000..d898989 --- /dev/null +++ b/packages/scoring/src/declarations-match.test.mjs @@ -0,0 +1,94 @@ +// Verifies that every hand-written .d.mts declaration matches the .mjs it +// claims to describe. +// +// Why this exists: the runtime helpers in this package are .mjs (so the Stop +// hook and other plain-ESM consumers can import them with no build step), and +// each one has a hand-written .d.mts sitting next to it. TypeScript resolves +// importers to the .d.mts and never compares it against the .mjs, so the two +// could disagree indefinitely and every typecheck in the repo would still pass +// — a declared export that doesn't exist gives consumers a value that is +// `undefined` at runtime with no compile-time warning anywhere. +// +// This test closes that gap the direct way: parse the value-level exports out +// of each declaration, import the implementation, and assert the two sets are +// equal in both directions. +// +// Run: npm --workspace=packages/scoring test + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** + * Value-level exports declared in a .d.mts — the ones that must exist at + * runtime. `export interface` / `export type` are type-only and are + * deliberately excluded. + * + * @param {string} source + * @returns {Set} + */ +function declaredValueExports(source) { + const names = new Set(); + const patterns = [ + /^export\s+declare\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm, + /^export\s+declare\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm, + /^export\s+declare\s+class\s+([A-Za-z_$][\w$]*)/gm, + ]; + for (const re of patterns) { + for (const m of source.matchAll(re)) names.add(m[1]); + } + return names; +} + +const declarationFiles = readdirSync(HERE) + .filter((f) => f.endsWith('.d.mts')) + .sort(); + +test('the package actually has declaration files to check', () => { + // Guards against this whole suite silently passing if the files move. + assert.ok(declarationFiles.length >= 8, `found only ${declarationFiles.length} .d.mts files`); +}); + +for (const decl of declarationFiles) { + const impl = decl.replace(/\.d\.mts$/, '.mjs'); + + test(`${decl} matches ${impl}`, async () => { + const source = readFileSync(join(HERE, decl), 'utf8'); + const declared = declaredValueExports(source); + + const mod = await import(pathToFileURL(join(HERE, impl)).href); + const actual = new Set(Object.keys(mod)); + + const missing = [...declared].filter((n) => !actual.has(n)); + assert.deepEqual( + missing, + [], + `${decl} declares ${missing.join(', ')} but ${impl} does not export it — ` + + `consumers would get undefined at runtime with no type error`, + ); + + const undeclared = [...actual].filter((n) => !declared.has(n)); + assert.deepEqual( + undeclared, + [], + `${impl} exports ${undeclared.join(', ')} but ${decl} does not declare it — ` + + `the export is invisible to every TypeScript consumer`, + ); + }); +} + +test('models.mjs exports the model ids the API and README both cite', async () => { + // These strings are load-bearing: gemini.ts routes on them and the README + // documents them. A silent rename here is a production incident. + /** @type {Record} */ + const models = await import('./models.mjs'); + for (const k of ['SCORE_MODEL', 'TOPIC_MODEL', 'DIFF_MODEL', 'EXTRACT_MODEL']) { + const v = models[k]; + assert.equal(typeof v, 'string', `${k} must be a string`); + assert.ok(/** @type {string} */ (v).length > 0, `${k} must not be empty`); + } +}); diff --git a/packages/scoring/tsconfig.json b/packages/scoring/tsconfig.json index a6a0c40..0888379 100644 --- a/packages/scoring/tsconfig.json +++ b/packages/scoring/tsconfig.json @@ -3,7 +3,9 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "esModuleInterop": true, "allowSyntheticDefaultImports": true, "verbatimModuleSyntax": true, @@ -12,7 +14,13 @@ "skipLibCheck": true, "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true + "noEmit": true, + "allowJs": true, + "checkJs": true }, - "include": ["src/**/*.ts"] + "include": [ + "src/**/*.ts", + "src/**/*.mts", + "src/**/*.mjs" + ] } diff --git a/packages/shared/package.json b/packages/shared/package.json index 1c6b897..51a294a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,11 +1,12 @@ -{ - "name": "@trailhead/shared", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./types.ts", - "types": "./types.ts", - "exports": { - ".": "./types.ts" - } -} +{ + "name": "@trailhead/shared", + "version": "0.0.0", + "license": "MIT", + "private": true, + "type": "module", + "main": "./types.ts", + "types": "./types.ts", + "exports": { + ".": "./types.ts" + } +} diff --git a/packages/shared/types.ts b/packages/shared/types.ts index b93b4e3..54564b0 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -77,6 +77,15 @@ export interface ExamplesItem { } export interface ExamplesResponse { items: ExamplesItem[]; } +// GET /search?q=&scope= — free-text search across durable learnings, node +// rules and graduated prompts. `kind` says which of the three matched. +export interface SearchItem { + kind: 'learning' | 'rule' | 'prompt'; + body: string; + node_path: string; +} +export interface SearchResponse { items: SearchItem[]; } + // GET /prompts/proven — every graduated prompt for the team, with the // actual overall score from when /coach promoted it. "Proven" is the user- // facing framing; under the hood it's status='graduated' filtered by @@ -298,6 +307,18 @@ export interface CoachResponse { missing: MissingHints; text: string; // fully rendered block to relay verbatim, may be '' + // Set when coaching could not be produced because the scoring LLM failed + // (request threw, or returned output we could not parse). `proceed` stays + // true — a coaching outage must not block the user's actual work — but the + // caller is told, in `text` and here, that this turn was NOT coached. + // + // Before this existed, that case returned { proceed: true, text: '', + // overall: 0 }, which the MCP tool rendered as "no coaching needed" — the + // exact same output as a flawless prompt. The tool ran forever, never + // coaching and never erroring. Never let a scoring failure be silent. + degraded?: boolean; + error?: string; // short machine-readable reason, e.g. 'score_failed' + // Populated when proceed=false. Echo the four fields back unchanged on the // next coach() call, with `prompt` set to original + user's reply. next_round_inputs?: CoachNextRoundInputs; @@ -313,12 +334,20 @@ export interface CoachResponse { // before any token is configured. Demo simplicity: no per-user // permission filter (the user explicitly asked for "all teams"). // -// Token is the team's primary key (the value the client sends as -// X-Team-Token). There is no separate UUID id since the -// 2026-04-26 token-as-team-key refactor. +// A team as described to a client. +// +// `token` is deliberately NOT here. The team token is the only credential this +// system has — it grants read on the wiki (which summarises private source +// code) and write everywhere — and GET /teams used to hand back every tenant's +// token, unauthenticated, with CORS `*`. One request to a public URL was a +// full compromise of every team on the server. +// +// `id` is an opaque, stable, non-reversible digest of the token. It is safe to +// display and to use as a React key or a lookup handle, and it cannot be +// replayed as an X-Team-Token. export interface TeamSummary { name: string; - token: string; + id: string; } export interface TeamsListResponse { teams: TeamSummary[]; diff --git a/scripts/delete-prompt.mjs b/scripts/delete-prompt.mjs deleted file mode 100644 index ac73bcb..0000000 --- a/scripts/delete-prompt.mjs +++ /dev/null @@ -1,74 +0,0 @@ -// One-off DELETE for the prompt(s) the user confirmed. -// Run from apps/api so node resolves `pg`: -// cd apps/api && node ../../scripts/delete-prompt.mjs - -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import pg from 'pg'; - -for (const candidate of ['.env', '../../.env', '../../../.env']) { - const p = resolve(process.cwd(), candidate); - if (existsSync(p)) { process.loadEnvFile(p); break; } -} - -const PROMPT_IDS = [ - '9fd05c5f-346f-42f5-8548-f087c251ad15', - '2e9bb4d8-1e1d-49c9-ba95-e92174fd69a5', -]; -const EXPECTED_TEAM = 'repo_dbab62ba8d72ca37'; -const EXPECTED_TEMPLATE_PREFIX = 'I want to improve the create case to output differently'; - -const { DATABASE_URL } = process.env; -if (!DATABASE_URL) { console.error('DATABASE_URL not set'); process.exit(1); } - -const pool = new pg.Pool({ connectionString: DATABASE_URL }); - -async function main() { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - const before = await client.query( - `SELECT p.id, p.template, n.team_token - FROM prompts p - JOIN nodes n ON n.id = p.node_id - WHERE p.id = ANY($1::uuid[])`, - [PROMPT_IDS], - ); - if (before.rowCount !== PROMPT_IDS.length) { - throw new Error(`expected ${PROMPT_IDS.length} rows, found ${before.rowCount}`); - } - for (const row of before.rows) { - if (row.team_token !== EXPECTED_TEAM) { - throw new Error(`team_token mismatch on ${row.id}: expected ${EXPECTED_TEAM}, got ${row.team_token}`); - } - if (!row.template.startsWith(EXPECTED_TEMPLATE_PREFIX)) { - throw new Error(`template prefix mismatch on ${row.id} — refusing to delete`); - } - } - - const del = await client.query( - `DELETE FROM prompts WHERE id = ANY($1::uuid[])`, - [PROMPT_IDS], - ); - console.log(`deleted ${del.rowCount} row(s) from prompts`); - - await client.query('COMMIT'); - - const after = await client.query( - `SELECT id FROM prompts WHERE id = ANY($1::uuid[])`, - [PROMPT_IDS], - ); - if (after.rowCount !== 0) throw new Error('rows still present after commit'); - console.log('verified: rows no longer present'); - } catch (e) { - await client.query('ROLLBACK').catch(() => {}); - throw e; - } finally { - client.release(); - } -} - -main() - .catch((e) => { console.error('FAILED:', e.message); process.exit(1); }) - .finally(() => pool.end()); diff --git a/scripts/find-prompt-to-delete.mjs b/scripts/find-prompt-to-delete.mjs deleted file mode 100644 index 74bae66..0000000 --- a/scripts/find-prompt-to-delete.mjs +++ /dev/null @@ -1,77 +0,0 @@ -// One-off: locate the prompt the user wants to delete. -// READ-ONLY. No DELETE happens here — that's a second script. -// -// Run from apps/api so node resolves `pg`: -// cd apps/api && node ../../scripts/find-prompt-to-delete.mjs - -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import pg from 'pg'; - -for (const candidate of ['.env', '../../.env', '../../../.env']) { - const p = resolve(process.cwd(), candidate); - if (existsSync(p)) { process.loadEnvFile(p); break; } -} - -const { DATABASE_URL } = process.env; -if (!DATABASE_URL) { - console.error('DATABASE_URL not set'); - process.exit(1); -} - -const pool = new pg.Pool({ connectionString: DATABASE_URL }); - -async function main() { - const teams = await pool.query( - `SELECT token, name FROM teams - WHERE LOWER(name) LIKE '%cs%weekly%' - OR LOWER(name) LIKE '%weekly%journal%' - OR LOWER(name) LIKE '%item%journal%' - OR LOWER(name) LIKE '%journal%' - ORDER BY name`, - ); - console.log(`\n=== Teams matching the description (${teams.rowCount}) ===`); - for (const r of teams.rows) console.log(` ${r.token} ${JSON.stringify(r.name)}`); - - const prompts = await pool.query( - `SELECT p.id, - p.template, - p.topic, - p.graduated_overall_score, - p.reuse_count, - p.created_at, - p.author_user_id, - n.path AS node_path, - n.team_token, - t.name AS team_name - FROM prompts p - JOIN nodes n ON n.id = p.node_id - JOIN teams t ON t.token = n.team_token - WHERE LOWER(p.template) LIKE '%improve the create case%' - OR LOWER(p.template) LIKE '%create case to output%' - OR LOWER(p.template) LIKE '%create_case%' - OR (LOWER(p.template) LIKE '%create case%' - AND (LOWER(p.template) LIKE '%output%' - OR LOWER(p.template) LIKE '%json%' - OR LOWER(p.template) LIKE '%csv%')) - ORDER BY p.created_at DESC`, - ); - - console.log(`\n=== Prompts matching the chain (${prompts.rowCount}) ===`); - for (const r of prompts.rows) { - console.log('---'); - console.log(` id ${r.id}`); - console.log(` team ${JSON.stringify(r.team_name)} (${r.team_token})`); - console.log(` node_path ${r.node_path}`); - console.log(` topic ${r.topic}`); - console.log(` score/reuse ${r.graduated_overall_score}/10 reuse=${r.reuse_count}`); - console.log(` created_at ${r.created_at.toISOString()}`); - console.log(` author ${r.author_user_id ?? '(null)'}`); - console.log(` template ${JSON.stringify(r.template).slice(0, 400)}${r.template.length > 400 ? '…' : ''}`); - } - console.log(''); -} - -main() - .catch((e) => { console.error(e); process.exit(1); }) - .finally(() => pool.end()); diff --git a/scripts/list-team-prompts.mjs b/scripts/list-team-prompts.mjs deleted file mode 100644 index b08ec27..0000000 --- a/scripts/list-team-prompts.mjs +++ /dev/null @@ -1,25 +0,0 @@ -// READ-ONLY: list every prompt for the CS-Weekly-Item-Journal team. -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import pg from 'pg'; - -for (const candidate of ['.env', '../../.env', '../../../.env']) { - const p = resolve(process.cwd(), candidate); - if (existsSync(p)) { process.loadEnvFile(p); break; } -} -const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); - -const rows = await pool.query( - `SELECT p.id, p.template, p.created_at, p.graduated_overall_score AS score, n.path - FROM prompts p - JOIN nodes n ON n.id = p.node_id - WHERE n.team_token = $1 - ORDER BY p.created_at DESC`, - ['repo_dbab62ba8d72ca37'], -); -console.log(`\n${rows.rowCount} prompt(s) for CS-Weekly-Item-Journal:\n`); -for (const r of rows.rows) { - console.log(` ${r.id} ${r.created_at.toISOString()} ${r.score}/10 path="${r.path}"`); - console.log(` ${JSON.stringify(r.template).slice(0, 220)}${r.template.length > 220 ? '…' : ''}`); -} -await pool.end();