commits
when the bot fails to DM a subscriber the raw error (e.g.
"FetchFailed: getConvo bad_request: NotFollowedBySender") meant
nothing to the user. now we map known bsky chat error codes to
friendly text with an action link:
- NotFollowedBySender / ActorNotMessageable →
"bsky blocked this DM — follow @pub-search.waow.tech or set your DM
preference to 'Everyone'" with a direct profile link
- RateLimit / TooManyRequests → "bsky rate-limited us, will retry"
- LoginFailed / BotNotConfigured → "server problem, not yours"
raw error still shown underneath in dim italic for debugging.
fallback: show raw for unknown codes.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
handleLogout bypassed sendJson (because it needs Set-Cookie to clear the
session cookie) and so returned only content-type. the browser then
blocked the response because credentialed cross-origin responses
require an explicit Access-Control-Allow-Origin (not '*'). the server
still cleared the session server-side, so on refresh the user appeared
"logged out" even though JS saw a NetworkError.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
my earlier edit to insert <div id="bot-notice"> into #pubs-section was
silently no-op'd (Edit tool reverted when the surrounding markup had
been re-formatted). renderBotNotice then exploded with
"can't access property 'className', el is null" — which stopped render()
before renderPubs ever ran, leaving the toggle list empty even when
subscriptions existed. adds the element back.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
debugging why the toggle list shows empty even after the devlog account
created a site.standard.graph.subscription on its PDS. logs:
- entry (did + resolved pds url)
- PDS response size + first 120 bytes
- record count on success path
- explicit logs for the "no records key" / "not an array" early returns
- json parse failure includes a body preview
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
previously /api/my-publications loaded the user's OWN
site.standard.publication records — backwards. the canonical signal
for "publications this user cares about" is the user's
site.standard.graph.subscription records (the same collection a
standard.site reader writes when you click "subscribe" on a publication).
now the toggle list shows publications you follow, enriched with
name + url from pub-search's local publications mirror when indexed.
if a followed publication isn't in the index, we still return the
at-uri so the UI can show it.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
three pieces that work together so the user knows when DMs won't land:
1. bot-follow banner — on login, check app.bsky.graph.getRelationships
against @pub-search.waow.tech. if the user doesn't follow the bot,
show a warning with a direct link. quiet on the happy path.
2. last_error persistence — new columns on the subscriptions table;
populated by the delivery worker on failure (and cleared on success).
surfaced in /api/subscriptions and rendered under each toggle.
3. bsky error extraction — bsky_bot now plucks the `error` field from
non-2xx chat responses (e.g. ActorNotMessageable) and exposes the
snippet via lastErrorSnippet(). notifications.deliver combines the
zig error name with this snippet into the persisted last_error.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
bsky chat convos require two distinct members — you can't DM yourself.
switching to a dedicated bot account that DMs subscribers.
changes:
- new src/bsky_bot.zig: app-password login + cached session + chat.bsky
sendMessage via atproto-proxy. self-healing on 401.
- notifications delivery now always DMs the subscription owner (the
subscriber), sent FROM the bot account
- oauth SCOPE drops transition:chat.bsky (not needed anymore — the bot
has its own bsky session via app password)
- subscriptions CRUD drops destinationKind/destinationValue from the
client-facing contract; lexicon makes them optional
- frontend no longer asks for recipient
fly secrets to set: BSKY_BOT_HANDLE (default pub-search.waow.tech),
BSKY_BOT_APP_PASSWORD (staged).
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
every path (enqueue, session resolve, getConvoForMembers, sendMessage)
gets an info or warn so we can see exactly where a DM fails silently.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
third-party cookies are dropped by Chrome/Safari on cross-eTLD+1 credentialed
fetches even with SameSite=None; Secure. move the backend hostname under the
same eTLD+1 as the frontend (waow.tech) — matches the plyr.fm / ken pattern.
- frontend (pub-search.waow.tech/subscriptions) and backend
(api.pub-search.waow.tech) share registrable domain waow.tech → same-site
for cookies
- cookie now SameSite=Lax (was None), which works for cross-subdomain
credentialed fetches within a single eTLD+1
- API_URL in site/subscriptions.html swapped to api.pub-search.waow.tech
next step: fly secrets OAUTH_CLIENT_ID + OAUTH_REDIRECT_URI already
staged to the new hostname; this deploy picks them up.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
so they show up in fly logs alongside http.request spans — debug level
is filtered out by the logfire zig module.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
- /api/auth-debug returns {hasCookieHeader, hasSessionCookie, sessionTokenPrefix, origin, didResolved, frontendOrigin} so we can see what the browser is sending cross-origin
- logfire.info on callback when session is stored + cookie set
- logfire.debug / warn in getSessionDid to distinguish between
"no cookie at all", "cookie but no pubsearch_session", and "token
present but no live session"
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
- debounced search against app.bsky.actor.searchActorsTypeahead
- arrow keys + enter + click + escape
- 16px font on the input (belt-and-suspenders — iOS won't auto-zoom)
- autocorrect=off, spellcheck=false so handles don't get mangled
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
new page at pub-search.waow.tech/subscriptions — sign in with atproto
oauth, see your site.standard.publication records, toggle on to get a
bsky DM whenever pub-search indexes a new doc under that publication.
architecture:
- oauth client uses zat (pattern lifted from ken); scope =
`atproto repo:tech.waow.pub-search.subscription transition:chat.bsky`
- subscription records live on the user's PDS as
tech.waow.pub-search.subscription — portable, inspectable
- local sqlite mirror (new `subscriptions` table) keyed by (owner, rkey)
so match at ingest time is an indexed lookup
- indexer.insertDocument gains a void-returning hook that queries
matching subs and enqueues deliveries on a bounded in-memory queue
(drops when full; never blocks the tap worker)
- a single worker thread drains the queue and sends via
chat.bsky.convo.sendMessage, proxied through the subscriber's PDS
notes:
- sessions are in-memory (ken pattern) so deliveries for users who
haven't signed in since the last backend restart are skipped —
tracked in the skipped_no_session counter
- frontend is served from CF Pages; cross-origin cookies use
SameSite=None; Secure with credentialed CORS back to the backend
- load-bearing paths (search, atlas, tap, reconciler, embedder, sync)
are not touched
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
publications were silently omitted from incrementalSync — they only
synced on fullSync (first boot), so every publication added after
the initial sync stayed invisible to the local replica forever.
turso had 4,476 publications, replica had 3,624.
root cause: publications table had no indexed_at column, so there
was no incremental cursor.
- schema: add publications.indexed_at + one-time backfill to now()
- indexer.insertPublication: stamp indexed_at via ON CONFLICT DO UPDATE
(was INSERT OR REPLACE with no timestamp)
- sync.incrementalSync: add publications fetch mirroring documents,
with its own labeled block so a failure falls through to tombstones
- sync_span gains new_pubs attribute alongside new_docs/deleted
- local replica schema + migration gain publications.indexed_at
- fullSync publications SELECT now includes indexed_at column
the schema backfill sets every existing publication's indexed_at to
the deploy timestamp, which means the first post-deploy incremental
sync pulls all 4,476 rows down in one shot (~1 round trip).
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
master tarball URL moved; CI was rejecting the old hash. new hash
matches what zig fetch resolves today.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
local replica silently desynced for 6 days; dashboard showed stale
13,990 while turso had 14,625. sync.zig only used std.debug.print
and zig 0.16 http.Client has no timeout, so a wedged turso call
killed sync forever with no alert.
- sync.incrementalSync now emits a logfire span per run with
new_docs/deleted/since attributes and recordError on failures
- db.zig syncLoop updates a heartbeat atomic before each attempt
- new watchdog thread aborts the process (→ fly restart) if the
heartbeat goes stale for >3x the sync interval
- also instrument the silent drop paths in indexer.zig
(content_hash_dupe, test_domain, bridgy_fed) as tap.dropped
spans so the drop-reason breakdown is queryable in logfire
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
0.998 was too sluggish, 0.9/1.1 was too jumpy. 0.995 is ~2.5x more
responsive than 0.998 while still scaling proportionally to deltaY.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Trackpads send many small-delta wheel events, so the fixed 0.9/1.1 factor
made zooming feel jumpy. Now uses Math.pow(0.998, dy) for smooth scaling.
Mouse wheel (deltaMode=1) still feels snappy via line-to-pixel conversion.
Mobile pinch zoom was already proportional — unchanged.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Thread io: std.Io through all modules instead of global compat bridge.
Replaces compat.Mutex/Condition with Io.Mutex/Io.Condition, compat.sleep*
with io.sleep(), compat.timestamp/microTimestamp with Io.Timestamp, and
inlines std.c.getenv at each call site.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- smp_allocator replaces GeneralPurposeAllocator (removed in 0.16)
- std.Io.Threaded backend for async I/O (networking, http)
- std.Io.net replaces std.net for TCP server
- thread-per-connection replaces Thread.Pool (removed)
- compat.zig wraps removed APIs: getenv, sleep, timestamp, Mutex
- C file I/O replaces std.fs (removed) in timing.zig, LocalDb.zig
- ArrayList .empty replaces .{} init syntax
- all deps updated to 0.16-compatible hashes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
prevents ~1s TLS handshake on first query after idle.
same pattern as existing tpuf keepalive.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- reduce radius multiplier from 0.5 to 0.35, cap at 28px
- extract DID from document metadata when turso lookup misses
- batch-fetch author avatars from bsky public API at build time
- atlas.js: prefer coverImage, fall back to avatar URL
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
build-atlas: query turso for publication names/covers, group docs by basePath,
compute centroids, output publications array in atlas.json.
atlas.js: render publication circles scaled by sqrt(count)*zoom (~25 at overview,
hundreds at deep zoom), lazy-load cover images from bsky CDN, fallback letter
circles, name labels at zoom 3+.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- idle-frame optimization: rAF loop stops when not animating (GPU sleeps when idle)
- cache colors per frame: theme read once, precomputed rgba lookup eliminates
getColors/isDark/hexToRgba from hot path
- extend nebula halos to all zoom levels: continuous alpha curve instead of
hard cutoff at zoom 4 (25% floor at zoom 10)
- smooth zoom transitions: labels cross-fade coarse↔fine↔titles, connections
fade in over zoom 2.5–3.5 instead of hard pop at zoom 3
- cache nebula halo sprites: pre-rendered offscreen canvases keyed by
(platform, radiusBucket), drawImage instead of createRadialGradient per frame
- reuse connection line buffers: pre-allocated Float32Arrays instead of 18
fresh arrays + thousands of push() calls per frame
- quantize sprite sizes: snap radius to 0.5px steps, reduces sprite rebuilds
during smooth zoom from ~60/s to ~4-5/s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
checked turso: only 20 leaflet docs have custom slugs, and most of
those slug URLs actually 404 on leaflet.pub (piffey, meri) while the
rkey URLs work fine. the ann.leaflet.pub case where rkey 404s and slug
works is the exception, not the rule. reverting to rkey-based URLs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
leaflet.pub posts with custom slugs (e.g. /run-claude-and-opencode-...)
were 404ing because we always built URLs with rkey. now prefer the path
field when available.
also adds: small scatter-dot icon on each search result that navigates
directly to that document on the atlas (by URI lookup, no API call),
touch-active feedback for mobile, and fixes atlas.js variable ordering
bug that crashed the page when ?q= was present.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- skip blento.app base_paths in URL construction (blento is a card
portal, not a document server) — affected docs fall through to
leaflet.pub/p/{did}/{rkey} or platform-specific fallbacks
- prefetch search API call in parallel with atlas.json when ?q= is
present, eliminating stacked latency on shared links
- add 3-minute tpuf keepalive ping to prevent cold start penalty
(~600-900ms extra on first query after inactivity)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace 25 uniformly scattered dots with ~190 gaussian-distributed
points mimicking the actual UMAP cluster shape. Bright stars with
box-shadow glow, gradient text overlay, outlier dots for depth.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- indexer: backfill base_path when publication changes (not just when empty)
- fixed 80+ docs with stale pckt.blog subdomains in Turso
- bottom bar: legend + stats stacked in flex container, no overlap
- interactive legend: click platform to filter/highlight those points
- label clipping: clamp labels to stay within viewport bounds
- tap targets: 44px min for theme toggle and nav links on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Crosshair replaced with amber reticle (outer ring, gapped crosshair
lines, center dot) — much more visible against both themes
- Search zoom now targets 4-15x (was 2-8x), fitting results in 30%
of viewport instead of 40%
- Label uses drawLabel with outline for readability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Search queries are reflected in the URL as ?q=term, so you can
share a link like /atlas?q=techno and the recipient sees the
same search results centered on the map.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Labels now check bounding box overlaps before rendering — dense
clusters show only the most important labels instead of stacking
- Coarse/fine labels sorted by cluster size so biggest clusters win
- Max zoom increased from 15x to 30x for exploring dense areas
- Legend and stats no longer overlap on mobile (stacked vertically)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
atproto.at 404s on all records. pdsls.dev actually renders record
content for live PDS records. Also adds leaflet.pub/p/{did}/{rkey}
fallback for 208 leaflet docs without basePath (matching backend logic).
Affects ~1,619 docs (13% of atlas) that had no clickable link.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- tap-to-select (shows tooltip), tap-again-to-open (navigates to doc)
- larger hit radius on touch devices (40px vs 20px)
- pinch zoom now zooms toward pinch midpoint instead of screen center
- scale labels down on narrow viewports (9/8/9px vs 12/11/11px)
- fewer labels and shorter titles on mobile to reduce clutter
- responsive CSS for header, legend, stats, search, tooltip
- tooltip anchored at top-center on mobile instead of following finger
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
90 documents (mostly koio.sh and blackskyweb.xyz) had broken/empty URLs
because their custom site.standard routing doesn't match basePath/rkey.
Falls back to the Taproot AT Protocol record viewer instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
min_cluster_size=100 with min_samples=5 collapsed to 2 clusters on
the current UMAP projection. Switch to min_cluster_size=50 with
min_samples=10 which produces ~64 coarse clusters with recognizable
topics.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pckt uses slug-based paths (e.g., /da-premier-post-9dmgs7z) not rkeys
in URLs. Include the path field from turbopuffer in atlas.json and
use it in atUriToUrl when available, matching the backend's
buildDocUrl logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename all files, routes, docs, and references from "constellation"
to "atlas" (constellation conflicts with an existing ATProto service).
Add search box to atlas page — type a query, it calls the semantic
search API, matches result URIs to points on the canvas, computes a
weighted centroid, and animates the view to center on the results
with matched points highlighted. Cmd+K to focus, Escape to clear.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- README: add constellation section with link and brief description
- docs/constellation.md: data pipeline, frontend, recomputation, future work
- docs/bridgy-fed.md: history of two failed attempts, why we exclude,
detection method, scripts, and what we'd need to reconsider
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bridgy fed content is now dropped at ingest, so the metric card,
stacked timeline bars, and special CSS were all showing zero. Remove
the dead code.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add OG meta tags to constellation.html and extend og-image.js to
generate a constellation-themed card with scattered platform-colored
dots, platform legend, and document count.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- cross-referenced turbopuffer with turso: 26,112 of 38,625 vectors
were orphans (deleted from turso but never cleaned from tpuf)
- rebuilt constellation: 12,513 points, 32 coarse / 161 fine clusters
- removed 100ms hover debounce — spatial index is O(1), no delay needed
- constellation.json down from 10.8MB to 3MB
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- labels now use fixed screen-size fonts (11-12px) instead of
shrinking with zoom — readable at all zoom levels
- replaced shadowBlur (gaussian blur per fillText = perf killer)
with strokeText outline — same readability, orders of magnitude cheaper
- connection lines batched into 3 opacity buckets with single
beginPath/stroke per bucket instead of per-line style changes
- inlined coordinate transform (cx/cy/scale cached per frame)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- all scripts now prioritize .env file over environment variables
(fixes stale env var overriding fresh .env keys)
- purged 4 remaining bridgy fed vectors from turbopuffer
- rebuilt constellation data (38,624 points, 81 coarse / 414 fine clusters)
- added post-particles-inspired connection lines between nearby points
when zoomed in (>= 3x), using spatial index for O(n) performance
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
python pipeline (scripts/build-constellation) exports vectors from
turbopuffer, projects to 2D via PCA+UMAP, clusters with HDBSCAN at
two granularities, and labels via c-TF-IDF on titles.
frontend (site/constellation.{html,css,js}) renders a full-viewport
dark canvas with celestial-style radial gradients per platform,
pan/zoom, semantic zoom labels, hover tooltips, and click-through.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tap keeps re-ingesting bridgy fed docs from the relay. Instead of
storing them with is_bridgyfed=1 and filtering at query time, skip
the insert entirely. Stops the bleeding.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Without this, rows updated by the backfill script never reach the local
SQLite replica because incremental sync filters by indexed_at.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Show bridgy fed as a distinct category in platform breakdown, add
amber stacked bars in the timeline chart with legend, add bridgy fed
count to top metrics, exclude bridgy fed from top publications.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add is_bridgyfed column to documents table. Mark at ingest time via
HTTP URL heuristic (only bridgy fed puts HTTP URLs in the site field).
Exclude from all search paths: keyword (turso + local SQLite),
semantic (local DB check in filter loop), and author browse queries.
Includes scripts/mark-bridgyfed for backfilling existing rows via
PLC directory resolution.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
search, search_semantic, search_hybrid → search(mode="keyword"|"semantic"|"hybrid")
mirrors how the backend API actually works (single /search endpoint with mode param).
reduces tool surface for LLM consumers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cursor-based replay and runtime filters are appealing, but tap's
pain points are already worked around and hydrant adds operational
complexity (no docker image, unstable storage, single maintainer).
not adopting now; revisit if we need multiple relay sources or
runtime collection management.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
when the bot fails to DM a subscriber the raw error (e.g.
"FetchFailed: getConvo bad_request: NotFollowedBySender") meant
nothing to the user. now we map known bsky chat error codes to
friendly text with an action link:
- NotFollowedBySender / ActorNotMessageable →
"bsky blocked this DM — follow @pub-search.waow.tech or set your DM
preference to 'Everyone'" with a direct profile link
- RateLimit / TooManyRequests → "bsky rate-limited us, will retry"
- LoginFailed / BotNotConfigured → "server problem, not yours"
raw error still shown underneath in dim italic for debugging.
fallback: show raw for unknown codes.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
handleLogout bypassed sendJson (because it needs Set-Cookie to clear the
session cookie) and so returned only content-type. the browser then
blocked the response because credentialed cross-origin responses
require an explicit Access-Control-Allow-Origin (not '*'). the server
still cleared the session server-side, so on refresh the user appeared
"logged out" even though JS saw a NetworkError.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
my earlier edit to insert <div id="bot-notice"> into #pubs-section was
silently no-op'd (Edit tool reverted when the surrounding markup had
been re-formatted). renderBotNotice then exploded with
"can't access property 'className', el is null" — which stopped render()
before renderPubs ever ran, leaving the toggle list empty even when
subscriptions existed. adds the element back.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
debugging why the toggle list shows empty even after the devlog account
created a site.standard.graph.subscription on its PDS. logs:
- entry (did + resolved pds url)
- PDS response size + first 120 bytes
- record count on success path
- explicit logs for the "no records key" / "not an array" early returns
- json parse failure includes a body preview
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
previously /api/my-publications loaded the user's OWN
site.standard.publication records — backwards. the canonical signal
for "publications this user cares about" is the user's
site.standard.graph.subscription records (the same collection a
standard.site reader writes when you click "subscribe" on a publication).
now the toggle list shows publications you follow, enriched with
name + url from pub-search's local publications mirror when indexed.
if a followed publication isn't in the index, we still return the
at-uri so the UI can show it.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
three pieces that work together so the user knows when DMs won't land:
1. bot-follow banner — on login, check app.bsky.graph.getRelationships
against @pub-search.waow.tech. if the user doesn't follow the bot,
show a warning with a direct link. quiet on the happy path.
2. last_error persistence — new columns on the subscriptions table;
populated by the delivery worker on failure (and cleared on success).
surfaced in /api/subscriptions and rendered under each toggle.
3. bsky error extraction — bsky_bot now plucks the `error` field from
non-2xx chat responses (e.g. ActorNotMessageable) and exposes the
snippet via lastErrorSnippet(). notifications.deliver combines the
zig error name with this snippet into the persisted last_error.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
bsky chat convos require two distinct members — you can't DM yourself.
switching to a dedicated bot account that DMs subscribers.
changes:
- new src/bsky_bot.zig: app-password login + cached session + chat.bsky
sendMessage via atproto-proxy. self-healing on 401.
- notifications delivery now always DMs the subscription owner (the
subscriber), sent FROM the bot account
- oauth SCOPE drops transition:chat.bsky (not needed anymore — the bot
has its own bsky session via app password)
- subscriptions CRUD drops destinationKind/destinationValue from the
client-facing contract; lexicon makes them optional
- frontend no longer asks for recipient
fly secrets to set: BSKY_BOT_HANDLE (default pub-search.waow.tech),
BSKY_BOT_APP_PASSWORD (staged).
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
third-party cookies are dropped by Chrome/Safari on cross-eTLD+1 credentialed
fetches even with SameSite=None; Secure. move the backend hostname under the
same eTLD+1 as the frontend (waow.tech) — matches the plyr.fm / ken pattern.
- frontend (pub-search.waow.tech/subscriptions) and backend
(api.pub-search.waow.tech) share registrable domain waow.tech → same-site
for cookies
- cookie now SameSite=Lax (was None), which works for cross-subdomain
credentialed fetches within a single eTLD+1
- API_URL in site/subscriptions.html swapped to api.pub-search.waow.tech
next step: fly secrets OAUTH_CLIENT_ID + OAUTH_REDIRECT_URI already
staged to the new hostname; this deploy picks them up.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
- /api/auth-debug returns {hasCookieHeader, hasSessionCookie, sessionTokenPrefix, origin, didResolved, frontendOrigin} so we can see what the browser is sending cross-origin
- logfire.info on callback when session is stored + cookie set
- logfire.debug / warn in getSessionDid to distinguish between
"no cookie at all", "cookie but no pubsearch_session", and "token
present but no live session"
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
new page at pub-search.waow.tech/subscriptions — sign in with atproto
oauth, see your site.standard.publication records, toggle on to get a
bsky DM whenever pub-search indexes a new doc under that publication.
architecture:
- oauth client uses zat (pattern lifted from ken); scope =
`atproto repo:tech.waow.pub-search.subscription transition:chat.bsky`
- subscription records live on the user's PDS as
tech.waow.pub-search.subscription — portable, inspectable
- local sqlite mirror (new `subscriptions` table) keyed by (owner, rkey)
so match at ingest time is an indexed lookup
- indexer.insertDocument gains a void-returning hook that queries
matching subs and enqueues deliveries on a bounded in-memory queue
(drops when full; never blocks the tap worker)
- a single worker thread drains the queue and sends via
chat.bsky.convo.sendMessage, proxied through the subscriber's PDS
notes:
- sessions are in-memory (ken pattern) so deliveries for users who
haven't signed in since the last backend restart are skipped —
tracked in the skipped_no_session counter
- frontend is served from CF Pages; cross-origin cookies use
SameSite=None; Secure with credentialed CORS back to the backend
- load-bearing paths (search, atlas, tap, reconciler, embedder, sync)
are not touched
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
publications were silently omitted from incrementalSync — they only
synced on fullSync (first boot), so every publication added after
the initial sync stayed invisible to the local replica forever.
turso had 4,476 publications, replica had 3,624.
root cause: publications table had no indexed_at column, so there
was no incremental cursor.
- schema: add publications.indexed_at + one-time backfill to now()
- indexer.insertPublication: stamp indexed_at via ON CONFLICT DO UPDATE
(was INSERT OR REPLACE with no timestamp)
- sync.incrementalSync: add publications fetch mirroring documents,
with its own labeled block so a failure falls through to tombstones
- sync_span gains new_pubs attribute alongside new_docs/deleted
- local replica schema + migration gain publications.indexed_at
- fullSync publications SELECT now includes indexed_at column
the schema backfill sets every existing publication's indexed_at to
the deploy timestamp, which means the first post-deploy incremental
sync pulls all 4,476 rows down in one shot (~1 round trip).
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
local replica silently desynced for 6 days; dashboard showed stale
13,990 while turso had 14,625. sync.zig only used std.debug.print
and zig 0.16 http.Client has no timeout, so a wedged turso call
killed sync forever with no alert.
- sync.incrementalSync now emits a logfire span per run with
new_docs/deleted/since attributes and recordError on failures
- db.zig syncLoop updates a heartbeat atomic before each attempt
- new watchdog thread aborts the process (→ fly restart) if the
heartbeat goes stale for >3x the sync interval
- also instrument the silent drop paths in indexer.zig
(content_hash_dupe, test_domain, bridgy_fed) as tap.dropped
spans so the drop-reason breakdown is queryable in logfire
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Trackpads send many small-delta wheel events, so the fixed 0.9/1.1 factor
made zooming feel jumpy. Now uses Math.pow(0.998, dy) for smooth scaling.
Mouse wheel (deltaMode=1) still feels snappy via line-to-pixel conversion.
Mobile pinch zoom was already proportional — unchanged.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Thread io: std.Io through all modules instead of global compat bridge.
Replaces compat.Mutex/Condition with Io.Mutex/Io.Condition, compat.sleep*
with io.sleep(), compat.timestamp/microTimestamp with Io.Timestamp, and
inlines std.c.getenv at each call site.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- smp_allocator replaces GeneralPurposeAllocator (removed in 0.16)
- std.Io.Threaded backend for async I/O (networking, http)
- std.Io.net replaces std.net for TCP server
- thread-per-connection replaces Thread.Pool (removed)
- compat.zig wraps removed APIs: getenv, sleep, timestamp, Mutex
- C file I/O replaces std.fs (removed) in timing.zig, LocalDb.zig
- ArrayList .empty replaces .{} init syntax
- all deps updated to 0.16-compatible hashes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
build-atlas: query turso for publication names/covers, group docs by basePath,
compute centroids, output publications array in atlas.json.
atlas.js: render publication circles scaled by sqrt(count)*zoom (~25 at overview,
hundreds at deep zoom), lazy-load cover images from bsky CDN, fallback letter
circles, name labels at zoom 3+.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- idle-frame optimization: rAF loop stops when not animating (GPU sleeps when idle)
- cache colors per frame: theme read once, precomputed rgba lookup eliminates
getColors/isDark/hexToRgba from hot path
- extend nebula halos to all zoom levels: continuous alpha curve instead of
hard cutoff at zoom 4 (25% floor at zoom 10)
- smooth zoom transitions: labels cross-fade coarse↔fine↔titles, connections
fade in over zoom 2.5–3.5 instead of hard pop at zoom 3
- cache nebula halo sprites: pre-rendered offscreen canvases keyed by
(platform, radiusBucket), drawImage instead of createRadialGradient per frame
- reuse connection line buffers: pre-allocated Float32Arrays instead of 18
fresh arrays + thousands of push() calls per frame
- quantize sprite sizes: snap radius to 0.5px steps, reduces sprite rebuilds
during smooth zoom from ~60/s to ~4-5/s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
checked turso: only 20 leaflet docs have custom slugs, and most of
those slug URLs actually 404 on leaflet.pub (piffey, meri) while the
rkey URLs work fine. the ann.leaflet.pub case where rkey 404s and slug
works is the exception, not the rule. reverting to rkey-based URLs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
leaflet.pub posts with custom slugs (e.g. /run-claude-and-opencode-...)
were 404ing because we always built URLs with rkey. now prefer the path
field when available.
also adds: small scatter-dot icon on each search result that navigates
directly to that document on the atlas (by URI lookup, no API call),
touch-active feedback for mobile, and fixes atlas.js variable ordering
bug that crashed the page when ?q= was present.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- skip blento.app base_paths in URL construction (blento is a card
portal, not a document server) — affected docs fall through to
leaflet.pub/p/{did}/{rkey} or platform-specific fallbacks
- prefetch search API call in parallel with atlas.json when ?q= is
present, eliminating stacked latency on shared links
- add 3-minute tpuf keepalive ping to prevent cold start penalty
(~600-900ms extra on first query after inactivity)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- indexer: backfill base_path when publication changes (not just when empty)
- fixed 80+ docs with stale pckt.blog subdomains in Turso
- bottom bar: legend + stats stacked in flex container, no overlap
- interactive legend: click platform to filter/highlight those points
- label clipping: clamp labels to stay within viewport bounds
- tap targets: 44px min for theme toggle and nav links on mobile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Crosshair replaced with amber reticle (outer ring, gapped crosshair
lines, center dot) — much more visible against both themes
- Search zoom now targets 4-15x (was 2-8x), fitting results in 30%
of viewport instead of 40%
- Label uses drawLabel with outline for readability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Labels now check bounding box overlaps before rendering — dense
clusters show only the most important labels instead of stacking
- Coarse/fine labels sorted by cluster size so biggest clusters win
- Max zoom increased from 15x to 30x for exploring dense areas
- Legend and stats no longer overlap on mobile (stacked vertically)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
atproto.at 404s on all records. pdsls.dev actually renders record
content for live PDS records. Also adds leaflet.pub/p/{did}/{rkey}
fallback for 208 leaflet docs without basePath (matching backend logic).
Affects ~1,619 docs (13% of atlas) that had no clickable link.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- tap-to-select (shows tooltip), tap-again-to-open (navigates to doc)
- larger hit radius on touch devices (40px vs 20px)
- pinch zoom now zooms toward pinch midpoint instead of screen center
- scale labels down on narrow viewports (9/8/9px vs 12/11/11px)
- fewer labels and shorter titles on mobile to reduce clutter
- responsive CSS for header, legend, stats, search, tooltip
- tooltip anchored at top-center on mobile instead of following finger
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename all files, routes, docs, and references from "constellation"
to "atlas" (constellation conflicts with an existing ATProto service).
Add search box to atlas page — type a query, it calls the semantic
search API, matches result URIs to points on the canvas, computes a
weighted centroid, and animates the view to center on the results
with matched points highlighted. Cmd+K to focus, Escape to clear.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- README: add constellation section with link and brief description
- docs/constellation.md: data pipeline, frontend, recomputation, future work
- docs/bridgy-fed.md: history of two failed attempts, why we exclude,
detection method, scripts, and what we'd need to reconsider
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- cross-referenced turbopuffer with turso: 26,112 of 38,625 vectors
were orphans (deleted from turso but never cleaned from tpuf)
- rebuilt constellation: 12,513 points, 32 coarse / 161 fine clusters
- removed 100ms hover debounce — spatial index is O(1), no delay needed
- constellation.json down from 10.8MB to 3MB
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- labels now use fixed screen-size fonts (11-12px) instead of
shrinking with zoom — readable at all zoom levels
- replaced shadowBlur (gaussian blur per fillText = perf killer)
with strokeText outline — same readability, orders of magnitude cheaper
- connection lines batched into 3 opacity buckets with single
beginPath/stroke per bucket instead of per-line style changes
- inlined coordinate transform (cx/cy/scale cached per frame)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- all scripts now prioritize .env file over environment variables
(fixes stale env var overriding fresh .env keys)
- purged 4 remaining bridgy fed vectors from turbopuffer
- rebuilt constellation data (38,624 points, 81 coarse / 414 fine clusters)
- added post-particles-inspired connection lines between nearby points
when zoomed in (>= 3x), using spatial index for O(n) performance
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
python pipeline (scripts/build-constellation) exports vectors from
turbopuffer, projects to 2D via PCA+UMAP, clusters with HDBSCAN at
two granularities, and labels via c-TF-IDF on titles.
frontend (site/constellation.{html,css,js}) renders a full-viewport
dark canvas with celestial-style radial gradients per platform,
pan/zoom, semantic zoom labels, hover tooltips, and click-through.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add is_bridgyfed column to documents table. Mark at ingest time via
HTTP URL heuristic (only bridgy fed puts HTTP URLs in the site field).
Exclude from all search paths: keyword (turso + local SQLite),
semantic (local DB check in filter loop), and author browse queries.
Includes scripts/mark-bridgyfed for backfilling existing rows via
PLC directory resolution.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cursor-based replay and runtime filters are appealing, but tap's
pain points are already worked around and hydrant adds operational
complexity (no docker image, unstable storage, single maintainer).
not adopting now; revisit if we need multiple relay sources or
runtime collection management.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>