CLAUDE.md #
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is #
Stargazer is a text search over non-Bluesky ATProto records. It tails the Bluesky Jetstream firehose, drops anything that matches an NSID blocklist (by default app.bsky.*, chat.bsky.*, etc.), extracts prose from the remaining lexicons, and indexes it into Meilisearch.
Commands #
Dev shell (provides Go toolchain, Meilisearch, process-compose, delve, golangci-lint):
nix develop
Run the whole stack (Meilisearch + ingester):
process-compose up
Run pieces individually:
meilisearch --db-path ./.state/meili --http-addr 127.0.0.1:7700
go run ./cmd/ingester [-config config.yaml]
go run ./cmd/search [-config config.yaml] [-collection <nsid|prefix*>] [-limit N] <query...>
Build / lint / test:
go build ./...
go vet ./...
golangci-lint run
go test ./...
go test ./internal/classify -run TestX # single test
State lives in ./.state/ (Meili DB + cursor). Deleting ./.state/cursor starts from now (or the beginning of the sealed archive if jetstream.api_key is set).
Configuration #
config.yaml is non-secret. Secrets come from env and override the file:
JETSTREAM_API_KEY— presence flips the ingester from live-only tail to archive-replay + live (see below).MEILI_MASTER_KEY— Meili auth.
Architecture #
Two binaries share internal/:
cmd/ingester— long-running firehose consumer.cmd/search— one-shot CLI that queries Meili directly (no server component).
Ingester pipeline, per batch from jetstream.Client.Events:
- Skip non-commit events.
- On delete: queue the doc id for removal.
- On create/update:
classify.Classifier.IsBlocked(nsid)drops blocked NSIDs early (no decode cost beyond what Jetstream already did). classify.Classifier.Extractwalks the decoded record and concatenates every string field whose key is inprose_fields(matched at any depth, min rune length applied). Also pullscreatedAtandlangs[0].- Records with empty extracted text are counted as
notextand skipped. - Surviving docs are pushed to Meili in one
AddDocumentscall, then deletes, then the cursor is saved.
Key design points to preserve when editing:
- Full firehose, client-side filter. The ingester intentionally does not pass
WithCollectionsto Jetstream. It eats full bandwidth (Bluesky-native traffic dominates) in exchange for automatic discovery of new lexicons and a simpler model. If you narrow the subscription, update the comment incmd/ingester/main.goand the blocklist notes inconfig.yaml. - Two subscribe modes, chosen by API key presence. With key →
WithAPIKey + WithAfterSeq(start)(exclusive; clean resume; cold start replays the sealed archive). Without →WithLiveCursor(start)(server's live buffer;0== "from now"). Don't collapse these paths. - Cursor is written after Meili accepts the batch. If
AddDocumentsfails, the loopcontinues without saving the cursor, so the failed range is replayed on the next connect. Preserve this ordering. - Meili primary key sanitisation.
docIDincmd/ingester/main.gomapsdid/collection/rkey→[A-Za-z0-9_-]by replacing:,/,.with_. The full AT-URI is stored separately asuri. Don't change the id scheme without a full reindex. - Meili schema is declared once in
internal/meili/meili.goEnsure. Searchable:text. Filterable:collection,did,lang,createdAt. Sortable:createdAt,indexedAt. Any new field the search CLI wants to filter/sort on must be added here. - Blocklist wildcards are prefix-only and require the trailing
.(app.bsky.*→ prefixapp.bsky.), soapp.bskyishis not blocked. Seeclassify.New. - Stats invariant:
seen == blocked + indexed + notext. The 30 s summary in the ingester relies on this; keep the four increments mutually exclusive per event.