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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# duh environment variables
# Copy to .env and fill in values. Never commit .env to git.

# Provider API keys (at least one required)
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
GOOGLE_API_KEY=
PERPLEXITY_API_KEY=
# MISTRAL_API_KEY=

# JWT secret for auth tokens (required in production; auto-generated in dev if empty)
DUH_JWT_SECRET=

Expand Down
661 changes: 661 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ duh asks multiple LLMs the same question, forces them to challenge each other's
uv add duh
export ANTHROPIC_API_KEY=sk-ant-...
export OPENAI_API_KEY=sk-...
export GOOGLE_API_KEY=AIza... # optional: Gemini models
export PERPLEXITY_API_KEY=pplx-... # optional: Sonar models (challenger-only)
duh ask "What database should I use for a new SaaS product?"
```

Or use a `.env` file (see `.env.example`).

## Features

- **Multi-model consensus** -- Claude, GPT, Gemini, Mistral, and Perplexity debate. Sycophantic challenges are detected and flagged.
Expand Down Expand Up @@ -127,4 +131,4 @@ If duh is useful to you, consider [sponsoring the project](https://github.com/sp

## License

TBD
[AGPL-3.0](LICENSE)
57 changes: 20 additions & 37 deletions memory-bank/activeContext.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,37 @@
# Active Context

**Last Updated**: 2026-02-20
**Current Phase**: v0.6.0 "It's Honest" — sign-out bug fix in progress
**Next Action**: User needs to rebuild (`cd web && npm run build`) and test sign-out. If it works, PR ready.
**Last Updated**: 2026-03-07
**Current Phase**: Post v0.6.0 — z-index fix + GPT-5.4 + .env improvements
**Next Action**: PR ready for review

## v0.6.0 "It's Honest" — Complete (minus sign-out bug)
## Latest Work (2026-03-07)

All 9 tasks (T1-T9) implemented:
- T1: Auth store (Zustand) — `web/src/stores/auth.ts`
- T2: API client auth integration — Bearer token injection, 401 handling, WS token handshake
- T3: Login page — `web/src/pages/LoginPage.tsx`
- T4: Route protection — `web/src/components/shared/ProtectedRoute.tsx`, TopBar user menu
- T5: Dev mode detection — `GET /api/auth/status` endpoint, guest fallback
- T6: Batch feedback — inline Pass/Partial/Fail buttons on ThreadCard
- T7: Frontend tests — 11 auth store + 8 auth component tests
- T8: Documentation — web-ui auth, authentication guide, epistemic-confidence concept doc
- T9: Version bump to 0.6.0
### Z-index stacking context fix
- **Problem**: Nested stacking contexts (`z-10` on main content, `z-20` on TopBar header) trapped dropdowns inside containers. Account menu's `fixed inset-0 z-40` backdrop was meaningless outside its container.
- **Fix**: Removed unnecessary z-index values creating stacking contexts, added `isolate` to Shell root, defined z-index tokens in CSS (`--z-background`, `--z-dropdown`, `--z-overlay`, `--z-modal`), replaced backdrop hack with `useRef` + `mousedown` click-outside pattern (matching ExportMenu).
- Files: `duh-theme.css`, `Shell.tsx`, `TopBar.tsx`, `GridOverlay.tsx`, `ParticleField.tsx`, `ExportMenu.tsx`, `ConsensusComplete.tsx`, `ThreadDetail.tsx`

### Sign-Out Bug (IN PROGRESS)
### GPT-5.4 added to model catalog
- `gpt-5.4`: 1M context, 128K output, $2.50/$15.00 per MTok, no temperature (uses reasoning.effort)
- Added to `NO_TEMPERATURE_MODELS` set
- File: `src/duh/providers/catalog.py`

**Problem**: Clicking "Sign Out" in TopBar user menu dropdown does nothing — menu closes but user stays authenticated.

**Root cause**: The outside-click handler used `document.addEventListener('mousedown', ...)` which was intercepting ALL mouse events inside the dropdown (including on the Sign Out button), closing the menu before the click handler could fire. User confirmed: "I can't right click to inspect — the interface disappears."

**Fix applied** (`web/src/components/layout/TopBar.tsx`):
- Removed the broken `mousedown` document listener entirely
- Replaced with invisible backdrop pattern (`fixed inset-0 z-40` div behind dropdown)
- Dropdown at `z-50` — clicks on menu items hit menu, clicks elsewhere hit backdrop
- Sign Out uses plain `onClick` → `logout()` + `window.location.href = '/login'` (hard redirect)
- Removed `useNavigate` dependency — hard redirect avoids React lifecycle race conditions
- Removed `useRef` for menuRef — no longer needed

**Status**: Code written and built (`npm run build` ran successfully). User needs to restart server or hard-refresh (Cmd+Shift+R) to test. Previous attempts failed because browser was serving cached old JS bundle.

**If sign-out still fails after rebuild**: The `handleLogout` function is simple (`logout()` clears localStorage + Zustand, then `window.location.href` does hard redirect). If it still doesn't work, add `console.log('handleLogout called')` at the top of the function to verify it fires.

### Other fix applied this session
- Auto-generated JWT secret in `src/duh/config/loader.py:141-149` — generates `secrets.token_hex(32)` when no JWT secret configured, checks `DUH_JWT_SECRET` env var first. Note: tokens won't survive server restarts with auto-generated secret.
### .env improvements
- Added provider API key placeholders to `.env.example` (ANTHROPIC, OPENAI, GOOGLE, PERPLEXITY, MISTRAL)
- Updated README quick start with all provider env vars + `.env` reference
- Note: Google key env var is `GOOGLE_API_KEY` (not `GEMINI_API_KEY`)

### Test Results
- 1586 Python tests + 185 Vitest tests (1771 total)
- 1603 Python tests + 185 Vitest tests (1788 total)
- Build clean, all tests pass

---

## Current State

- **Branch `ux-cleanup`** — v0.6.0 features complete, sign-out fix pending user verification
- **1586 Python tests + 185 Vitest tests** (1771 total)
- All previous features intact (v0.1–v0.5 + export + epistemic confidence + consensus nav)
- **Branch `ux-cleanup`** — z-index fix, GPT-5.4, .env docs
- **1603 Python tests + 185 Vitest tests** (1788 total)
- All previous features intact (v0.1–v0.6)

## Open Questions (Still Unresolved)

Expand Down
5 changes: 5 additions & 0 deletions memory-bank/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,8 @@ Phase 0 benchmark framework — fully functional, pilot-tested on 5 questions.
| 2026-02-19 | v0.6.0 — "It's Honest" | **Complete** |
| 2026-02-20 | Fix sign-out bug: replaced broken mousedown outside-click handler with backdrop pattern in TopBar | Pending verification |
| 2026-02-20 | Auto-generate JWT secret in config loader for dev environments | Done |
| 2026-03-07 | Password reset + .env support + TopBar z-index fix | Done |
| 2026-03-07 | Z-index stacking context fix: tokens, isolate, click-outside pattern | Done |
| 2026-03-07 | GPT-5.4 added to model catalog (1M ctx, $2.50/$15.00, no-temperature) | Done |
| 2026-03-07 | .env.example updated with provider API key placeholders | Done |
| 2026-03-07 | README updated with all provider env vars | Done |
10 changes: 10 additions & 0 deletions memory-bank/tasks/2026-03/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@
- Stable JWT secret in `.env` for session persistence across server restarts
- Files: `mail.py`, `auth.py`, `schema.py`, `loader.py`, `LoginPage.tsx`, `ResetPasswordPage.tsx`, `TopBar.tsx`
- See: [070307_password-reset.md](./070307_password-reset.md)

## 2026-03-07: Z-index Fix + GPT-5.4 + .env Docs
- Fixed z-index stacking contexts trapping dropdowns (Shell z-10, TopBar z-20 removed)
- Added CSS z-index tokens (`--z-background`, `--z-dropdown`, `--z-overlay`, `--z-modal`)
- Added `isolate` to Shell root, replaced backdrop hack with click-outside pattern in TopBar
- Added GPT-5.4 to model catalog (1M context, $2.50/$15.00, no-temperature)
- Updated `.env.example` with provider API key placeholders
- Updated README quick start with all provider env vars
- Files: `duh-theme.css`, `Shell.tsx`, `TopBar.tsx`, `GridOverlay.tsx`, `ParticleField.tsx`, `ExportMenu.tsx`, `ConsensusComplete.tsx`, `ThreadDetail.tsx`, `catalog.py`, `.env.example`, `README.md`
- 1603 Python + 185 Vitest tests passing
40 changes: 35 additions & 5 deletions src/duh/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING

from fastapi import FastAPI
from fastapi import APIRouter, FastAPI

if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager as AsyncContextManager

from duh.config.schema import DuhConfig

# Extra lifespans keyed by config id — avoids closure issues with lifespan()
_extra_lifespans: dict[int, Callable[[FastAPI], AsyncContextManager[None]] | None] = {}


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
Expand All @@ -26,25 +30,46 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.engine = engine
app.state.provider_manager = pm

yield
extra = getattr(app.state, "extra_lifespan", None)
if extra is not None:
async with extra(app):
yield
else:
yield

await engine.dispose()


def create_app(config: DuhConfig | None = None) -> FastAPI:
"""Create and configure the FastAPI application."""
def create_app(
config: DuhConfig | None = None,
extra_routers: list[APIRouter] | None = None,
extra_lifespan: Callable[[FastAPI], AsyncContextManager[None]] | None = None,
) -> FastAPI:
"""Create and configure the FastAPI application.

Args:
config: Application configuration. Loaded from defaults if None.
extra_routers: Additional routers registered after built-in routes
but before the frontend static file mount.
extra_lifespan: Additional async context manager composed with the
built-in lifespan. Runs after DB/provider setup.
"""
from duh.config.loader import load_config

if config is None:
config = load_config()

# Store extra_lifespan on module level so the lifespan function can use it
_extra_lifespans[id(config)] = extra_lifespan

app = FastAPI(
title="duh",
description="Multi-model consensus engine API",
version="0.6.0",
lifespan=lifespan,
)
app.state.config = config
app.state.extra_lifespan = extra_lifespan

# ── Middleware (Starlette runs in reverse order of addition) ──
from fastapi.middleware.cors import CORSMiddleware
Expand Down Expand Up @@ -89,6 +114,11 @@ def create_app(config: DuhConfig | None = None) -> FastAPI:
app.include_router(health_router)
app.include_router(metrics_router)

# Extra routers from external packages
if extra_routers:
for r in extra_routers:
app.include_router(r)

# ── Static file serving for web UI ──
_mount_frontend(app)

Expand Down
53 changes: 52 additions & 1 deletion src/duh/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,11 @@ async def login(body: LoginRequest, request: Request) -> TokenResponse:
result = await session.execute(stmt)
user = result.scalar_one_or_none()

if user is None or not verify_password(body.password, user.password_hash):
if (
user is None
or user.password_hash is None
or not verify_password(body.password, user.password_hash)
):
raise HTTPException(status_code=401, detail="Invalid credentials")

if not user.is_active:
Expand All @@ -181,6 +185,53 @@ async def login(body: LoginRequest, request: Request) -> TokenResponse:
return TokenResponse(access_token=token, user_id=user.id, role=user.role)


class GuestRequest(BaseModel):
email: str


@router.post("/guest", response_model=TokenResponse)
async def guest_login(body: GuestRequest, request: Request) -> TokenResponse:
"""Create or retrieve a guest user (email-only, no password)."""
config = request.app.state.config
if not config.auth.jwt_secret:
raise HTTPException(status_code=500, detail="JWT secret not configured")

from sqlalchemy import select

from duh.memory.models import User

db_factory = request.app.state.db_factory
async with db_factory() as session:
stmt = select(User).where(User.email == body.email)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()

if existing is not None:
if not existing.is_guest:
raise HTTPException(
status_code=409,
detail="Email registered as full account. Please log in.",
)
# Return token for existing guest
token = create_token(existing.id, config.auth.jwt_secret, expiry_hours=4)
return TokenResponse(
access_token=token, user_id=existing.id, role=existing.role
)

# Create new guest user
user = User(
email=body.email,
display_name=body.email.split("@")[0],
is_guest=True,
)
session.add(user)
await session.commit()
await session.refresh(user)

token = create_token(user.id, config.auth.jwt_secret, expiry_hours=4)
return TokenResponse(access_token=token, user_id=user.id, role=user.role)


class AuthStatusResponse(BaseModel):
auth_required: bool

Expand Down
1 change: 1 addition & 0 deletions src/duh/api/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class APIKeyMiddleware(BaseHTTPMiddleware):
"/api/metrics",
"/api/auth/register",
"/api/auth/login",
"/api/auth/guest",
"/api/auth/status",
"/docs",
"/openapi.json",
Expand Down
30 changes: 30 additions & 0 deletions src/duh/core/slugify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Slug generation for public thread URLs."""

from __future__ import annotations

import hashlib
import re


def generate_slug(question: str) -> str:
"""Generate a URL-friendly slug from a question string.

Lowercase, strip non-alphanumeric (keep spaces turned to hyphens),
truncate to 80 chars, append 6-char hash suffix for uniqueness.
"""
# Lowercase and replace whitespace with hyphens
slug = question.lower().strip()
slug = re.sub(r"\s+", "-", slug)
# Strip non-alphanumeric except hyphens
slug = re.sub(r"[^a-z0-9-]", "", slug)
# Collapse multiple hyphens
slug = re.sub(r"-+", "-", slug)
# Strip leading/trailing hyphens
slug = slug.strip("-")
# Truncate to 80 chars
slug = slug[:80]
# Strip trailing hyphen after truncation
slug = slug.rstrip("-")
# Append 6-char hash suffix
hash_suffix = hashlib.sha256(question.encode()).hexdigest()[:6]
return f"{slug}-{hash_suffix}" if slug else hash_suffix
45 changes: 37 additions & 8 deletions src/duh/memory/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,53 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine

logger = logging.getLogger(__name__)


async def _get_columns(conn: AsyncConnection, table: str) -> set[str]:
"""Get column names for a table via PRAGMA."""
rows = await conn.exec_driver_sql(f"PRAGMA table_info({table})")
return {row[1] for row in rows}


async def ensure_schema(engine: AsyncEngine) -> None:
"""Apply pending schema migrations.

Currently handles:
- Adding ``rigor`` column to ``decisions`` table (Phase A).
Handles:
- Adding ``rigor`` column to ``decisions`` table.
- Adding ``is_guest`` column to ``users`` table.
- Adding ``is_public`` and ``slug`` columns to ``threads`` table.
- Making ``password_hash`` nullable on ``users`` (SQLite: already nullable
if created with current models; this is a no-op safety check).
"""
async with engine.begin() as conn:
# Check if rigor column exists
rows = await conn.exec_driver_sql("PRAGMA table_info(decisions)")
columns = {row[1] for row in rows}

if "rigor" not in columns:
# ── decisions table ──
decision_cols = await _get_columns(conn, "decisions")
if "rigor" not in decision_cols:
logger.info("Adding 'rigor' column to decisions table")
await conn.exec_driver_sql(
"ALTER TABLE decisions ADD COLUMN rigor FLOAT DEFAULT 0.0"
)

# ── users table ──
user_cols = await _get_columns(conn, "users")
if "is_guest" not in user_cols:
logger.info("Adding 'is_guest' column to users table")
await conn.exec_driver_sql(
"ALTER TABLE users ADD COLUMN is_guest BOOLEAN DEFAULT 0"
)

# ── threads table ──
thread_cols = await _get_columns(conn, "threads")
if "is_public" not in thread_cols:
logger.info("Adding 'is_public' column to threads table")
await conn.exec_driver_sql(
"ALTER TABLE threads ADD COLUMN is_public BOOLEAN DEFAULT 0"
)
if "slug" not in thread_cols:
logger.info("Adding 'slug' column to threads table")
await conn.exec_driver_sql(
"ALTER TABLE threads ADD COLUMN slug VARCHAR(200) DEFAULT NULL"
)
Loading
Loading