marlin

AGENT.md

This is a fully vibe coded project with oversight, so this file reflects the current truth. When the humans request contradicts this file, clarify the intent to stray from the definition.

Context for Cursor and future agents working in this repo. Human operator guide: docs/GETTING_STARTED.md (entities, config, CLI, LM Studio, vLLM/Vast). Policy inventory: data/README.md. Schema: docs/MIGRATIONS.md. Do not duplicate those step-by-steps here; keep this file to facts that are expensive to infer.

What this is

Marlin is a personal, single-user search index aimed at tens of millions of domains. No auth, no multi-tenancy. Do not add userId or a domain-ignore table unless asked. Ignore is a boolean on categories and tags.

v1 discovery is a domain list file (ingest) plus link following after LM (outbound enqueue). An optional BFS spider can also expand from a few seeds into pending — not required for normal use. There is no IPv4/ICMP/TLS-SAN scanner.

Layout

Path Owns
apps/spider Ingest CLI (src/ingest.ts: domain list → pending) + optional BFS spider (src/index.ts: seed link-follow → pending). Spider is not the main crawl path — prefer ingest + fetcher; primary link growth is LM outbound enqueue after done
apps/fetcher High-concurrency homepage fetch → store extracted text + outbound hosts (no enqueue)
apps/worker Claim ready pages: near-empty / bot-check → empty (no LM), for-sale lander → parked (no LM), else one OpenAI-compatible LM call; src/probe.ts is the no-DB smoke test
apps/steward Spiral detector: SQL-nominate busy apexes → LM sample judge → auto-block in Postgres; same WORKER_PROFILE as catalog worker; policy from STEWARD_POLICY / --policy
apps/api Fastify /api/* search (incl. country/language, { hits, hasMore }), ignore toggles, /api/dashboard, /api/workers, /api/analyze/* catalog analysis + label merge + steward unblock
apps/web Vite + React search UI (query-string filters + load more), /dashboard, /workers, /analyze (overview / labels / platforms / steward), ignore modal
packages/db Drizzle schema, SQL migrations, pool, queries, analyze aggregates, label-merge lib, migrate/requeue/flush-queue/merge-labels CLIs
packages/shared Hostname normalize, TLD whitelist (data/tlds.txt), ICANN apex + subdomain cap, category/language crawl priority, fetch/extract, LLM JSON schema + catalog/steward policy loaders, geo normalize, pickSiteName, lm + worker profiles
data/ Forkable policy — see data/README.md
data/domains.sample.txt Tiny ingest file for test runs
data/seeds.makers.txt Maker / small-web seed hosts (ingest to bias discovery)
data/blocked-apex.txt Seed crawler-trap apex denylist (Forumotion, B2B mills) — bootstrapped into blocked_apexes
data/allowed-apex.txt UGC / platform apexes the steward must never auto-block
data/category-priority.txt Per-category crawl/LM queue weights (edit + restart fetcher/worker)
data/language-priority.txt Language demotion weights on outbound enqueue (en / mul / default)
data/tlds.txt English-oriented last-label TLD whitelist
data/lm-profiles.json Named LM connections (baseUrl / model / apiKey / timeoutMs). Used by probe/compare via --lm, and by worker profiles via lm key
data/worker-profiles.json Named worker bundles (lm key + concurrency). Selected by WORKER_PROFILE or npm run worker -- <name>
data/catalog-policies.json Catalog LM policy (prompt, textChars, sampling). Selected by CATALOG_POLICY or --policy on worker / probe / compare
data/steward-policies.json Steward spiral-judge policy (prompt, sampleSummaryChars, sampling). Selected by STEWARD_POLICY or --policy on steward
data/label-aliases.txt Tag/category spelling aliases — rewrite at completeDomain; CLI/UI merge for rows already in DB

packages/db is the only place schema/SQL should live. packages/shared is the only place hostname rules, crawl-priority weights, and the LLM schema should live — spider/fetcher/worker/steward must not fork copies. Catalog/steward prompts and sampling live in data/*-policies.json.

Runtime is TypeScript via tsx (dev and Docker). Workspace exports point at src/*.ts.

Invariants (do not “simplify” away)

Data flow

domains.txt  --ingest-->  pending (seed priority)
seeds        --spider-->  pending (seed / default by depth; no outbound dump)
pending      --fetcher--> fetching → fetch homepage → extract → page_* (body) + outbound_hosts → ready
ready        --lm worker--> summarizing → empty body / CF challenge → empty (no LM)
                          → for-sale lander → parked (no LM)
                          → else LM → done (page_* + outbound_hosts cleared)
                          → enqueue outbound hosts at category + language crawl priority
                          | failed (page_* + outbound_hosts kept)
UI search    --api-->     done rows, hide ignored category OR any ignored tag (unless that label is in the query)
Statuses: pending fetching ready summarizing done failed skipped.

Startup reclaim: fetcher maps fetching/processingpending. LM worker maps summarizingready.

domain_count on categories/tags is a counter cache incremented once in completeDomain when status is summarizingdone. Double-complete is a no-op (aborted: true) so counts cannot inflate from races. Re-cataloging already-done rows is unsupported in v1 (would need to decrement the old labels). npm run merge-labels -- --apply recalculates counts with COUNT(*) for merged labels only. Do not COUNT(*) 40M rows for the ignore modal.

Agent workflow (checks)

After changing code, check only the packages you touched — do not run the full monorepo by default.

npm run check -w @marlin/shared   # example: only shared
npm run check -w @marlin/db       # example: only db

Each workspace check runs typecheck + lint, and test only if that package has tests (today: shared, db, worker). Packages without tests omit test from their local check.

Touched several packages? Run each -w you changed (dependency order if unsure: shareddb → apps). Full tree (ordered):

npm run check

Root order: shared → db → fetcher → worker → spider → steward → api → web.

Workflows

Human setup/CLI: docs/GETTING_STARTED.md. Short agent reminders below.

Dev: Postgres via Compose (host 5433 → container 5432); apps on the host. npm run dev = api+web. LM Studio on host. Vite :5173/api:3000. Root .env via packages/db/src/env.ts.

Safe test order: LM Studio → npm run probe -- example.com --lm studio-g4-4b → migrate → ingest → npm run fetcher + npm run worker (two terminals) → UI → ignore modal → spider last.

Schema change: edit packages/db/src/schema.ts → new SQL in packages/db/migrations/npm run db:migrate. Compose migrate must stay a dependency of api/fetcher/worker/spider/steward.

Prod-ish: npm run docker:up (postgres+migrate+api+web). Crawl tools: npm run docker:up:tools. Dev Postgres only: npm run docker:db. npm run docker:down stops containers without deleting volumes.

Requeue: npm run requeue -- failed → rows with page_text become ready, others pending. No auto-retry loop.

Flush unfinished crawl: npm run flush-queue deletes pending/fetching/ready/summarizing/failed/skipped, keeps done. Full wipe: Compose down -v (see docs/MIGRATIONS.md).

LM / fetch pitfalls

Search pitfalls

Scale notes

~40M metadata × ~1KB plus trgm. Page text is a bounded staging buffer (FETCH_MAX_READY), not a permanent corpus. Unique host is the dedup key.

Explicit non-goals (v1)

IPv4/TLS scanning, user accounts, recrawl scheduler, robots.txt beyond UA+delay, storing HTML, Redis, multi-tenant ignore lists, cleaning near-duplicate categories.

Where to look