Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/agentic-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
pull_request:
paths:
- "src/**"
- "packages/**"
- "public/**"
- "index.html"
- "supabase/functions/**"
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
- "src/lib/supabase/**"
- "src/lib/stores/**"
- "src/lib/ai/**"
- "packages/**"
- "e2e/full-stack/**"
- "e2e/workspace-collaboration.spec.ts"
- "playwright.workspace.config.ts"
Expand All @@ -31,6 +32,7 @@ on:
- "src/lib/supabase/**"
- "src/lib/stores/**"
- "src/lib/ai/**"
- "packages/**"
- "e2e/full-stack/**"
- "e2e/workspace-collaboration.spec.ts"
- "playwright.workspace.config.ts"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/live-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
paths:
- "supabase/functions/**"
- "src/lib/ai/**"
- "packages/**"
- "e2e/live/**"
- "e2e/full-stack/fake-openrouter.mjs"
- ".superset/db-gate.sh"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/model-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- supabase/functions/_shared/**
- supabase/functions/ai/**
- src/lib/ai/**
- packages/**
- e2e/model-matrix/**
- .github/workflows/model-matrix.yml
schedule:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The local Supabase stack still uses shared default ports and container names. In

- `src/` is the React 18 and TypeScript client. Routes and lazy page entry points live in `src/App.tsx`.
- `src/lib/stores/` owns browser state and Supabase hydration. `src/lib/ai/client.ts` is the client AI boundary.
- `packages/core/` (`@debatelab/core`, npm workspace) holds platform-neutral shared code: Zod schemas, validation, languages, Crossfire and Sparring logic. No DOM globals, React, or `import.meta` there; old `src/lib/` paths re-export it. Stores and i18n catalogs stay in `src/`.
- `supabase/functions/` contains Deno Edge Functions. Shared auth, CORS, research, prompts, cost, and validation code lives in `supabase/functions/_shared/`.
- `supabase/migrations/` is the database history and schema source of truth.
- `src/lib/stores/debate-store.ts` owns the Current Debate: every source and saved result belongs to one debate or to one-off work, and nothing crosses debates.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ Key entry points:
## Repository map

```text
src/ React client, stores, hooks, schemas, and unit tests
src/ React client, stores, hooks, and unit tests
packages/core/ Platform-neutral shared code: schemas, validation, languages
supabase/ Edge Functions, migrations, pgTAP tests, local config
e2e/ Playwright browser, full-stack, live, staging, and canary tests
services/ Collaboration service for the hidden Workspace
Expand Down
8 changes: 6 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ Collaborative Workspace (`/app/workspaces/*`, the `workspace-invites` function,

`AuthProvider`, TanStack Query, theme and motion providers, toast systems, the offline indicator, and the route error boundary wrap the routed application.

## Workspace layout

The repository is an npm workspace. The web app is the root package and still lives in `src/`. [`packages/core/`](../packages/core/README.md) (`@debatelab/core`) holds platform-neutral TypeScript the iOS app will share: languages, input validation, the Zod schemas for AI requests and results, Crossfire and Sparring logic, and the Edge Function error reader. Its code may not use DOM globals, React, `import.meta`, or Deno specifiers; its tsconfig has no DOM library and ESLint enforces the rest. Each moved module leaves a one-line re-export at its old `src/lib/` path, so web imports and test mocks are unchanged. The stores, the AI client and streaming, the Supabase client, and the i18n catalogs stay in `src/` for now.

## Client state

[`src/lib/stores/session-store.ts`](../src/lib/stores/session-store.ts) owns active AI results, conversations, saved work, sources, folders, flowcharts, autosave state, and persisted form state. It persists selected local state and hydrates authenticated rows from Supabase.
Expand All @@ -37,7 +41,7 @@ Collaborative Workspace (`/app/workspaces/*`, the `workspace-invites` function,

## Language behavior

[`src/lib/languages.ts`](../src/lib/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and `<html lang>`. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages.
[`packages/core/src/languages.ts`](../packages/core/src/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and `<html lang>`. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages.

Interface language and AI-output language are separate settings. Interface language controls rendered copy, accessible names, generated app boilerplate, dates, numbers, relative time, PDF chrome, and Web Speech's recognition locale for dictation. Voice rounds recognise speech in the AI-output language instead, because that is the language the round is held in. AI-output language is sent through every structured, streaming, Crossfire, and Sparring request path and becomes an explicit backend prompt instruction. Existing user/provider content and previously saved titles are not rewritten when either setting changes.

Expand Down Expand Up @@ -68,7 +72,7 @@ For a validated Crossfire next turn, the server returns the reply first with a s

Sparring and Crossfire can be spoken. The setup screen offers a voice switch only where the browser has the Web Speech recogniser and the microphone is not blocked; otherwise it shows a one-line reason and the round is typed as before. The first time the switch is turned on, a notice says where audio and text go, and [`src/lib/voice/availability.ts`](../src/lib/voice/availability.ts) remembers the acknowledgement in that browser.

The student's speech never reaches DebateLab as audio. [`src/hooks/use-speech-capture.ts`](../src/hooks/use-speech-capture.ts) feeds the microphone to the browser's recogniser (in Chrome that sends audio to Google), restarts it when the browser ends a session on silence, and puts the recognised words into the same draft a typed turn uses. It also times the take, and [`src/lib/voice/delivery.ts`](../src/lib/voice/delivery.ts) turns that into measured facts: speaking time, words per minute, filler words from a per-language list, and silences of three seconds or more. The facts ride on the user's speech or turn as an optional `delivery` field, so sessions and drafts from before voice rounds still load. The sparring scorecard and the Crossfire debrief show them as numbers. The sparring judge receives them with a prompt that allows it to quote those numbers and comment on structure, and forbids any claim about tone, confidence, or volume; typed rounds keep the typed prompt unchanged. The Crossfire debrief is never asked to judge delivery.
The student's speech never reaches DebateLab as audio. [`src/hooks/use-speech-capture.ts`](../src/hooks/use-speech-capture.ts) feeds the microphone to the browser's recogniser (in Chrome that sends audio to Google), restarts it when the browser ends a session on silence, and puts the recognised words into the same draft a typed turn uses. It also times the take, and [`packages/core/src/voice/delivery.ts`](../packages/core/src/voice/delivery.ts) turns that into measured facts: speaking time, words per minute, filler words from a per-language list, and silences of three seconds or more. The facts ride on the user's speech or turn as an optional `delivery` field, so sessions and drafts from before voice rounds still load. The sparring scorecard and the Crossfire debrief show them as numbers. The sparring judge receives them with a prompt that allows it to quote those numbers and comment on structure, and forbids any claim about tone, confidence, or volume; typed rounds keep the typed prompt unchanged. The Crossfire debrief is never asked to judge delivery.

A voice request adds a top-level `voice: true` beside `input`, where a server from before voice rounds ignores it. The opponent prompt then allows a short allowlist of inline audio tags such as `[sighs]` and `[sarcastic]`. [`supabase/functions/_shared/voice-tags.ts`](../supabase/functions/_shared/voice-tags.ts) splits the model's reply in two: the clean text, with every tag removed, is what the response's `output` carries, what is signed, shown, saved, and read by the fallback voice; the spoken text keeps allowlisted tags, turns `[pause]` into the ellipsis Eleven v4 documents for pauses, rations tags to at most one per sentence and never two sentences running, and is cut into segments of at most 1,000 characters. Up to 12 segments are returned in a top-level `voice.segments` list, each with a 30-minute HMAC token from [`_shared/voice-token.ts`](../supabase/functions/_shared/voice-token.ts) that binds the text to the user and the feature; the verifier allows 30 seconds of clock difference between the two functions. Text past the twelfth segment comes back unsigned and without tags in `voice.overflow`, which only the browser voice reads, so a long reply is heard in full without raising the ElevenLabs cost of one reply. The spoken text lives only in memory in the browser.

Expand Down
6 changes: 5 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ nvm use
npm ci
```

The repository is an npm workspace: `npm ci` at the root installs the web app and every package under `packages/`, and links `@debatelab/core` into `node_modules`. Run every script from the root.

## Start the client

Outside Superset, start Vite directly:
Expand Down Expand Up @@ -85,7 +87,9 @@ The main entry points are:
- `src/components/features/` for feature UI.
- `src/lib/stores/` for state and Supabase hydration.
- [`src/lib/ai/client.ts`](../src/lib/ai/client.ts) for AI requests.
- `src/lib/ai/schemas/` for response validation.
- `packages/core/src/ai/schemas/` for response validation.

Platform-neutral code shared with the iOS app lives in [`packages/core/`](../packages/core/README.md). It mirrors `src/lib/` paths, and each old path re-exports it, so web code keeps importing `@/lib/...`. To add a module, put it under `packages/core/src/`, import siblings by relative path, leave a re-export at the old `src/lib/` path if web code used it, and follow the package's no-DOM rule. The stores, the AI client, and the i18n catalogs stay in `src/` for now.

Follow existing functional React, Zustand, Zod, and shadcn/Radix patterns. Reuse current helpers before adding another abstraction.

Expand Down
43 changes: 40 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";

export default tseslint.config(
{ ignores: ["dist", "coverage", ".remember"] },
{ ignores: ["dist", "coverage", ".remember", "packages/**/node_modules", "packages/**/dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
ignores: ["packages/**"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
Expand All @@ -23,11 +24,47 @@ export default tseslint.config(
"@typescript-eslint/no-unused-vars": "off",
},
},
{
// Shared packages run on the web and in React Native, so they get no
// browser globals and no web-only libraries. See packages/core/README.md.
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["packages/**/*.ts"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.es2021,
},
rules: {
"@typescript-eslint/no-unused-vars": "off",
"no-restricted-globals": [
"error",
...["window", "document", "localStorage", "sessionStorage", "navigator", "location", "self"].map((name) => ({
name,
message: "Shared packages cannot use browser globals. Take the value as a parameter.",
})),
],
"no-restricted-syntax": [
"error",
{ selector: "MetaProperty[meta.name='import']", message: "Shared packages cannot use import.meta. Take the value as a parameter." },
],
"no-restricted-imports": [
"error",
{
paths: [
"react", "react-dom", "react-native", "react-i18next", "zustand", "@supabase/supabase-js", "posthog-js",
"sonner", "react-router-dom", "reactflow", "lucide-react", "framer-motion", "jspdf", "docx",
].map((name) => ({ name, message: "Shared packages stay platform-neutral; keep this in the app." })),
patterns: [
{ group: ["@sentry/*", "@radix-ui/*"], message: "Shared packages stay platform-neutral; keep this in the app." },
],
},
],
},
},
{
// A promise nobody awaits or catches turns a failed write into an
// unhandled rejection with no message for the user. Type-aware, so it
// runs on the client app only.
files: ["src/**/*.{ts,tsx}"],
// runs on the client app and the shared packages only.
files: ["src/**/*.{ts,tsx}", "packages/**/src/**/*.ts"],
ignores: ["src/test/**"],
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
Expand Down
69 changes: 39 additions & 30 deletions knip.json
Original file line number Diff line number Diff line change
@@ -1,36 +1,45 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
"scripts/*.{mjs,ts}",
"scripts/staging/seed.mjs",
"e2e/full-stack/fake-openrouter.mjs",
"e2e.config.ts",
"agentic/**/*.e2e.ts",
"services/collaboration/src/index.ts",
"services/collaboration/src/**/*.test.ts",
".claude/skills/verify-debatelab/bin/*.mjs",
".claude/skills/verify-debatelab/recipes/*.spec.ts",
"e2e/**/*.setup.ts",
".claude/skills/verify-debatelab/playwright.verify.config.ts"
],
"project": [
"src/**/*.{ts,tsx}",
"scripts/**/*.{mjs,ts}",
"e2e/**/*.{ts,mjs}",
"agentic/**/*.ts",
"services/collaboration/src/**/*.ts",
".claude/skills/verify-debatelab/**/*.{ts,mjs}"
],
"playwright": {
"config": [
"playwright.config.ts",
"playwright.production.config.ts",
"playwright.workspace.config.ts"
]
"workspaces": {
".": {
"entry": [
"scripts/*.{mjs,ts}",
"scripts/staging/seed.mjs",
"e2e/full-stack/fake-openrouter.mjs",
"e2e.config.ts",
"agentic/**/*.e2e.ts",
"services/collaboration/src/index.ts",
"services/collaboration/src/**/*.test.ts",
".claude/skills/verify-debatelab/bin/*.mjs",
".claude/skills/verify-debatelab/recipes/*.spec.ts",
"e2e/**/*.setup.ts",
".claude/skills/verify-debatelab/playwright.verify.config.ts"
],
"project": [
"src/**/*.{ts,tsx}",
"scripts/**/*.{mjs,ts}",
"e2e/**/*.{ts,mjs}",
"agentic/**/*.ts",
"services/collaboration/src/**/*.ts",
".claude/skills/verify-debatelab/**/*.{ts,mjs}"
],
"playwright": {
"config": [
"playwright.config.ts",
"playwright.production.config.ts",
"playwright.workspace.config.ts"
]
},
"ignore": [
"src/lib/workspace/document-conversion.ts"
]
},
"packages/core": {
"project": [
"src/**/*.ts"
]
}
},
"ignore": [
"src/lib/workspace/document-conversion.ts"
],
"ignoreBinaries": [
"supabase"
],
Expand Down
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"engines": {
"node": "22.x"
},
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "vite",
"dev:preview": "VITE_STAGING_PREVIEW=true vite --host 127.0.0.1",
Expand All @@ -16,7 +19,7 @@
"build:dev": "vite build --mode development",
"lint": "eslint . && npm run lint:repo",
"lint:repo": "knip --no-config-hints && node scripts/check-repo-rules.mjs",
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.agentic.json",
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.agentic.json && tsc --noEmit -p packages/core/tsconfig.json",
"preview": "vite preview",
"smoke:jev": "node scripts/smoke-jev.mjs",
"test": "vitest run",
Expand Down
22 changes: 22 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# @debatelab/core

Platform-neutral TypeScript shared by the web app and, later, the iOS app: languages, input validation, Zod schemas for AI requests and results, Crossfire and Sparring logic, and the Edge Function error reader. Files keep their old `src/lib/` relative paths, and each old path is now a one-line re-export, so web imports did not change.

## The rule

Code here must run in a browser and in React Native without changes:

- No DOM globals: no `window`, `document`, `localStorage`, `sessionStorage`, `navigator`, or `location`. Take a value or a small interface as a parameter instead, as [`crossfire/storage.ts`](src/crossfire/storage.ts) does with `StorageLike`.
- No React, no web-only libraries (stores, i18n bindings, the Supabase client, analytics, UI kits).
- No `import.meta`, and no Deno or `esm.sh` specifiers.
- Plain TypeScript. The only dependency is `zod`, at the same range as the root.

[`tsconfig.json`](tsconfig.json) has no DOM library, and ESLint rejects browser globals and web-only imports here. [`src/globals.d.ts`](src/globals.d.ts) declares the few runtime globals both platforms have that the ES library lacks; add to it only what package code uses.

`src/contract/` re-exports the request contracts in `supabase/functions/_shared/` so package code has one import path for them. Those files stay where the Edge Functions import them.

## Adding a module

1. Move or write the file under `src/`, importing siblings by relative path (never `@/`).
2. If web code already imports it from `src/lib/`, leave `export * from '@debatelab/core/<path>';` at the old path.
3. Run `npm run typecheck` and `npm run lint` from the repository root.
12 changes: 12 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@debatelab/core",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./*": "./src/*.ts"
},
"dependencies": {
"zod": "^4.3.6"
}
}
31 changes: 31 additions & 0 deletions packages/core/src/ai/providers/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// packages/core/src/ai/providers/types.ts

import { MODEL_CATALOG } from '../../contract/model-catalog';
import type { ReasoningEffort } from '../../contract/model-catalog';

export {
AUTO_MODEL_SELECTIONS,
DEFAULT_MODEL,
DEFAULT_PRO_MODEL,
isAutoModelSelection,
isModelSelection,
isReasoningEffort,
} from '../../contract/model-catalog';
export type {
AutoModelSelection,
ModelSelection,
ReasoningEffort,
} from '../../contract/model-catalog';
export type { ModelCatalogEntry as AIModel } from '../../contract/model-catalog';

/** Every accepted model routes through OpenRouter; see _shared/model-catalog. */
export const PROVIDER_ID = 'openrouter';

export const AVAILABLE_MODELS = MODEL_CATALOG;

export interface ProviderConfig {
provider: string;
model: string;
temperature: number;
reasoningEffort?: ReasoningEffort;
}
Loading
Loading