Imported from Yjason-K/Damwha (
be/AGENTS.md). Install upstream withnpx skills add Yjason-K/Damwha --skill be. Copyright stays with the author.
AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in be/,
the damwha-be package of the Damwha monorepo. The frontend lives at fe/ in the
same repo; see fe/CLAUDE.md. Codex reads AGENTS.md from the repo root, so this
file is reached from the root AGENTS.md pointer.
What this is
Damwha is a personal, self-hosted meeting recording/search platform. The primary object is the utterance — every spoken line is attributed to a speaker, timestamped, and traceable back to the original audio. This repo is the backend.
The full design and the executable plans live in docs/superpowers/:
- Spec (NestJS API):
docs/superpowers/specs/2026-06-22-damwha-ingestion-backend-design.md - Plan 1 (
src/, NestJS API):docs/superpowers/plans/2026-06-22-damwha-ingestion-api.md - Spec (Python worker):
docs/superpowers/specs/2026-06-23-damwha-ml-worker-design.md - Plan 2 (
worker/, Python ML worker):docs/superpowers/plans/2026-06-23-damwha-ml-worker.md
Read the spec before changing data model / pipeline semantics — many decisions there are deliberate (privacy: local-only; non-goals). Specs/plans are dated snapshots and are not edited after the fact; record implementation deltas in living docs (this file, docs/README.md, worker/SMOKE.md).
Architecture: two runtimes joined by one table
The system is a polyglot split that communicates only through the Postgres job table — never HTTP between them:
- NestJS API (
src/, TypeScript) — HTTP only, knows nothing about ML. Stores audio, CRUDs metadata, enqueues jobs, serves status/results. - Python ML worker (
worker/, Python) — pollsjob, runs ffmpeg normalize+probe → VAD → diarization → speaker ID → STT → align, writesutterance/meeting_cluster/voiceprintrows. No HTTP. Implemented (Plan 2); see the worker section below.
The job table is the contract. The TypeScript side validates job payloads with zod (src/contracts/job-payload.schema.ts); the Python worker mirrors the same shape with pydantic (worker/damwha_worker/contracts.py), and the same JSON fixtures (test/fixtures/job-payloads/) are validated on both sides to block drift. The payload carries a top-level schema_version (currently 1; both sides default missing → 1). Changing the payload shape or the stage/status enums means changing both sides — treat src/contracts/, worker/damwha_worker/contracts.py, and the 001_init.sql CHECK constraints as the source of truth.
There is no ORM. All DB access is raw SQL through DatabaseService (pg.Pool wrapper with query + withTransaction). This is intentional — SELECT ... FOR UPDATE SKIP LOCKED and pgvector types don't fit ORMs cleanly. Each domain has a thin *.repository.ts (SQL), a *.service.ts (transactions/orchestration), and a *.controller.ts (HTTP).
Non-obvious invariants (read before editing these areas)
These cross-file rules are easy to break and are enforced by tests:
- Job queue (
src/jobs/):attemptsis incremented at claim time (queued→running), not on retry — so a crashed worker still counts as one attempt.claimmust not setstage(the worker sets stage per type as it enters each step).complete()/fail()update thejobrow only — on normal completion the linkedmeeting.status/speaker.enrollment_statustransitions are the worker's responsibility (Plan 2, in the same persist/enroll transaction). The reaper (reaper.service.ts, every 5 min) is the only place that propagates status on the crash path: stalerunningjobs (locked_atolder thanREAPER_STALE_MINUTES) are requeued if attempts remain, else failed + linked meeting/speaker marked failed. - Reprocess + stale guard: reprocess bumps
meeting.processing_versionand enqueues a new job (it does not wrap ML in a DB transaction). The worker'spersiststep must only write results whenmeeting.processing_version = payload.processing_version AND meeting.current_job_id = job.id— otherwise a stale lower-version job would overwrite newer results. Phase 1 reprocess is overwrite, no history;processing_version/job_idstamps on rows exist to support this guard and a future non-destructive merge. - Storage path safety (
src/storage/): the DB stores only relative keys (meetings/<meeting_id>/...). Never trust client filenames or store absolute paths. All key→path resolution goes throughStorageService.resolve(), which rejects traversal/absolute keys. Uploads use multer diskStorage (temp file) +saveFromTemp— never buffer large audio in memory. - Speaker identification (
voiceprint): pgvector columns are fixed-dimension (vector(192)). Identification must filter voiceprints by matchingmodel+dimension, and only compare against speakers withenrollment_status='ready'. Unidentified speakers are preserved asmeeting_clusterrows (rawdiar_label), never force-created asspeaker. - Env loading:
loadEnv()parses the full schema and requiresDATABASE_URL— only call it inside constructors/runtime, never in decorator/module metadata (it runs at import time before tests set env). Use the narrowmaxUploadBytes()helper in decorators instead.
Python worker (worker/)
Separate Python project under worker/ (uv + ruff + pytest + pydantic v2 + psycopg3, no ORM — same raw-SQL reasons as the API). It consumes the job contract and never imports the NestJS side. Key realities (mostly learned during Plan 2 and the real-model smoke):
- Models are an optional extra. Heavy/gated ML deps live in
[project.optional-dependencies] models(platform-marked:mlx-whisperon Apple Silicon,faster-whisperelsewhere), not base deps. The deterministic test suite never imports them (registry/adapters are imported only inside__main__.main()), so plainuv syncstays light; the real worker runsuv sync --extra models. - Ownership guards (the safety model). Every worker write to shared state is guarded; 0 affected rows = lost ownership → discard local result. Two distinct guards, both needed: job guard (
locked_by = worker AND status='running'— catches a same-job requeue+reclaim) and meeting guard (processing_version = payload_pv AND current_job_id = job.id— catches a newer reprocess).persistapplies both in one short TX → returnscommitted/discarded(stale: job markeddone+reason, meeting untouched) /lost. - Failure classification.
errors.ErrorKindis PERMANENT vs TRANSIENT (uncategorized → TRANSIENT). PERMANENT → fail immediately; TRANSIENT → immediate requeue if attempts remain (no timed backoff —jobhas nonext_attempt_at). Heartbeat runs on its own DB connection in a daemon thread and survives a transient DB error. - pyannote.audio resolves to 4.x (the spec named the 3.1 model; the library major bumped). 4.x renamed
use_auth_token→tokenand the pipeline returns aDiarizeOutput(use.speaker_diarization). The diarization pipeline pulls a 3-model gated HF chain — seeworker/SMOKE.md. ECAPA runs on CPU even on Apple Silicon (SpeechBrain MPS support is unreliable; the model is tiny); pyannote and mlx-whisper use the GPU. - Tests vs smoke. All deterministic glue (db guards, align, identify, persist, poll loop) is tested with fake models + real Postgres (testcontainers) and runs in CI. The real models are verified only by a local smoke (
worker/SMOKE.md,scripts/smoke_process_meeting.py) — gated/heavy, never in CI.
Commands
be/ is the damwha-be package of the Damwha monorepo. Node 22 is required
(.nvmrc, engines); pnpm is pinned to 10.26.0 by the root package.json.
Never run npm install here — it recreates a package-lock.json and a hoisted
node_modules that the workspace no longer uses.
pnpm install # FROM THE MONOREPO ROOT — installs be + fe
cp .env.example .env # configure DATABASE_URL, STORAGE_ROOT, model envs
pnpm migrate # apply SQL migrations (needs a running Postgres w/ pgvector)
pnpm start:dev # watch mode
pnpm build && pnpm start # prod (build copies migrations into dist/)
pnpm test # full suite, serial
pnpm exec jest test/meetings.e2e-spec.ts # one suite
pnpm exec jest test/jobs.repository.spec.ts -t "concurrent" # one test by name
pnpm exec tsc --noEmit -p tsconfig.build.json # type-check src without emitting
Python worker (worker/, Python 3.12 via uv):
cd worker
uv sync # deterministic deps only (CI/tests; no heavy models)
uv run pytest -q # full worker suite (testcontainers Postgres + fake models)
uv run ruff check . && uv run ruff format .
uv sync --extra models # real ML models (mlx-whisper/pyannote/ECAPA/silero)
uv run python scripts/download_models.py # pre-cache models (needs HF_TOKEN; see SMOKE.md)
uv run python -m damwha_worker # run the real worker (poll loop)
uv run python scripts/smoke_process_meeting.py <audio> # local end-to-end smoke
Tests require Docker. Integration/e2e tests use Testcontainers, which spins up a real damwha/postgres-bigm:pg16 Postgres per suite (see test/db.ts). Run with --runInBand (already in pnpm test) — parallel containers are heavy. No mocking of the DB; tests exercise real SQL including SKIP LOCKED, the reaper CTE, and pgvector.
Conventions
- Follow the plan doc's task structure and the existing per-domain repository/service/controller split when adding features.
- Migrations are plain SQL files in
src/database/migrations/applied in filename order bymigrate.ts(tracked in a_migrationstable). Add new numbered files; don't edit applied ones. - Enums are
text+CHECK(not native Postgres enums) so values can evolve; keep the zod/pydantic contracts and CHECK lists in sync. - Keep the API/worker split clean. The ML pipeline, ffmpeg audio-integrity validation, and worker-side status transitions live in the Python worker (
worker/), not the NestJSsrc/. Don't add ML or cloud calls tosrc/; both halves keep the privacy premise (local-only, no external network) intact.
Working guidelines (general)
Behavioral guidelines to reduce common LLM coding mistakes. Adapted from multica-ai/andrej-karpathy-skills. They bias toward caution over speed; for trivial tasks, use judgment.
1. Think before coding
Don't assume. Don't hide confusion. Surface tradeoffs. Before implementing: state assumptions explicitly (ask if uncertain); if multiple interpretations exist, present them rather than picking silently; if a simpler approach exists, say so and push back when warranted; if something is unclear, stop, name what's confusing, and ask.
2. Simplicity first
Minimum code that solves the problem. Nothing speculative. No features beyond what was asked; no abstractions for single-use code; no "flexibility"/"configurability" that wasn't requested; no error handling for impossible scenarios. If you write 200 lines and it could be 50, rewrite it. Test: "Would a senior engineer call this overcomplicated?"
3. Surgical changes
Touch only what you must. Clean up only your own mess. Don't "improve" adjacent code/comments/formatting; don't refactor what isn't broken; match existing style even if you'd do it differently; if you spot unrelated dead code, mention it — don't delete it. Remove imports/variables/functions that your changes orphaned, but leave pre-existing dead code unless asked. Every changed line should trace directly to the request.
4. Goal-driven execution
Define success criteria. Loop until verified. Turn tasks into verifiable goals ("Add validation" → "write tests for invalid inputs, then make them pass"; "Fix the bug" → "write a test that reproduces it, then make it pass"; "Refactor X" → "ensure tests pass before and after"). For multi-step work, state a brief plan with a verify check per step. Strong success criteria let you loop independently; weak ones ("make it work") force constant clarification.
These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites from overcomplication, and clarifying questions come before implementation rather than after mistakes.