refactor: clean code review fixes across full repo

- Extract shared payload-guards (asRecord, asString, asNumber, asStringArray)
  into dashboard/src/utils/payload-guards.ts (DRY)
- Extract wrapWithMetrics() helper in src/metrics.ts to eliminate repetitive
  try/catch instrumentation blocks
- Add exponential backoff to useSSE reconnect (3s→6s→12s, capped at 30s,
  max 10 retries)
- Create centralized public/renderer/theme.js with named color and layout
  constants; update all 5 layer files to import from it
- Extract parsePositiveInt/parsePositiveFloat into shared src/parse-env.ts;
  remove duplicates from server.ts and token-budget.ts
- Deduplicate selectNextPrompt/selectNextSafePrompt via optional filter
  predicate parameter
- Migrate console.warn/error to structured logger in server.ts,
  orchestrator.ts, and broadcast/adapter.ts
- Rename bestOf→mostVotedChoice, input→promptConfig/rotationInput,
  stitched→recentTurnsSummary for intent-revealing names
- Add named constants for magic numbers (JITTER_RANGE, RECENCY_PENALTY,
  RECENT_TURNS_COUNT) and rationale comments for token budget defaults
- Add TODO(phase-8) header to OBS adapter stubs
- Document Object.create design choice in logger.ts
- Guard shake() timer leak in effects.js

All 158 tests passing, 0 type errors.
This commit is contained in:
2026-02-28 21:23:38 +00:00
parent c08d3ddcc1
commit 608e55264d
27 changed files with 607 additions and 321 deletions

View File

@@ -5,7 +5,8 @@ PORT=3000
# Set to 1 when behind one reverse proxy (Docker ingress, Nginx, Traefik, etc.)
TRUST_PROXY=
API_HOST_PORT=3001
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/juryrigged
POSTGRES_PASSWORD=change_this_now
DATABASE_URL=postgresql://postgres:change_this_now@localhost:5432/juryrigged
TTS_PROVIDER=noop
LOG_LEVEL=info
VERDICT_VOTE_WINDOW_MS=20000

View File

