commits
replaces the previous model (one thread per job, all fighting over the
embedder mutex) with a single worker thread draining a FIFO queue.
- jobs run one at a time: CAR download + embed, then next job
- status response includes queue_position and queue_depth
- frontend shows "N jobs ahead of you" while waiting
- predictable throughput, honest wait times
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
~150MB of binaries that are only needed for local dev (macos dylibs)
or were already extracted (tarball). the linux .so files in llama-bin/
stay tracked since the Docker build copies them into the runtime image.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- tech.waow.ken.profile lexicon (rkey self, createdAt) — presence
signal so indexers can discover ken users
- oauth.putRecord: idempotent create-or-update for fixed-rkey records
- pds.getRecord: unauthenticated single-record lookup
- server.ensureProfile: best-effort write on first /api/me per session,
skips if record already exists on the user's PDS
- scope updated to include repo:tech.waow.ken.profile
no auth or frontend changes — existing single-cookie flow untouched.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
notes/multi-account-and-settings.md has the full design writeup
with reference implementations (pdsls scope mgmt, plyr.fm group_id
session linking). TODO.md at repo root is the statement of work for
the next engineer picking this up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
README: cut from 54 lines to 40. dropped the verbose how-it-works
walkthrough, data-propagation section, and sharing explanation. what
remains: one-liner hook, lexicon link, stack diagram, dev/deploy
commands, pointer to notes/. matches the pollz/typeahead pattern.
disclosure popover: the description paragraph was restating what the
about modal already says. trimmed to one line of state context ("on
your PDS. ken reloads it next sign-in." / "in memory only. save to
keep it across sessions.") — the about modal is the single source
for the full explanation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the previous commit rejected repos over 50k records entirely. wrong —
we should index what we can and tell the user the rest was dropped.
now: if post-filter records > ABSOLUTE_MAX_RECORDS (50k), truncate to
the cap and set pack.truncated_from to the original count. the UI
shows the cap message inline in the pack-meta line with a DM prompt.
the user gets 50k searchable records instead of an error page.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
if even the 2-year time cutoff leaves more than 50k records, bail
before embedding rather than OOMing the 4GB fly machine. the error
message is user-facing and tells them to DM @zzstoatzz.io.
also: runJob now preserves a pre-set error_msg instead of blindly
overwriting it with the error code, so custom messages from
openAndWalkRepo actually reach the UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the session cookie was literally the user's DID (a public identifier).
anyone who knew the DID could set the cookie and impersonate the user,
gaining the ability to save/delete packs on their PDS. DIDs are public
by design (they're in every AT-URI, in plc.directory, on profiles), so
this was a real vulnerability, not a theoretical one.
fix: generate 32 cryptographically random bytes (hex-encoded, 64 chars)
as the session token. store a token → DID mapping in state.zig. the
cookie contains only the opaque token; getSessionDid resolves it back
to the DID via the map. logout deletes both the token mapping and the
session data.
follows the same pattern as plyr.fm's session handling (secrets.token_
urlsafe(32) → DB mapping → cookie). pollz has the same bug and should
be fixed separately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- "built locally" wrongly implied the user's machine; it runs on
the server. rewritten to say ken fetches from your PDS and builds
the index on the server.
- added "experimental" upfront.
- clarified the save is opt-in: nothing is written to your repo
unless you click save. the result is stored as a record with
vector blobs, not just "a record."
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the previous commit rewrote the entire style.css and dropped ~220
lines of rules (search form, typeahead, progress bar, results,
modals, etc.), nuking the site layout. this restores the known-good
CSS from 39daa31 and applies only the targeted pack-menu changes:
kill the pill border/padding, make the trigger inline text, add
margin-left: auto on the share button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the bordered pill wrapped awkwardly on mobile and buried share behind
2 extra clicks. share is about the query, not the pack — it should be
one click from the results page.
- pack state ("saved" / "not saved") is inline muted text in the
stats line, clickable for the disclosure (save/delete/view actions)
- share is back as its own inline button, pushed right with
margin-left: auto, visible whenever there's a query
- no more bordered pill, no "search index" label, no wrapping issues
- trimmed popover description copy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- "nothing lives anywhere else" was wrong — writing to a public PDS
is a broadcast. replaced with a data-propagation section that says
so plainly and gives the actual opt-out (don't click save).
- "nothing new is exposed" was technically defensible but misleading.
semantic search is a new discoverability surface even when the
underlying records were already public. the sharing section now
says both halves.
- added the collection filter + auto time cutoff to the how-it-works
list since they're now a material part of the pipeline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the about modal's "example pack on pdsls.dev" link started life as
href="#" and only got overwritten inside renderPackActions when a
signed-in user had a persisted pack. on mobile, if you opened the
about modal before the status poll had fired (or before signing in
at all), the "#" placeholder was still there — and mobile browsers
interpret <a href="#" target="_blank"> as a fresh navigation to
"/#", dumping the user on the same page with a dangling hash. on
desktop the issue was invisible because the page tended to have
already polled by the time you opened the modal.
fix: bake in a real fallback href pointing at the ken maintainer's
own pack collection via handle. pdsls resolves the handle to a did
and renders the collection view regardless of which specific rkey
is current. signed-in users still get their own pack URL from
renderPackActions as before; this just guarantees the link is never
broken for the signed-out flow or the pre-poll window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
three holistic fixes addressing user frustration with the pack-meta
line:
1. the resting state now reads "search index: saved" / "search index:
not saved" inside a real pill button. the earlier version was bare
muted text that said "saved" with no subject — users legitimately
asked "saved what?". the subject is explicit now and survives
zero-context viewing.
2. the trigger is a visible bordered pill with padding, hover, and a
0.9em chevron. the earlier 0.75em chevron on muted text was nearly
invisible — several users couldn't even tell it was a dropdown.
3. removed the orphaned "·" separator that dangled after "no text
content" because margin-left: auto pushed the menu far right. no
separator span anymore; the pill's border gives it enough visual
weight to not need one. flex gap handles spacing.
also renamed "pack" → "search index" in all user-facing copy for
consistency: the description, view-on-pds link, save button, delete
button, and delete confirm prompt. "pack" stays as the internal
record name (tech.waow.ken.pack) but isn't user-facing anymore.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the pack-meta line had the state label ("saved") sitting directly next
to a "delete" button and a "share" button, which read as the nonsense
phrase "saved delete" and made it unclear what any of the verbs acted
on. confusing enough that a user asked literally "delete what?".
rework: the state label IS the menu trigger now. one click on
"saved" / "not saved" opens a small popover anchored to the trigger
with:
- a one-sentence description of what the pack is and where it lives
- view pack on PDS (if saved)
- delete saved pack (if saved)
- save pack to my PDS (if not saved)
- share this search (if there's a query to share)
every action closes the menu on click so the user gets immediate
visual confirmation. mobile gets a left-anchored panel so it doesn't
clip off the right edge of the viewport.
shared-view mode swaps the entire disclosure for a static "shared
view" label — there's no auth and nothing to save/delete, so a menu
would be empty.
no backend changes, just frontend. deploy picks it up because assets
are embedded at build time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
just deploy (fly deploy from backend/), just fmt / just fmt-check
(zig fmt). fmt baseline picks up pre-existing drift in display.zig
and oauth.zig so fmt-check passes clean on main going forward.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
review followups on the filter commit:
- the listRecords fallback was skipping denylisted collections but
never running the count pass or applying the TID cutoff, so any repo
that hit the fallback (old/flaky PDS, CAR walker failure) regressed
to the old unbounded behavior. now the fallback paginates non-skipped
collections, then post-hoc applies the same 2-year TID cutoff if the
total crosses LARGE_REPO_THRESHOLD. post-hoc is less efficient than
the pre-filter count pass but listRecords doesn't report per-
collection totals, so we can't decide before fetching. fallback is
rare enough that the extra transient memory is acceptable; revisit
if it starts firing routinely.
- made decodeTidMicros public so the fallback can reuse it without
copy-pasting the TID alphabet.
- derived per_collection from collection_of post-cutoff in the
fallback, matching the CAR path (previously inlined during
pagination, which meant cutoff-dropped records still counted).
- skipped_by_collection stays 0 on the fallback path (documented) —
listRecords can't cheaply report what we chose not to fetch.
- fixed pack-meta copy to say "records with no text content" instead
of "likes/follows/reposts" — the denylist covers blocks, listitems,
threadgates, actor status, chat declarations, and tangled graph
follows/stars too. the old copy was misleading for any repo with
those record types.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the streaming CAR walker unblocked very large repos at fetch/parse time,
but the embed pipeline still choked: pfrazee's 196k records (mostly
likes/follows/reposts) burned transient memory + embed time on records
with no semantic text. this was never going to scale beyond me.
two transparent concessions, surfaced honestly in the UI:
1. collection-level filter. records in DEFAULT_SKIP_COLLECTIONS (likes,
follows, reposts, blocks, listitems, threadgate, postgate, actor
status, chat declaration, sh.tangled graph follow/star) are dropped
before CBOR value decode — skipped records cost only the MST entry
iteration. applied in both the CAR walker and the listRecords
fallback for consistency.
2. auto time cutoff. if post-collection-filter count still exceeds
LARGE_REPO_THRESHOLD (30k), enable a 2-year TID cutoff. implemented
as a cheap count-only MST walk before the full walk — we learn the
post-filter size without decoding record values, then decide. TIDs
decode from base32-sortable rkeys in ~15 lines; non-TID rkeys (self,
etc.) are always kept.
pipeline shape becomes: openRepo → countOpened → decide filter →
walkOpened → close. the open/walk split keeps the mmap alive across
both passes so the count pass is essentially free.
pfrazee smoke: 195,908 total → 37,611 kept post collection filter →
cutoff kicks in → 35,682 final. zzstoatzz.io regression-clean: 17,350
total → 5,145 kept, 12,205 skipped, no cutoff.
status response gains skipped_by_collection, skipped_by_time,
applied_tid_cutoff_ms. pack-meta line in the UI shows the honest
breakdown: \"5,145 records · 190 collections · skipped 12,205
likes/follows/reposts\" for normal repos; \"35,682 records · 30
collections · skipped 158,297 likes/follows/reposts · indexed records
after 2023-04-01 (1,929 older records skipped)\" for pfrazee.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ken's old walker buffered the entire sync.getRepo response body in heap via
zat.HttpTransport.fetch (which always dupes into std.Io.Writer.Allocating),
then handed it to zat.car.readWithOptions which eagerly materialized a
StringHashMap of every CID → block content. that combination capped ken at
repos with <200k blocks and kept the whole CAR resident for the duration of
the walk. pfrazee.com (196k records, 72 MB CAR, 248k blocks counting MST
internals) sat just past the cliff.
this path now:
1. talks to std.http.Client directly for the one call that needs it,
streaming the response body straight into /tmp/ken-car-{seq}-{did}.car
via std.Io.File.Writer.initStreaming — no heap staging
2. mmaps the temp file read-only via std.Io.File.MemoryMap (kernel pages
in what we touch, evicts what we don't)
3. feeds the mmap slice to zat.car.streamBlocks (v0.3.0-alpha.24) and
builds a CID → {offset, len} index into the buffer via pointer
arithmetic — no block content duplication, ~16 bytes of value per
entry instead of 48
4. walks the MST through that index, delete-on-destroy cleans the
temp file whether the walk succeeds or errors out
every other ken call still uses zat.HttpTransport — only this one endpoint
needs streaming. bumps zat to v0.3.0-alpha.24 for car.streamBlocks.
smoke tested against two real repos via a standalone /tmp/ken_smoke.zig that
imports repo_walk.zig directly:
zzstoatzz.io: 17,348 records, 200 collections, 8.2 MB CAR
pfrazee.com: 195,904 records, 39 collections, 72.0 MB CAR
pfrazee walks end-to-end in ~11.5s on my laptop. 0 lingering /tmp/ken-car-*
files after either run. verified fly's /tmp is on the rootfs overlay (7.4G
free on the current machine), not tmpfs, so streaming to disk does not
compete with the 4 GB memory budget.
not yet addressed: indexer.zig still holds the full records[] + extracted
text + 384-dim vectors for every record simultaneously during embedding,
which for pfrazee would be ~300 MB of vectors on top of the mmap. walking
pfrazee is unblocked; embedding pfrazee needs a separate, record-at-a-time
pipeline that's planned as a follow-up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
phase A-F was an internal numbering from the python spike → zig backend
migration. nothing has been "phase F" in weeks; it just reads as nonsense
in the health endpoint. /health now returns {"status":"ok"}, and the
main.zig module comment describes what ken actually does.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
share view recipients can no longer type new queries. the search box is
pre-populated with the shared query and set readonly, so the recipient
sees the specific result the sharer meant to share rather than
accidentally getting a browseable interface over the target's whole
repo. the underlying PDS records are public either way (and the sharer
accepted that when they clicked save), but the share modal copy
promises "this query" — the UI should honor that. a recipient who wants
to search freely can sign in with their own handle.
visual: dashed border + muted text + transparent fill so it reads as
"fixed, not editable" without looking disabled. readonly (not disabled)
keeps select + copy working.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
carousel: 8 static lines describing what the backend is doing right now
(CAR walk, zat parsing, bge-small batching, cosine search cost, reuse by
(uri, cid), etc), rotating every 4.5s during the load phase. when
pollStatus sees prior_build_ms / prior_count come back, the pool is
extended in place with two calibrated lines that report the user's real
last-run numbers — they join the rotation alongside the static facts
instead of pinning. killed it during the branding pass on a "link, don't
explain" principle that overshot; the carousel does a different job from
the about modal (occupying the wait with something useful vs. answering
"what is this when asked").
ios zoom: safari auto-zooms into any text input rendered smaller than
16px on tap. the body inputs were at clamp(13px, 1.6vmin, 15px) which
tripped it on every phone. switched both the signin/search inputs and
the share-modal url input to font-size: max(16px, var(--text-*)) — 16
floor on mobile, clamp-scaled on desktop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
MIT, verbatim from the SPDX canonical template
(raw.githubusercontent.com/spdx/license-list-data/main/text/MIT.txt) with
only the <year> and <copyright holders> placeholders filled in.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
semantic search over an atproto repo. sign in, the backend walks your PDS
via com.atproto.sync.getRepo (one CAR, parsed locally via zat), embeds
records with bge-small through llama.cpp, and writes the resulting vector
pack back to your own PDS as a tech.waow.ken.pack record + blobs. nothing
lives anywhere else — delete the record and the pack is gone.
- zig backend, std.http.Server, zat for atproto primitives
- llama.cpp batched inference, 16 records per encode
- incremental re-index: unchanged records are reused by (uri, cid)
- partial search works from the moment the first batch finishes
- opt-in save + delete with explicit consent
- auth-first UI — no drive-by indexing of other people's repos
- public share URLs with per-query OG tags
- running at https://ken.waow.tech on fly.io
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
replaces the previous model (one thread per job, all fighting over the
embedder mutex) with a single worker thread draining a FIFO queue.
- jobs run one at a time: CAR download + embed, then next job
- status response includes queue_position and queue_depth
- frontend shows "N jobs ahead of you" while waiting
- predictable throughput, honest wait times
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- tech.waow.ken.profile lexicon (rkey self, createdAt) — presence
signal so indexers can discover ken users
- oauth.putRecord: idempotent create-or-update for fixed-rkey records
- pds.getRecord: unauthenticated single-record lookup
- server.ensureProfile: best-effort write on first /api/me per session,
skips if record already exists on the user's PDS
- scope updated to include repo:tech.waow.ken.profile
no auth or frontend changes — existing single-cookie flow untouched.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
README: cut from 54 lines to 40. dropped the verbose how-it-works
walkthrough, data-propagation section, and sharing explanation. what
remains: one-liner hook, lexicon link, stack diagram, dev/deploy
commands, pointer to notes/. matches the pollz/typeahead pattern.
disclosure popover: the description paragraph was restating what the
about modal already says. trimmed to one line of state context ("on
your PDS. ken reloads it next sign-in." / "in memory only. save to
keep it across sessions.") — the about modal is the single source
for the full explanation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the previous commit rejected repos over 50k records entirely. wrong —
we should index what we can and tell the user the rest was dropped.
now: if post-filter records > ABSOLUTE_MAX_RECORDS (50k), truncate to
the cap and set pack.truncated_from to the original count. the UI
shows the cap message inline in the pack-meta line with a DM prompt.
the user gets 50k searchable records instead of an error page.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
if even the 2-year time cutoff leaves more than 50k records, bail
before embedding rather than OOMing the 4GB fly machine. the error
message is user-facing and tells them to DM @zzstoatzz.io.
also: runJob now preserves a pre-set error_msg instead of blindly
overwriting it with the error code, so custom messages from
openAndWalkRepo actually reach the UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the session cookie was literally the user's DID (a public identifier).
anyone who knew the DID could set the cookie and impersonate the user,
gaining the ability to save/delete packs on their PDS. DIDs are public
by design (they're in every AT-URI, in plc.directory, on profiles), so
this was a real vulnerability, not a theoretical one.
fix: generate 32 cryptographically random bytes (hex-encoded, 64 chars)
as the session token. store a token → DID mapping in state.zig. the
cookie contains only the opaque token; getSessionDid resolves it back
to the DID via the map. logout deletes both the token mapping and the
session data.
follows the same pattern as plyr.fm's session handling (secrets.token_
urlsafe(32) → DB mapping → cookie). pollz has the same bug and should
be fixed separately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- "built locally" wrongly implied the user's machine; it runs on
the server. rewritten to say ken fetches from your PDS and builds
the index on the server.
- added "experimental" upfront.
- clarified the save is opt-in: nothing is written to your repo
unless you click save. the result is stored as a record with
vector blobs, not just "a record."
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the previous commit rewrote the entire style.css and dropped ~220
lines of rules (search form, typeahead, progress bar, results,
modals, etc.), nuking the site layout. this restores the known-good
CSS from 39daa31 and applies only the targeted pack-menu changes:
kill the pill border/padding, make the trigger inline text, add
margin-left: auto on the share button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the bordered pill wrapped awkwardly on mobile and buried share behind
2 extra clicks. share is about the query, not the pack — it should be
one click from the results page.
- pack state ("saved" / "not saved") is inline muted text in the
stats line, clickable for the disclosure (save/delete/view actions)
- share is back as its own inline button, pushed right with
margin-left: auto, visible whenever there's a query
- no more bordered pill, no "search index" label, no wrapping issues
- trimmed popover description copy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- "nothing lives anywhere else" was wrong — writing to a public PDS
is a broadcast. replaced with a data-propagation section that says
so plainly and gives the actual opt-out (don't click save).
- "nothing new is exposed" was technically defensible but misleading.
semantic search is a new discoverability surface even when the
underlying records were already public. the sharing section now
says both halves.
- added the collection filter + auto time cutoff to the how-it-works
list since they're now a material part of the pipeline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the about modal's "example pack on pdsls.dev" link started life as
href="#" and only got overwritten inside renderPackActions when a
signed-in user had a persisted pack. on mobile, if you opened the
about modal before the status poll had fired (or before signing in
at all), the "#" placeholder was still there — and mobile browsers
interpret <a href="#" target="_blank"> as a fresh navigation to
"/#", dumping the user on the same page with a dangling hash. on
desktop the issue was invisible because the page tended to have
already polled by the time you opened the modal.
fix: bake in a real fallback href pointing at the ken maintainer's
own pack collection via handle. pdsls resolves the handle to a did
and renders the collection view regardless of which specific rkey
is current. signed-in users still get their own pack URL from
renderPackActions as before; this just guarantees the link is never
broken for the signed-out flow or the pre-poll window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
three holistic fixes addressing user frustration with the pack-meta
line:
1. the resting state now reads "search index: saved" / "search index:
not saved" inside a real pill button. the earlier version was bare
muted text that said "saved" with no subject — users legitimately
asked "saved what?". the subject is explicit now and survives
zero-context viewing.
2. the trigger is a visible bordered pill with padding, hover, and a
0.9em chevron. the earlier 0.75em chevron on muted text was nearly
invisible — several users couldn't even tell it was a dropdown.
3. removed the orphaned "·" separator that dangled after "no text
content" because margin-left: auto pushed the menu far right. no
separator span anymore; the pill's border gives it enough visual
weight to not need one. flex gap handles spacing.
also renamed "pack" → "search index" in all user-facing copy for
consistency: the description, view-on-pds link, save button, delete
button, and delete confirm prompt. "pack" stays as the internal
record name (tech.waow.ken.pack) but isn't user-facing anymore.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the pack-meta line had the state label ("saved") sitting directly next
to a "delete" button and a "share" button, which read as the nonsense
phrase "saved delete" and made it unclear what any of the verbs acted
on. confusing enough that a user asked literally "delete what?".
rework: the state label IS the menu trigger now. one click on
"saved" / "not saved" opens a small popover anchored to the trigger
with:
- a one-sentence description of what the pack is and where it lives
- view pack on PDS (if saved)
- delete saved pack (if saved)
- save pack to my PDS (if not saved)
- share this search (if there's a query to share)
every action closes the menu on click so the user gets immediate
visual confirmation. mobile gets a left-anchored panel so it doesn't
clip off the right edge of the viewport.
shared-view mode swaps the entire disclosure for a static "shared
view" label — there's no auth and nothing to save/delete, so a menu
would be empty.
no backend changes, just frontend. deploy picks it up because assets
are embedded at build time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
review followups on the filter commit:
- the listRecords fallback was skipping denylisted collections but
never running the count pass or applying the TID cutoff, so any repo
that hit the fallback (old/flaky PDS, CAR walker failure) regressed
to the old unbounded behavior. now the fallback paginates non-skipped
collections, then post-hoc applies the same 2-year TID cutoff if the
total crosses LARGE_REPO_THRESHOLD. post-hoc is less efficient than
the pre-filter count pass but listRecords doesn't report per-
collection totals, so we can't decide before fetching. fallback is
rare enough that the extra transient memory is acceptable; revisit
if it starts firing routinely.
- made decodeTidMicros public so the fallback can reuse it without
copy-pasting the TID alphabet.
- derived per_collection from collection_of post-cutoff in the
fallback, matching the CAR path (previously inlined during
pagination, which meant cutoff-dropped records still counted).
- skipped_by_collection stays 0 on the fallback path (documented) —
listRecords can't cheaply report what we chose not to fetch.
- fixed pack-meta copy to say "records with no text content" instead
of "likes/follows/reposts" — the denylist covers blocks, listitems,
threadgates, actor status, chat declarations, and tangled graph
follows/stars too. the old copy was misleading for any repo with
those record types.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
the streaming CAR walker unblocked very large repos at fetch/parse time,
but the embed pipeline still choked: pfrazee's 196k records (mostly
likes/follows/reposts) burned transient memory + embed time on records
with no semantic text. this was never going to scale beyond me.
two transparent concessions, surfaced honestly in the UI:
1. collection-level filter. records in DEFAULT_SKIP_COLLECTIONS (likes,
follows, reposts, blocks, listitems, threadgate, postgate, actor
status, chat declaration, sh.tangled graph follow/star) are dropped
before CBOR value decode — skipped records cost only the MST entry
iteration. applied in both the CAR walker and the listRecords
fallback for consistency.
2. auto time cutoff. if post-collection-filter count still exceeds
LARGE_REPO_THRESHOLD (30k), enable a 2-year TID cutoff. implemented
as a cheap count-only MST walk before the full walk — we learn the
post-filter size without decoding record values, then decide. TIDs
decode from base32-sortable rkeys in ~15 lines; non-TID rkeys (self,
etc.) are always kept.
pipeline shape becomes: openRepo → countOpened → decide filter →
walkOpened → close. the open/walk split keeps the mmap alive across
both passes so the count pass is essentially free.
pfrazee smoke: 195,908 total → 37,611 kept post collection filter →
cutoff kicks in → 35,682 final. zzstoatzz.io regression-clean: 17,350
total → 5,145 kept, 12,205 skipped, no cutoff.
status response gains skipped_by_collection, skipped_by_time,
applied_tid_cutoff_ms. pack-meta line in the UI shows the honest
breakdown: \"5,145 records · 190 collections · skipped 12,205
likes/follows/reposts\" for normal repos; \"35,682 records · 30
collections · skipped 158,297 likes/follows/reposts · indexed records
after 2023-04-01 (1,929 older records skipped)\" for pfrazee.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ken's old walker buffered the entire sync.getRepo response body in heap via
zat.HttpTransport.fetch (which always dupes into std.Io.Writer.Allocating),
then handed it to zat.car.readWithOptions which eagerly materialized a
StringHashMap of every CID → block content. that combination capped ken at
repos with <200k blocks and kept the whole CAR resident for the duration of
the walk. pfrazee.com (196k records, 72 MB CAR, 248k blocks counting MST
internals) sat just past the cliff.
this path now:
1. talks to std.http.Client directly for the one call that needs it,
streaming the response body straight into /tmp/ken-car-{seq}-{did}.car
via std.Io.File.Writer.initStreaming — no heap staging
2. mmaps the temp file read-only via std.Io.File.MemoryMap (kernel pages
in what we touch, evicts what we don't)
3. feeds the mmap slice to zat.car.streamBlocks (v0.3.0-alpha.24) and
builds a CID → {offset, len} index into the buffer via pointer
arithmetic — no block content duplication, ~16 bytes of value per
entry instead of 48
4. walks the MST through that index, delete-on-destroy cleans the
temp file whether the walk succeeds or errors out
every other ken call still uses zat.HttpTransport — only this one endpoint
needs streaming. bumps zat to v0.3.0-alpha.24 for car.streamBlocks.
smoke tested against two real repos via a standalone /tmp/ken_smoke.zig that
imports repo_walk.zig directly:
zzstoatzz.io: 17,348 records, 200 collections, 8.2 MB CAR
pfrazee.com: 195,904 records, 39 collections, 72.0 MB CAR
pfrazee walks end-to-end in ~11.5s on my laptop. 0 lingering /tmp/ken-car-*
files after either run. verified fly's /tmp is on the rootfs overlay (7.4G
free on the current machine), not tmpfs, so streaming to disk does not
compete with the 4 GB memory budget.
not yet addressed: indexer.zig still holds the full records[] + extracted
text + 384-dim vectors for every record simultaneously during embedding,
which for pfrazee would be ~300 MB of vectors on top of the mmap. walking
pfrazee is unblocked; embedding pfrazee needs a separate, record-at-a-time
pipeline that's planned as a follow-up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
phase A-F was an internal numbering from the python spike → zig backend
migration. nothing has been "phase F" in weeks; it just reads as nonsense
in the health endpoint. /health now returns {"status":"ok"}, and the
main.zig module comment describes what ken actually does.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
share view recipients can no longer type new queries. the search box is
pre-populated with the shared query and set readonly, so the recipient
sees the specific result the sharer meant to share rather than
accidentally getting a browseable interface over the target's whole
repo. the underlying PDS records are public either way (and the sharer
accepted that when they clicked save), but the share modal copy
promises "this query" — the UI should honor that. a recipient who wants
to search freely can sign in with their own handle.
visual: dashed border + muted text + transparent fill so it reads as
"fixed, not editable" without looking disabled. readonly (not disabled)
keeps select + copy working.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
carousel: 8 static lines describing what the backend is doing right now
(CAR walk, zat parsing, bge-small batching, cosine search cost, reuse by
(uri, cid), etc), rotating every 4.5s during the load phase. when
pollStatus sees prior_build_ms / prior_count come back, the pool is
extended in place with two calibrated lines that report the user's real
last-run numbers — they join the rotation alongside the static facts
instead of pinning. killed it during the branding pass on a "link, don't
explain" principle that overshot; the carousel does a different job from
the about modal (occupying the wait with something useful vs. answering
"what is this when asked").
ios zoom: safari auto-zooms into any text input rendered smaller than
16px on tap. the body inputs were at clamp(13px, 1.6vmin, 15px) which
tripped it on every phone. switched both the signin/search inputs and
the share-modal url input to font-size: max(16px, var(--text-*)) — 16
floor on mobile, clamp-scaled on desktop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
semantic search over an atproto repo. sign in, the backend walks your PDS
via com.atproto.sync.getRepo (one CAR, parsed locally via zat), embeds
records with bge-small through llama.cpp, and writes the resulting vector
pack back to your own PDS as a tech.waow.ken.pack record + blobs. nothing
lives anywhere else — delete the record and the pack is gone.
- zig backend, std.http.Server, zat for atproto primitives
- llama.cpp batched inference, 16 records per encode
- incremental re-index: unchanged records are reused by (uri, cid)
- partial search works from the moment the first batch finishes
- opt-in save + delete with explicit consent
- auth-first UI — no drive-by indexing of other people's repos
- public share URLs with per-query OG tags
- running at https://ken.waow.tech on fly.io
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>