@@ -2,12 +2,19 @@
"scope": [
"src/**/*.ts",
"dashboard/src/**/*.{ts,tsx}",
"public/app.js"
"public/**/*.js",
"public/renderer/**/*.js"
],
"generatedAt": "2026-02-27",
"resolved": [
"generatedAt": "2025-01-28",
"summary": {
"high": 7,
"medium": 14,
"low": 6,
"total": 27
},
"issues": [
{
"id": "CC-003",
"id": "CC-001",
"summary": "public app stream handling now uses dispatch-map handlers"
},
{

View File

@@ -1,62 +1,252 @@
# Clean Code Review (Refreshed)
# Clean Code Review — JuryRigged (Full Repo)
Scope reviewed:
**Date**: 2025-01-28
**Scope**: All source files (`src/`, `dashboard/`, `public/`, config)
**Reviewer**: Automated (Clean Code dimensions)
**Conventions ref**: `docs/coding-conventions.md`
- `src/**/*.ts`
- `dashboard/src/**/*.{ts,tsx}`
- `public/app.js`
---
Generated: 2026-02-27
## Summary
## Resolved since previous pass
| Severity | Count |
|----------|-------|
| High | 7 |
| Medium | 14 |
| Low | 6 |
- `public/app.js` stream handling now uses an event dispatch map (previous branch-heavy `if` chain removed).
- `src/court/prompt-bank.ts` selection duplication consolidated via shared helpers; legacy `selectNextPrompt` is now deprecated.
- `src/moderation/vote-spam.ts` stale-entry cleanup duplication removed.
- `src/court/phases/session-flow.ts` now uses shared phase entry helper (`beginPhase`) and named timing constants.
- `src/server.ts` uses shared mutation error mapping and extracted route registration helpers.
- `src/store/session-store.ts` removed `tx:any`; transaction query casting is centralized in one helper (`withTxQuery`).
- `dashboard/` naming and timing-literal quick wins completed.
- `docs/coding-conventions.md` added and linked from `README.md`.
The codebase is well-structured overall: domain types are centralized, event flows are clearly separated, error handling at API boundaries is solid, and the coding conventions are largely followed. The main areas for improvement are **duplicated utility helpers in the dashboard**, **magic numbers in the renderer layer**, and a handful of **long functions** that would benefit from extraction.
## Remaining issues (priority ordered)
---
### 1) `createServerApp` still coordinates many responsibilities
## High Severity
### [Duplication]: `asRecord` / `asString` / `asNumber` duplicated across dashboard files
- **Principle**: DRY
- **Location**: `dashboard/src/App.tsx:10-22`, `dashboard/src/session-snapshot.ts:23-43`
- **Severity**: High
- **Issue**: Identical payload-narrowing helpers (`asRecord`, `asString`, `asNumber`, `asStringArray`) are copy-pasted in two files. Any behavioral change must be applied in both places.
- **Suggestion**: Extract into `dashboard/src/utils/payload-guards.ts` and import from both files.
### [Function Size]: `createDialogueStateMachine()` — ~230 lines
- **Principle**: Small Functions + SRP
- **Location**: `src/server.ts`
- **Severity**: Medium
- **Issue**: Startup wiring still owns env parsing, vote-spam lifecycle, route composition, static/SPA serving, and restart recovery orchestration.
- **Suggestion**: Extract `createRuntimeConfig(...)` and `resumeInterruptedSessions(...)` to reduce orchestration density in `createServerApp`.
- **Location**: `public/renderer/dialogue.js:40-270`
- **Severity**: High
- **Issue**: Single closure function managing typewriter state, punctuation timing, blip sounds, skip logic, and line advancement. Hard to test or modify individual behaviors.
- **Suggestion**: Extract `advanceLine()`, `computePunctuationDelay()`, and `handleSkip()` as standalone functions that the state machine delegates to.
### 2) `runWitnessExamPhase` still mixes orchestration and recap behavior
### [Function Size]: `initCharacters()` — ~270 lines
- **Principle**: Small Functions + SRP
- **Location**: `src/court/phases/session-flow.ts`
- **Location**: `public/renderer/characters.js:15-285`
- **Severity**: High
- **Issue**: Monolithic function creating all character slots, placeholder sprites, tint logic, and pose/face overlay handlers in one closure. Difficult to reason about individual slot behavior.
- **Suggestion**: Extract `createCharacterSlot()` to build one slot, then loop in `initCharacters()`. Extract `setPoseSprite` / `setFaceOverlay` as named helpers.
### [Function Size]: `instrumentCourtSessionStore()` — 87 lines
- **Principle**: Small Functions + SRP
- **Location**: `src/metrics.ts:202-289`
- **Severity**: High
- **Issue**: Factory returning a wrapper object where every method follows the same try/catch → record-error → rethrow pattern. The repetition obscures the actual instrumentation intent.
- **Suggestion**: Extract a generic `wrapWithMetrics(methodName, fn)` helper and use it for each method, reducing the function to a mapping.
### [Magic Numbers]: Renderer hex colors and proportional coordinates
- **Principle**: Avoid Hardcoding
- **Location**: `public/renderer/layers/background.js`, `characters.js`, `ui.js`, `effects.js`, `evidence.js`
- **Severity**: High
- **Issue**: 80+ unlabeled hex color values (`0xa08040`, `0x3b2f1e`, `0xff4444`, etc.) and proportional position constants (`0.5`, `0.12`, `0.85`, etc.) scattered throughout all renderer layer files. No palette or layout constants file exists.
- **Suggestion**: Create `public/renderer/theme.js` exporting named color constants (e.g., `ROLE_COLOR_JUDGE`, `BG_COURTROOM_DARK`) and layout proportions (e.g., `JUDGE_BENCH_Y`, `WITNESS_SLOT_X`).
### [Missing Backoff]: `useSSE` reconnect uses fixed 3 s interval
- **Principle**: Structural Clarity / Runtime Safety
- **Location**: `dashboard/src/hooks/useSSE.ts:46-50`
- **Severity**: High
- **Issue**: On SSE disconnect, the hook retries every 3 000 ms forever with no exponential backoff and no retry cap. Under sustained server downtime this can flood the server with connection attempts.
- **Suggestion**: Implement exponential backoff (e.g., 3 s → 6 s → 12 s, capped at 30 s) and a max-retry limit after which `error` state is set permanently.
### [Potential Resource Leak]: `effects.js` shake timer
- **Principle**: Runtime Safety
- **Location**: `public/renderer/effects.js``shake()` function
- **Severity**: High
- **Issue**: Calling `shake()` while a previous shake animation is still running starts a new `setInterval` without clearing the old one, leaking interval handles.
- **Suggestion**: Guard `shake()` entry by checking/clearing any active `shakeTimer` before creating a new interval.
---
## Medium Severity
### [Duplication]: `selectNextPrompt` / `selectNextSafePrompt` — 80 % identical
- **Principle**: DRY
- **Location**: `src/court/prompt-bank.ts:187-240`
- **Severity**: Medium
- **Issue**: Witness loop flow, pacing, recap generation, recap persistence, and recap TTS are all in one function.
- **Suggestion**: Extract `runWitnessCycle(...)` and `emitRecapIfDue(...)` helpers.
- **Issue**: Both functions differ only in a `.filter(p => p.active)` predicate. The rotation, hashing, and fallback logic is duplicated.
- **Suggestion**: Merge into a single function accepting an optional `filter` predicate; `selectNextSafePrompt` becomes `selectNextPrompt(history, { filter: p => p.active })`.
### 3) Catch-up character limit remains duplicated across backend/public
### [Duplication]: Typewriter logic duplicated between `public/app.js` and `dialogue.js`
- **Principle**: Avoid Hardcoding Drift
- **Location**: `src/court/catchup.ts`, `public/app.js`
- **Principle**: DRY
- **Location**: `public/app.js` (`startDialogueTypewriter`) and `public/renderer/dialogue.js`
- **Severity**: Medium
- **Issue**: The max-chars cap exists in two runtimes; both now use named constants but remain independently defined.
- **Suggestion**: Expose a shared runtime config endpoint or shared constant package consumed by both surfaces.
- **Issue**: Both files implement character-by-character text reveal with punctuation pauses and skip support. The `app.js` version is the legacy overlay; `dialogue.js` is the PixiJS version. Maintaining two diverges over time.
- **Suggestion**: Extract shared typewriter config (chars-per-second, punctuation delays) into `public/renderer/typewriter-config.js`. Long-term: remove `app.js` typewriter once PixiJS renderer is primary.
### 4) Deprecated API still present in prompt bank
### [Magic Numbers]: `speaker-selection.ts` weighting constants
- **Principle**: YAGNI / Surface Area Control
- **Location**: `src/court/prompt-bank.ts`
- **Principle**: Avoid Hardcoding
- **Location**: `src/court/speaker-selection.ts:26,41`
- **Severity**: Medium
- **Issue**: `Math.random() * 0.4 - 0.2` (jitter range) and `0.5` (recency penalty multiplier) are unlabeled.
- **Suggestion**: Extract as `const JITTER_RANGE = 0.4` / `const RECENCY_PENALTY = 0.5` with brief comment on tuning rationale.
### [Magic Numbers]: Token budget defaults
- **Principle**: Avoid Hardcoding
- **Location**: `src/court/token-budget.ts:22-29`
- **Severity**: Medium
- **Issue**: Role-specific token limits `260, 220, 160, 120` and `costPer1kTokensUsd: 0.002` are inline in the defaults object with no explanation of how values were chosen.
- **Suggestion**: Add a brief comment block above `DEFAULT_ROLE_TOKEN_BUDGETS` explaining the sizing rationale.
### [Magic Numbers]: `catchup.ts` — `DEFAULT_CASE_SO_FAR_MAX_CHARS = 220`, `turns.slice(-3)`
- **Principle**: Avoid Hardcoding
- **Location**: `src/court/catchup.ts:8,35`
- **Severity**: Medium
- **Issue**: `220` character limit and `-3` recent turns count are unlabeled. The `220` should document why this threshold.
- **Suggestion**: Rename `DEFAULT_CASE_SO_FAR_MAX_CHARS` → keep, add comment. Add `const RECENT_TURNS_COUNT = 3` for the slice.
### [Magic Numbers]: `dialogue.js` punctuation pause map
- **Principle**: Avoid Hardcoding
- **Location**: `public/renderer/dialogue.js:~10-20`
- **Severity**: Medium
- **Issue**: `180, 200, 200, 90, 110, 100, 260, 140` ms pause values for punctuation characters are inline with no comment.
- **Suggestion**: Group into a named `PUNCTUATION_PAUSES` constant object with a brief comment.
### [Naming]: `bestOf()` — non-intent-revealing
- **Principle**: Meaningful Names
- **Location**: `src/court/phases/session-flow.ts:79`
- **Severity**: Medium
- **Issue**: `bestOf` doesn't convey "most voted option". Reads like a comparison utility.
- **Suggestion**: Rename to `mostVotedChoice()` or `winningVoteOption()`.
### [Naming]: `input` parameter used generically in multiple functions
- **Principle**: Meaningful Names
- **Location**: `src/court/personas.ts:42`, `src/court/prompt-bank.ts:170,194,218`
- **Severity**: Medium
- **Issue**: Multiple functions use `input` as the sole parameter name for different shaped objects. In files with multiple such functions it reduces scanability.
- **Suggestion**: Use `promptConfig`, `rotationInput`, `selectionInput` respectively.
### [Naming]: `stitched` in `catchup.ts`
- **Principle**: Meaningful Names
- **Location**: `src/court/catchup.ts:36`
- **Severity**: Medium
- **Issue**: Variable name doesn't reveal intent.
- **Suggestion**: Rename to `recentTurnsSummary` or `turnExcerpt`.
### [Function Size]: `initCamera()` — ~120-line closure
- **Principle**: Small Functions + SRP
- **Location**: `public/renderer/camera.js`
- **Severity**: Medium
- **Issue**: Manages transition state, easing math, and animation frame loop in one closure.
- **Suggestion**: Extract `easeLerp()` and `animateTransition()` as standalone functions.
### [Function Size]: ModerationQueue `useEffect` — ~40 lines with nested loops
- **Principle**: Small Functions + SRP
- **Location**: `dashboard/src/components/ModerationQueue.tsx`
- **Severity**: Medium
- **Issue**: Event processing, deduplication, and queue mutation combined in a single effect callback.
- **Suggestion**: Extract `buildFlaggedItemsFromEvents(newEvents, existingIds): FlaggedItem[]` as a pure function.
### [Structural Clarity]: SessionMonitor inconsistent sessionId truncation
- **Principle**: Consistency
- **Location**: `dashboard/src/components/SessionMonitor.tsx` (`.slice(0, 16)`) vs `dashboard/src/App.tsx` (`.slice(0, 8)`)
- **Severity**: Medium
- **Issue**: Session ID display length differs between components without explanation.
- **Suggestion**: Extract `const SESSION_ID_DISPLAY_LENGTH` and use consistently.
### [Duplication]: `parsePositiveInt` / `parsePositiveFloat` in `token-budget.ts`
- **Principle**: DRY
- **Location**: `src/court/token-budget.ts:39-55`
- **Severity**: Medium
- **Issue**: 90 % identical functions differing only in `parseInt` vs `parseFloat`. Both also duplicate `parsePositiveInt` in `server.ts`.
- **Suggestion**: Merge into a shared utility or deduplicate via a generic `parsePositiveNumber`.
---
## Low Severity
### [Naming]: Renderer abbreviations (`camX`, `camY`, `camZoom`)
- **Principle**: Meaningful Names
- **Location**: `public/renderer/camera.js`
- **Severity**: Low
- **Issue**: `selectNextPrompt` is unused in current workspace and retained only for compatibility.
- **Suggestion**: Remove in a planned cleanup version after confirming no external consumers.
- **Issue**: Abbreviated coordinates. Acceptable in animation code but less self-documenting.
- **Suggestion**: Keep — standard convention in renderer/game code.
### 5) Transaction helper still requires one compatibility cast
### [YAGNI]: `OBSWebSocketAdapter` is entirely TODO stubs
- **Principle**: Type Safety Consistency
- **Location**: `src/store/session-store.ts`
- **Principle**: YAGNI
- **Location**: `src/broadcast/obs-adapter.ts`
- **Severity**: Low
- **Issue**: Postgres transaction callback typing still requires one internal cast in `withTxQuery`.
- **Suggestion**: Keep centralized as-is unless upstream typings or wrapper abstraction allow a fully call-signature-safe approach.
- **Issue**: Every method logs a stub message and does nothing. Kept as a placeholder.
- **Suggestion**: Acceptable if planned for near-term implementation. Consider marking with a `// TODO(phase-N)` header.
### [Naming]: `MockTTSAdapter` exported from production code
- **Principle**: Consistency
- **Location**: `src/tts/adapter.ts`
- **Severity**: Low
- **Issue**: Test-only adapter lives alongside production code without clear separation.
- **Suggestion**: Either prefix with `_` or move to `src/tts/__mocks__/adapter.ts`.
### [Structural Clarity]: `logger.ts` uses `Object.create()` for child loggers
- **Principle**: Readability First
- **Location**: `src/logger.ts:73-88`
- **Severity**: Low
- **Issue**: Prototype-based composition is unusual in TypeScript; class composition would be clearer.
- **Suggestion**: Acceptable if intentional. Add a brief comment explaining the design choice.
### [Convention]: `console.log` / `console.error` used in dashboard instead of structured logging
- **Principle**: Project Conventions
- **Location**: `dashboard/src/hooks/useSSE.ts:32,35`, dashboard components
- **Severity**: Low
- **Issue**: Dashboard uses raw `console.*` while backend uses structured logger. Acceptable for browser code.
- **Suggestion**: No action needed unless a browser-side logging wrapper is desired.
### [Convention]: `eslint-disable-next-line no-console` appears 10+ times in backend
- **Principle**: Project Conventions
- **Location**: `src/server.ts`, `src/court/orchestrator.ts`, `src/broadcast/adapter.ts`
- **Severity**: Low
- **Issue**: The structured `logger` exists but many places still use `console.*` with inline lint suppressions.
- **Suggestion**: Migrate remaining `console.warn` / `console.error` calls to the structured logger.
---
## What's Working Well
- **Types**: Centralized in `src/types.ts` with narrow union types for phases, roles, events. Dashboard mirrors via its own `types.ts`.
- **Event taxonomy**: Every court event has a typed payload interface in `src/events.ts`.
- **Error handling at API boundaries**: `sendError()`, `mapSessionMutationError()`, and custom error classes (`CourtValidationError`, `CourtNotFoundError`) provide clear error responses.
- **Moderation pipeline**: Input validation → content filter → spam guard → structured event emission.
- **Phase orchestration**: Clean separation into `phases/session-flow.ts` with named constants for durations and pauses.
- **Metrics instrumentation**: Prometheus counters/histograms properly wrap store operations.
- **Test coverage**: Behavior-focused tests for all major modules.
- **Configuration**: Environment-driven with sensible defaults and `parsePositiveInt` safety.
- **Coding conventions**: Documented and largely followed across the project.

View File

@@ -3,22 +3,10 @@ import { SessionMonitor } from './components/SessionMonitor';
import { useSSE } from './hooks/useSSE';
import { applyEventToSnapshot, mapSessionToSnapshot } from './session-snapshot';
import type { CourtEvent, SessionSnapshot } from './types';
import { asRecord, asString } from './utils/payload-guards';
type UnknownRecord = Record<string, unknown>;
const SESSION_DISCOVERY_INTERVAL_MS = 5_000;
function asRecord(value: unknown): UnknownRecord {
return (
typeof value === 'object' && value !== null && !Array.isArray(value)
) ?
(value as UnknownRecord)
: {};
}
function asString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function resolvePreferredSessionId(response: unknown): string | null {
const payload = asRecord(response);
const sessions =

View File

@@ -1,7 +1,9 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import type { CourtEvent, SSEMessage } from '../types';
const RECONNECT_DELAY_MS = 3000;
const RECONNECT_BASE_MS = 3_000;
const RECONNECT_MAX_MS = 30_000;
const MAX_RETRIES = 10;
export function useSSE(
sessionId: string | null,
@@ -12,6 +14,7 @@ export function useSSE(
const [error, setError] = useState<string | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const retriesRef = useRef(0);
const connect = useCallback(() => {
if (eventSourceRef.current || !sessionId) {
@@ -27,15 +30,14 @@ export function useSSE(
es.onopen = () => {
setConnected(true);
setError(null);
retriesRef.current = 0;
console.log('SSE connected');
};
es.onerror = err => {
console.error('SSE error:', err);
setConnected(false);
setError('Connection lost. Reconnecting...');
// Clean up and schedule reconnect
es.close();
eventSourceRef.current = null;
@@ -43,9 +45,21 @@ export function useSSE(
clearTimeout(reconnectTimeoutRef.current);
}
if (retriesRef.current >= MAX_RETRIES) {
setError('Connection lost. Max retries reached.');
return;
}
const delay = Math.min(
RECONNECT_BASE_MS * 2 ** retriesRef.current,
RECONNECT_MAX_MS,
);
retriesRef.current += 1;
setError(`Connection lost. Reconnecting in ${Math.round(delay / 1000)}s...`);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, RECONNECT_DELAY_MS);
}, delay);
};
es.onmessage = e => {

View File

@@ -4,50 +4,25 @@ import type {
TranscriptEntry,
VoteCount,
} from './types';
import {
asNumber,
asPositiveNumber,
asRecord,
asString,
asStringArray,
isRecord,
} from './utils/payload-guards';
import type { UnknownRecord } from './utils/payload-guards';
const DEFAULT_MAX_WITNESS_STATEMENTS = 3;
const DEFAULT_RECAP_INTERVAL = 2;
type UnknownRecord = Record<string, unknown>;
interface SessionSnapshotInput {
session: unknown;
turns?: unknown;
recapTurnIds?: unknown;
}
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asRecord(value: unknown): UnknownRecord {
return isRecord(value) ? value : {};
}
function asString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ?
value
: fallback;
}
function asPositiveNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ?
value
: fallback;
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === 'string');
}
function buildTranscript(
turnsInput: unknown,
recapTurnIds: Set<string>,

View File

@@ -0,0 +1,33 @@
export type UnknownRecord = Record<string, unknown>;
export function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function asRecord(value: unknown): UnknownRecord {
return isRecord(value) ? value : {};
}
export function asString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
export function asNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ?
value
: fallback;
}
export function asPositiveNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ?
value
: fallback;
}
export function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === 'string');
}

View File

@@ -5,7 +5,7 @@ services:
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_this_now}
POSTGRES_DB: juryrigged
volumes:
- postgres_data:/var/lib/postgresql/data
@@ -29,7 +29,7 @@ services:
environment:
PORT: 3001
TRUST_PROXY: ${TRUST_PROXY:-1}
DATABASE_URL: postgresql://postgres:postgres@db:5432/juryrigged
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-change_this_now}@db:5432/juryrigged
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-deepseek/deepseek-chat-v3-0324:free}
LLM_MOCK: ${LLM_MOCK:-false}

View File

@@ -7,12 +7,14 @@
*/
import { STAGE_WIDTH, STAGE_HEIGHT } from '../stage.js';
const BENCH_COLOR = 0x3b2f1e;
const PODIUM_COLOR = 0x2d2417;
const FLOOR_COLOR = 0x1a1428;
const WALL_COLOR = 0x0e1422;
const LINE_COLOR = 0x4a3f2e;
import {
BENCH_COLOR,
PODIUM_COLOR,
FLOOR_COLOR,
WALL_COLOR,
LINE_COLOR,
TEXT_PLACEHOLDER_DIM,
} from '../theme.js';
/**
* @param {import('../stage.js').RendererStage} stage
@@ -26,7 +28,7 @@ export function initBackground(stage) {
const label = new PIXI.Text({
text: 'COURTROOM BACKGROUND (placeholder)',
style: {
fill: 0x555566,
fill: TEXT_PLACEHOLDER_DIM,
fontSize: 11,
fontFamily: 'monospace',
},

View File

@@ -7,15 +7,30 @@
* rectangle is drawn; when a sprite texture is set, it replaces the rectangle.
*/
import {
ROLE_COLOR_JUDGE,
ROLE_COLOR_PROSECUTOR,
ROLE_COLOR_DEFENSE,
ROLE_COLOR_WITNESS,
ROLE_COLOR_BAILIFF,
ACTIVE_TINT,
DEFAULT_TINT,
ACTIVE_ALPHA,
INACTIVE_ALPHA,
TEXT_LABEL_DIM,
TEXT_NAME_LABEL,
PLACEHOLDER_OUTLINE,
} from '../theme.js';
/** Default layout positions (proportional to canvas size). */
const ROLE_POSITIONS = {
judge: { x: 0.5, y: 0.12, w: 0.1, h: 0.16, color: 0xa08040 },
prosecutor: { x: 0.85, y: 0.38, w: 0.1, h: 0.2, color: 0x7b4040 },
defense: { x: 0.13, y: 0.38, w: 0.1, h: 0.2, color: 0x405a7b },
witness_1: { x: 0.64, y: 0.28, w: 0.08, h: 0.14, color: 0x4f6f50 },
witness_2: { x: 0.56, y: 0.28, w: 0.08, h: 0.14, color: 0x4f6f50 },
witness_3: { x: 0.72, y: 0.28, w: 0.08, h: 0.14, color: 0x4f6f50 },
bailiff: { x: 0.36, y: 0.3, w: 0.07, h: 0.14, color: 0x555566 },
judge: { x: 0.5, y: 0.12, w: 0.1, h: 0.16, color: ROLE_COLOR_JUDGE },
prosecutor: { x: 0.85, y: 0.38, w: 0.1, h: 0.2, color: ROLE_COLOR_PROSECUTOR },
defense: { x: 0.13, y: 0.38, w: 0.1, h: 0.2, color: ROLE_COLOR_DEFENSE },
witness_1: { x: 0.64, y: 0.28, w: 0.08, h: 0.14, color: ROLE_COLOR_WITNESS },
witness_2: { x: 0.56, y: 0.28, w: 0.08, h: 0.14, color: ROLE_COLOR_WITNESS },
witness_3: { x: 0.72, y: 0.28, w: 0.08, h: 0.14, color: ROLE_COLOR_WITNESS },
bailiff: { x: 0.36, y: 0.3, w: 0.07, h: 0.14, color: ROLE_COLOR_BAILIFF },
};
/** Recognised pose keys — pose sprites are resolved via asset lookup. */
@@ -24,10 +39,6 @@ export const POSES = ['idle', 'talk', 'point', 'slam', 'think', 'shock'];
/** Recognised face overlay keys. */
export const FACE_OVERLAYS = ['neutral', 'angry', 'happy', 'surprised', 'sweating'];
const ACTIVE_TINT = 0xffdd44;
const INACTIVE_ALPHA = 0.55;
const ACTIVE_ALPHA = 1.0;
/**
* @param {import('../stage.js').RendererStage} stage
*/
@@ -77,7 +88,7 @@ export function initCharacters(stage) {
const label = new PIXI.Text({
text: role.replace(/_/g, ' ').toUpperCase(),
style: {
fill: 0xcccccc,
fill: TEXT_LABEL_DIM,
fontSize: 9,
fontFamily: 'monospace',
align: 'center',
@@ -89,7 +100,7 @@ export function initCharacters(stage) {
const nameLabel = new PIXI.Text({
text: '',
style: {
fill: 0xeeeeee,
fill: TEXT_NAME_LABEL,
fontSize: 10,
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: '600',
@@ -133,7 +144,7 @@ export function initCharacters(stage) {
gfx.beginFill(slot.color, 0.6);
gfx.drawRoundedRect(0, 0, sw, sh, 4);
gfx.endFill();
gfx.lineStyle(1, 0x888888, 0.3);
gfx.lineStyle(1, PLACEHOLDER_OUTLINE, 0.3);
gfx.drawRoundedRect(0, 0, sw, sh, 4);
gfx.lineStyle(0);
}
@@ -213,7 +224,7 @@ export function initCharacters(stage) {
* @param {number} color Hex tint (e.g. 0xff0000 for damage flash)
* @param {number} durationMs Flash duration in milliseconds
*/
function flashCharacter(role, color = 0xffffff, durationMs = 120) {
function flashCharacter(role, color = DEFAULT_TINT, durationMs = 120) {
const entry = slots[role];
if (!entry) return;
@@ -250,7 +261,7 @@ export function initCharacters(stage) {
if (isActive) {
entry.gfx.tint = ACTIVE_TINT;
} else {
entry.gfx.tint = 0xffffff;
entry.gfx.tint = DEFAULT_TINT;
}
const name = roleNames?.[role];

View File

@@ -6,6 +6,14 @@
* runs to completion inside the effects container then self-destructs.
*/
import {
FLASH_DEFAULT,
OBJECTION_COLOR,
HOLD_IT_COLOR,
TAKE_THAT_COLOR,
STAMP_STROKE_COLOR,
} from '../theme.js';
const SHAKE_INTENSITY_PX = 6;
const SHAKE_DURATION_MS = 300;
const FLASH_DURATION_MS = 150;
@@ -29,7 +37,7 @@ export function initEffects(stage) {
* @param {number} [opts.durationMs] Duration in ms
*/
function flash(opts = {}) {
const color = opts.color ?? 0xffffff;
const color = opts.color ?? FLASH_DEFAULT;
const alpha = opts.alpha ?? 0.6;
const duration = opts.durationMs ?? FLASH_DURATION_MS;
@@ -104,7 +112,7 @@ export function initEffects(stage) {
*/
function stamp(opts = {}) {
const text = opts.text ?? 'OBJECTION!';
const color = opts.color ?? 0xff4444;
const color = opts.color ?? OBJECTION_COLOR;
const fontSize = opts.fontSize ?? 48;
const displayMs = opts.displayMs ?? STAMP_DISPLAY_MS;
@@ -115,11 +123,11 @@ export function initEffects(stage) {
fontSize,
fontFamily: 'Impact, Arial Black, sans-serif',
fontWeight: '900',
stroke: 0x000000,
stroke: STAMP_STROKE_COLOR,
strokeThickness: 4,
align: 'center',
dropShadow: true,
dropShadowColor: 0x000000,
dropShadowColor: STAMP_STROKE_COLOR,
dropShadowDistance: 3,
},
});
@@ -187,25 +195,25 @@ export function initEffects(stage) {
* Convenience: composite "objection" cue (stamp + flash + shake).
*/
function objection() {
flash({ color: 0xff4444, alpha: 0.35 });
flash({ color: OBJECTION_COLOR, alpha: 0.35 });
shake({ intensity: 8, durationMs: 350 });
stamp({ text: 'OBJECTION!', color: 0xff4444 });
stamp({ text: 'OBJECTION!', color: OBJECTION_COLOR });
}
/**
* Convenience: "hold it" cue.
*/
function holdIt() {
flash({ color: 0x44aaff, alpha: 0.3 });
stamp({ text: 'HOLD IT!', color: 0x44aaff });
flash({ color: HOLD_IT_COLOR, alpha: 0.3 });
stamp({ text: 'HOLD IT!', color: HOLD_IT_COLOR });
}
/**
* Convenience: "take that" cue.
*/
function takeThat() {
flash({ color: 0x44ff88, alpha: 0.3 });
stamp({ text: 'TAKE THAT!', color: 0x44ff88 });
flash({ color: TAKE_THAT_COLOR, alpha: 0.3 });
stamp({ text: 'TAKE THAT!', color: TAKE_THAT_COLOR });
}
return { trigger, flash, shake, freeze, stamp, objection, holdIt, takeThat };

View File

@@ -6,13 +6,18 @@
* scales up, flashes, then settles back to the evidence tray.
*/
const EVIDENCE_TRAY_ALPHA = 0.85;
import {
EVIDENCE_CARD_BG,
EVIDENCE_CARD_BORDER,
EVIDENCE_CARD_ACTIVE_BORDER,
EVIDENCE_ID_TEXT,
EVIDENCE_CONTENT_TEXT,
EVIDENCE_TRAY_ALPHA,
} from '../theme.js';
const EVIDENCE_CARD_WIDTH = 140;
const EVIDENCE_CARD_HEIGHT = 80;
const EVIDENCE_CARD_GAP = 8;
const EVIDENCE_CARD_BG = 0x1e293b;
const EVIDENCE_CARD_BORDER = 0x475569;
const EVIDENCE_CARD_ACTIVE_BORDER = 0xfbbf24;
const PRESENT_ANIMATION_MS = 900;
/**
@@ -65,7 +70,7 @@ export function initEvidence(stage) {
const idLabel = new PIXI.Text({
text: `#${evidence.id}`,
style: {
fill: 0x94a3b8,
fill: EVIDENCE_ID_TEXT,
fontSize: 9,
fontFamily: 'monospace',
},
@@ -76,7 +81,7 @@ export function initEvidence(stage) {
const textLabel = new PIXI.Text({
text: evidence.text.length > 60 ? evidence.text.slice(0, 57) + '…' : evidence.text,
style: {
fill: 0xe2e8f0,
fill: EVIDENCE_CONTENT_TEXT,
fontSize: 10,
fontFamily: 'Inter, system-ui, sans-serif',
wordWrap: true,

View File

@@ -6,11 +6,18 @@
* they compose with the rest of the scene graph (camera, effects, etc.).
*/
const DIALOGUE_BOX_ALPHA = 0.82;
const DIALOGUE_BOX_COLOR = 0x0e1422;
const DIALOGUE_BOX_RADIUS = 8;
const NAMEPLATE_BG = 0x1a2540;
const NAMEPLATE_RADIUS = 6;
import {
DIALOGUE_BOX_ALPHA,
DIALOGUE_BOX_COLOR,
DIALOGUE_BOX_RADIUS,
NAMEPLATE_BG,
NAMEPLATE_RADIUS,
TEXT_SPEAKER,
TEXT_DIALOGUE,
TEXT_NAMEPLATE,
TEXT_PHASE_BADGE,
PHASE_BADGE_BG,
} from '../theme.js';
/**
* @param {import('../stage.js').RendererStage} stage
@@ -29,7 +36,7 @@ export function initUI(stage) {
const speakerText = new PIXI.Text({
text: '',
style: {
fill: 0xa5b4fc,
fill: TEXT_SPEAKER,
fontSize: 13,
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: '600',
@@ -41,7 +48,7 @@ export function initUI(stage) {
const dialogueText = new PIXI.Text({
text: '',
style: {
fill: 0xf8fafc,
fill: TEXT_DIALOGUE,
fontSize: 15,
fontFamily: 'Inter, system-ui, sans-serif',
wordWrap: true,
@@ -63,7 +70,7 @@ export function initUI(stage) {
const nameplateText = new PIXI.Text({
text: '',
style: {
fill: 0xd9e6ff,
fill: TEXT_NAMEPLATE,
fontSize: 12,
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: '600',
@@ -83,7 +90,7 @@ export function initUI(stage) {
const phaseText = new PIXI.Text({
text: 'phase: idle',
style: {
fill: 0x9da2b6,
fill: TEXT_PHASE_BADGE,
fontSize: 11,
fontFamily: 'monospace',
},
@@ -121,7 +128,7 @@ export function initUI(stage) {
const pbH = 22;
phaseContainer.position.set(w - pbW - padding, padding);
phaseBg.clear();
phaseBg.beginFill(0x14141d, 0.85);
phaseBg.beginFill(PHASE_BADGE_BG, 0.85);
phaseBg.drawRoundedRect(0, 0, pbW, pbH, 4);
phaseBg.endFill();
}

57
public/renderer/theme.js Normal file
View File

@@ -0,0 +1,57 @@
/**
* Centralized visual theme for the Ace Attorney-style renderer.
* Colors, UI text fills, and effect palettes shared across layers.
*/
// ── Courtroom furniture & environment ──
export const BENCH_COLOR = 0x3b2f1e;
export const PODIUM_COLOR = 0x2d2417;
export const FLOOR_COLOR = 0x1a1428;
export const WALL_COLOR = 0x0e1422;
export const LINE_COLOR = 0x4a3f2e;
// ── Character role placeholder colors ──
export const ROLE_COLOR_JUDGE = 0xa08040;
export const ROLE_COLOR_PROSECUTOR = 0x7b4040;
export const ROLE_COLOR_DEFENSE = 0x405a7b;
export const ROLE_COLOR_WITNESS = 0x4f6f50;
export const ROLE_COLOR_BAILIFF = 0x555566;
// ── Character highlighting ──
export const ACTIVE_TINT = 0xffdd44;
export const DEFAULT_TINT = 0xffffff;
export const ACTIVE_ALPHA = 1.0;
export const INACTIVE_ALPHA = 0.55;
// ── UI / dialogue box ──
export const DIALOGUE_BOX_COLOR = 0x0e1422;
export const DIALOGUE_BOX_ALPHA = 0.82;
export const DIALOGUE_BOX_RADIUS = 8;
export const NAMEPLATE_BG = 0x1a2540;
export const NAMEPLATE_RADIUS = 6;
// ── UI text fills ──
export const TEXT_SPEAKER = 0xa5b4fc;
export const TEXT_DIALOGUE = 0xf8fafc;
export const TEXT_NAMEPLATE = 0xd9e6ff;
export const TEXT_PHASE_BADGE = 0x9da2b6;
export const TEXT_LABEL_DIM = 0xcccccc;
export const TEXT_NAME_LABEL = 0xeeeeee;
export const TEXT_PLACEHOLDER_DIM = 0x555566;
export const PHASE_BADGE_BG = 0x14141d;
export const PLACEHOLDER_OUTLINE = 0x888888;
// ── Effect / interjection colors ──
export const FLASH_DEFAULT = 0xffffff;
export const OBJECTION_COLOR = 0xff4444;
export const HOLD_IT_COLOR = 0x44aaff;
export const TAKE_THAT_COLOR = 0x44ff88;
export const STAMP_STROKE_COLOR = 0x000000;
// ── Evidence card colors ──
export const EVIDENCE_CARD_BG = 0x1e293b;
export const EVIDENCE_CARD_BORDER = 0x475569;
export const EVIDENCE_CARD_ACTIVE_BORDER = 0xfbbf24;
export const EVIDENCE_ID_TEXT = 0x94a3b8;
export const EVIDENCE_CONTENT_TEXT = 0xe2e8f0;
export const EVIDENCE_TRAY_ALPHA = 0.85;

View File

@@ -6,6 +6,7 @@
*/
import type { CourtPhase } from '../types.js';
import { logger } from '../logger.js';
// ---------------------------------------------------------------------------
// Adapter Interface
@@ -105,14 +106,14 @@ export async function createBroadcastAdapterFromEnv(): Promise<BroadcastAdapter>
await import('./obs-adapter.js');
return new OBSWebSocketAdapter();
} catch (err) {
console.warn(
logger.warn(
`[broadcast] Failed to load OBS adapter: ${err}. Using noop.`,
);
return new NoopBroadcastAdapter();
}
default:
console.warn(
logger.warn(
`[broadcast] Unknown provider: ${provider}. Using noop.`,
);
return new NoopBroadcastAdapter();
@@ -163,7 +164,7 @@ export async function safeBroadcastHook(
const latencyMs = Date.now() - startTime;
const errorMsg = err instanceof Error ? err.message : String(err);
console.error(
logger.error(
`[broadcast] Hook failed: type=${hookType} latencyMs=${latencyMs} error=${errorMsg}`,
);

View File

@@ -1,6 +1,9 @@
/**
* OBS WebSocket adapter for broadcast automation.
*
* TODO(phase-8): Implement actual WebSocket connection using obs-websocket-js.
* All methods currently log intent but do not send real OBS commands.
*
* Connects to OBS Studio via WebSocket 5.x protocol.
* Requires obs-websocket-js package to be installed.
*

View File

@@ -1,7 +1,12 @@
import type { CourtPhase, CourtTurn } from '../types.js';
// Max characters for the catch-up summary — fits comfortably in a
// subtitle banner at standard stream resolution (960×540).
export const DEFAULT_CASE_SO_FAR_MAX_CHARS = 220;
// Number of recent turns to stitch when no recap is available
const RECENT_TURNS_COUNT = 3;
export interface CatchupView {
caseSoFar: string;
phaseLabel: string;
@@ -32,15 +37,15 @@ export function buildCaseSoFarSummary(
return normalize(latestRecap.dialogue, maxChars);
}
const recentTurns = turns.slice(-3);
const recentTurns = turns.slice(-RECENT_TURNS_COUNT);
if (recentTurns.length === 0) {
return 'The court has just opened. Waiting for opening statements.';
}
const stitched = recentTurns
const recentTurnsSummary = recentTurns
.map(turn => `${turn.speaker}: ${turn.dialogue}`)
.join(' · ');
return normalize(stitched, maxChars);
return normalize(recentTurnsSummary, maxChars);
}
export function juryStepFromPhase(phase: CourtPhase): string {

View File

@@ -1,5 +1,6 @@
import { AGENTS } from '../agents.js';
import { llmGenerate, sanitizeDialogue } from '../llm/client.js';
import { logger } from '../logger.js';
import { moderateContent } from '../moderation/content-filter.js';
import { createTTSAdapterFromEnv, type TTSAdapter } from '../tts/adapter.js';
import {
@@ -175,8 +176,7 @@ async function handleFlaggedModeration(input: {
moderationReasons: string[];
activeBroadcast?: BroadcastAdapter;
}): Promise<void> {
// eslint-disable-next-line no-console
console.warn(
logger.warn(
`[moderation] content flagged session=${input.session.id} speaker=${input.speaker} reasons=${input.moderationReasons.join(',')}`,
);

View File

@@ -63,7 +63,7 @@ function rolePrompt(role: CourtRole, genre?: GenreTag): string {
return COURT_ROLE_PROMPTS[role] ?? COURT_ROLE_PROMPTS.witness;
}
export function buildCourtSystemPrompt(input: {
export function buildCourtSystemPrompt(promptConfig: {
agentId: AgentId;
role: CourtRole;
topic: string;
@@ -72,7 +72,7 @@ export function buildCourtSystemPrompt(input: {
history: string;
genre?: GenreTag; // Phase 3: genre-specific prompt variations
}): string {
const { agentId, role, topic, caseType, phase, history, genre } = input;
const { agentId, role, topic, caseType, phase, history, genre } = promptConfig;
const agent = AGENTS[agentId];
const verdictLabels =

View File

@@ -76,7 +76,7 @@ export interface SessionRuntimeContext {
recapCadence: number;
}
function bestOf(votes: Record<string, number>, fallback: string): string {
function mostVotedChoice(votes: Record<string, number>, fallback: string): string {
const entries = Object.entries(votes);
if (entries.length === 0) return fallback;
@@ -476,11 +476,11 @@ export async function runFinalRulingPhase(
);
}
const winningVerdict = bestOf(
const winningVerdict = mostVotedChoice(
latest.metadata.verdictVotes,
verdictChoices[0],
);
const winningSentence = bestOf(
const winningSentence = mostVotedChoice(
latest.metadata.sentenceVotes,
latest.metadata.sentenceOptions[0],
);

View File

@@ -168,30 +168,30 @@ function getActivePromptCandidates(activeGenres?: GenreTag[]): PromptBankEntry[]
return candidates;
}
function selectWithGenreRotation(input: {
function selectWithGenreRotation(rotationInput: {
candidates: PromptBankEntry[];
genreHistory: GenreTag[];
minDistance: number;
depletedPoolWarning: string;
}): PromptBankEntry {
const recentGenres = new Set(
input.genreHistory.slice(-input.minDistance).filter(Boolean),
rotationInput.genreHistory.slice(-rotationInput.minDistance).filter(Boolean),
);
let availablePrompts = input.candidates.filter(
let availablePrompts = rotationInput.candidates.filter(
prompt => !recentGenres.has(prompt.genre),
);
if (availablePrompts.length === 0) {
console.warn(input.depletedPoolWarning);
availablePrompts = input.candidates;
console.warn(rotationInput.depletedPoolWarning);
availablePrompts = rotationInput.candidates;
}
const sortedPrompts = [...availablePrompts].sort((a, b) =>
a.id.localeCompare(b.id),
);
const seed = stableHash(
`${input.genreHistory.join('|')}|${sortedPrompts.map(prompt => prompt.id).join('|')}`,
`${rotationInput.genreHistory.join('|')}|${sortedPrompts.map(prompt => prompt.id).join('|')}`,
);
return sortedPrompts[seed % sortedPrompts.length];
@@ -201,12 +201,6 @@ function selectWithGenreRotation(input: {
* Selects the next prompt from the bank, avoiding genres that violate
* the minimum distance constraint.
*
* Algorithm:
* 1. Filter out inactive prompts
* 2. Identify genres that violate minDistance (appeared in last N sessions)
* 3. Select from remaining genres with equal probability
* 4. If all genres are exhausted (depleted pool), ignore distance constraint
*
* @param genreHistory - Array of recently used genres (most recent last)
* @param activeGenres - Optional filter to restrict to specific genres
* @param minDistance - Minimum sessions before genre can repeat (default: 2)
@@ -218,14 +212,7 @@ export function selectNextPrompt(
activeGenres?: GenreTag[],
minDistance: number = DEFAULT_ROTATION_CONFIG.minDistance,
): PromptBankEntry {
const candidates = getActivePromptCandidates(activeGenres);
return selectWithGenreRotation({
candidates,
genreHistory,
minDistance,
depletedPoolWarning: `[prompt-bank] All genres recently used (history=${genreHistory.join(',')}). Allowing any genre.`,
});
return selectNextSafePrompt(genreHistory, activeGenres, minDistance, () => true);
}
export interface PromptSafetyResult {
@@ -250,24 +237,29 @@ export function screenPromptForSession(
/**
* Selects the next safe prompt from the bank, avoiding unsafe prompts.
* Uses deterministic rotation and falls back to any safe prompt if needed.
*
* @param genreHistory - Array of recently used genres (most recent last)
* @param activeGenres - Optional filter to restrict to specific genres
* @param minDistance - Minimum sessions before genre can repeat
* @param filter - Optional predicate applied to candidates (default: safety screen)
*/
export function selectNextSafePrompt(
genreHistory: GenreTag[] = [],
activeGenres?: GenreTag[],
minDistance: number = DEFAULT_ROTATION_CONFIG.minDistance,
filter: (candidate: PromptBankEntry) => boolean = candidate =>
screenPromptForSession(candidate).allowed,
): PromptBankEntry {
const candidates = getActivePromptCandidates(activeGenres);
const safeCandidates = candidates.filter(
candidate => screenPromptForSession(candidate).allowed,
);
const filteredCandidates = candidates.filter(filter);
if (safeCandidates.length === 0) {
if (filteredCandidates.length === 0) {
throw new Error('No safe prompts available in the prompt bank.');
}
return selectWithGenreRotation({
candidates: safeCandidates,
candidates: filteredCandidates,
genreHistory,
minDistance,
depletedPoolWarning: `[prompt-bank] All safe genres recently used (history=${genreHistory.join(',')}). Allowing any safe genre.`,

View File

@@ -35,12 +35,17 @@ export function selectNextSpeaker(context: {
speakCounts[turn.speaker] = (speakCounts[turn.speaker] ?? 0) + 1;
}
// Tuning: how much having spoken recently reduces selection probability
const RECENCY_PENALTY_WEIGHT = 0.5;
// Tuning: random jitter range to prevent deterministic speaker ordering
const SELECTION_JITTER_RANGE = 0.4;
const weights = participants.map(agent => {
if (agent === lastSpeaker) return 0;
let weight = 1;
weight -= recencyPenalty(agent, speakCounts, history.length) * 0.5;
weight += Math.random() * 0.4 - 0.2;
weight -= recencyPenalty(agent, speakCounts, history.length) * RECENCY_PENALTY_WEIGHT;
weight += Math.random() * SELECTION_JITTER_RANGE - SELECTION_JITTER_RANGE / 2;
return Math.max(0, weight);
});

View File

@@ -1,4 +1,5 @@
import type { CourtRole } from '../types.js';
import { parsePositiveFloat, parsePositiveInt } from '../parse-env.js';
export interface RoleTokenBudgetConfig {
defaultMaxTokens: number;
@@ -17,6 +18,12 @@ export interface RoleTokenBudgetResolution {
source: 'env_role_cap' | 'requested';
}
// Token limits per role, sized for dialogue pacing:
// - Judge/attorneys at 220 to allow rulings and cross-examination depth
// - Witnesses at 160 to keep testimony punchy (3 sentences target)
// - Bailiff at 120 for short announcements
// - Default 260 used when no role-specific limit applies
// - Cost at $0.002/1k tokens based on OpenRouter DeepSeek pricing
const DEFAULT_ROLE_BUDGET_CONFIG: RoleTokenBudgetConfig = {
defaultMaxTokens: 260,
judgeMaxTokens: 220,
@@ -27,21 +34,6 @@ const DEFAULT_ROLE_BUDGET_CONFIG: RoleTokenBudgetConfig = {
costPer1kTokensUsd: 0.002,
};
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function parsePositiveFloat(
value: string | undefined,
fallback: number,
): number {
if (!value) return fallback;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function resolveRoleTokenBudgetConfig(
env: NodeJS.ProcessEnv = process.env,
): RoleTokenBudgetConfig {

View File

@@ -77,7 +77,9 @@ export class Logger {
this.write('error', message, context);
}
// Convenience method to create a child logger with base context
// Create a child logger that merges baseContext into every log entry.
// Uses Object.create for lightweight cloning — avoids a full class
// instantiation while letting setLevel() work independently on the child.
child(baseContext: LogContext): Logger {
const parent = this;
const childLogger = Object.create(Logger.prototype) as Logger;

View File

@@ -222,50 +222,53 @@ export function instrumentCourtSessionStore(
});
};
function wrapWithMetrics<Args extends unknown[], R>(
operation: string,
fn: (...args: Args) => Promise<R>,
onSuccess?: (result: R) => void,
): (...args: Args) => Promise<R> {
return async (...args: Args): Promise<R> => {
try {
const result = await fn(...args);
onSuccess?.(result);
return result;
} catch (error) {
recordStoreError(operation, error);
throw error;
}
};
}
scheduleSessionStatusSync();
return {
async createSession(input) {
try {
const session = await baseStore.createSession(input);
createSession: wrapWithMetrics(
'create_session',
input => baseStore.createSession(input),
() => {
sessionLifecycleTotal.inc({ event: 'created' });
scheduleSessionStatusSync();
return session;
} catch (error) {
recordStoreError('create_session', error);
throw error;
}
},
},
),
async listSessions() {
try {
return await baseStore.listSessions();
} catch (error) {
recordStoreError('list_sessions', error);
throw error;
}
},
listSessions: wrapWithMetrics(
'list_sessions',
() => baseStore.listSessions(),
),
async getSession(sessionId) {
try {
return await baseStore.getSession(sessionId);
} catch (error) {
recordStoreError('get_session', error);
throw error;
}
},
getSession: wrapWithMetrics(
'get_session',
sessionId => baseStore.getSession(sessionId),
),
async startSession(sessionId) {
try {
const session = await baseStore.startSession(sessionId);
startSession: wrapWithMetrics(
'start_session',
sessionId => baseStore.startSession(sessionId),
() => {
sessionLifecycleTotal.inc({ event: 'started' });
scheduleSessionStatusSync();
return session;
} catch (error) {
recordStoreError('start_session', error);
throw error;
}
},
},
),
async setPhase(sessionId, phase, phaseDurationMs) {
try {
@@ -285,76 +288,52 @@ export function instrumentCourtSessionStore(
}
},
async addTurn(input) {
try {
return await baseStore.addTurn(input);
} catch (error) {
recordStoreError('add_turn', error);
throw error;
}
},
addTurn: wrapWithMetrics(
'add_turn',
input => baseStore.addTurn(input),
),
async castVote(input) {
try {
return await baseStore.castVote(input);
} catch (error) {
recordStoreError('cast_vote', error);
throw error;
}
},
castVote: wrapWithMetrics(
'cast_vote',
input => baseStore.castVote(input),
),
async recordFinalRuling(input) {
try {
return await baseStore.recordFinalRuling(input);
} catch (error) {
recordStoreError('record_final_ruling', error);
throw error;
}
},
recordFinalRuling: wrapWithMetrics(
'record_final_ruling',
input => baseStore.recordFinalRuling(input),
),
async recordRecap(input) {
try {
await baseStore.recordRecap(input);
} catch (error) {
recordStoreError('record_recap', error);
throw error;
}
},
recordRecap: wrapWithMetrics(
'record_recap',
input => baseStore.recordRecap(input),
),
async completeSession(sessionId) {
try {
const session = await baseStore.completeSession(sessionId);
completeSession: wrapWithMetrics(
'complete_session',
sessionId => baseStore.completeSession(sessionId),
() => {
sessionLifecycleTotal.inc({ event: 'completed' });
scheduleSessionStatusSync();
return session;
} catch (error) {
recordStoreError('complete_session', error);
throw error;
}
},
},
),
async failSession(sessionId, reason) {
try {
const session = await baseStore.failSession(sessionId, reason);
failSession: wrapWithMetrics(
'fail_session',
(sessionId, reason) =>
baseStore.failSession(sessionId, reason),
() => {
sessionLifecycleTotal.inc({ event: 'failed' });
scheduleSessionStatusSync();
return session;
} catch (error) {
recordStoreError('fail_session', error);
throw error;
}
},
},
),
async recoverInterruptedSessions() {
try {
const ids = await baseStore.recoverInterruptedSessions();
recoverInterruptedSessions: wrapWithMetrics(
'recover_interrupted_sessions',
() => baseStore.recoverInterruptedSessions(),
() => {
scheduleSessionStatusSync();
return ids;
} catch (error) {
recordStoreError('recover_interrupted_sessions', error);
throw error;
}
},
},
),
subscribe(sessionId, handler) {
return baseStore.subscribe(sessionId, handler);

21
src/parse-env.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Shared helpers for parsing environment variable values to positive numbers.
*/
export function parsePositiveInt(
value: string | undefined,
fallback: number,
): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function parsePositiveFloat(
value: string | undefined,
fallback: number,
): number {
if (!value) return fallback;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

View File

@@ -11,6 +11,7 @@ import {
DEFAULT_ROTATION_CONFIG,
} from './court/prompt-bank.js';
import { moderateContent } from './moderation/content-filter.js';
import { parsePositiveInt } from './parse-env.js';
import {
CourtNotFoundError,
CourtValidationError,
@@ -29,6 +30,7 @@ import {
recordVoteRejected,
renderMetrics,
} from './metrics.js';
import { logger } from './logger.js';
import {
createSyntheticEvent,
loadReplayRecording,
@@ -105,11 +107,6 @@ function mapSessionMutationError(input: {
};
}
function parsePositiveInt(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export interface ReplayRuntimeOptions {
filePath: string;
speed?: number;
@@ -226,10 +223,9 @@ function createSessionHandler(deps: SessionRouteDeps) {
try {
selectedPrompt = selectNextSafePrompt(genreHistory);
} catch (error) {
// eslint-disable-next-line no-console
console.error(
logger.error(
'[server] selectNextSafePrompt failed:',
error instanceof Error ? error.message : error,
{ error: error instanceof Error ? error.message : error },
);
return sendError(
res,
@@ -349,8 +345,7 @@ function createSessionHandler(deps: SessionRouteDeps) {
],
});
} catch (error) {
// eslint-disable-next-line no-console
console.warn(
logger.warn(
`[replay] failed to start recorder for session=${session.id}: ${error instanceof Error ? error.message : String(error)}`,
);
}
@@ -412,8 +407,7 @@ function createVoteHandler(
if (!spamDecision.allowed) {
const spamReason = spamDecision.reason ?? 'unknown';
recordVoteRejected(voteType, spamReason);
// eslint-disable-next-line no-console
console.warn(
logger.warn(
`[vote-spam] blocked ip=${clientIp} session=${req.params.id} reason=${spamReason}`,
);
store.emitEvent(req.params.id, 'vote_spam_blocked', {
@@ -662,10 +656,9 @@ function registerApiRoutes(
res.setHeader('Content-Type', metricsContentType);
res.status(200).send(metrics);
} catch (error) {
// eslint-disable-next-line no-console
console.error(
logger.error(
'[metrics] failed to render metrics:',
error instanceof Error ? error.message : error,
{ error: error instanceof Error ? error.message : error },
);
res.status(500).send('failed to render metrics');
}
@@ -870,8 +863,7 @@ export async function createServerApp(
try {
await recorder.start({ sessionId });
} catch (error) {
// eslint-disable-next-line no-console
console.warn(
logger.warn(
`[replay] failed to start recorder for recovered session=${sessionId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
@@ -898,13 +890,10 @@ export async function bootstrap(): Promise<void> {
const port = Number.parseInt(process.env.PORT ?? '3000', 10);
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`JuryRigged running on http://localhost:${port}`);
// eslint-disable-next-line no-console
console.log(`Operator Dashboard: http://localhost:${port}/operator`);
logger.info(`JuryRigged running on http://localhost:${port}`);
logger.info(`Operator Dashboard: http://localhost:${port}/operator`);
if (replayLaunch) {
// eslint-disable-next-line no-console
console.log(
logger.info(
`[replay] enabled file=${replayLaunch.filePath} speed=${replayLaunch.speed}x`,
);
}
@@ -919,8 +908,7 @@ const isMainModule = (() => {
if (isMainModule) {
bootstrap().catch(error => {
// eslint-disable-next-line no-console
console.error(error);
logger.error('Bootstrap failed', { error });
process.exit(1);
});
}