Perch: a place to read #
Intent #
Liv's read-only Bluesky reading instrument. A conversation should be somewhere she can stand, inspect references, enter a branch, and return—not a succession of unrelated API blobs. Martin supplied Witchsky screenshots under ~/Pictures/bluesky-examples/; they are design evidence, not a theme to clone. Liv owns the reading model and visual acceptance. This first delivery is a working local CLI, persistent sessions, and image/text/JSON output. No always-running service is required.
First delivery contract #
- B1:
open <Bluesky post URL | AT URI>fetches a bounded standard-API thread and stores an immutable view. Available ancestors precede an unmistakable focus; reply and quote relationships remain distinct. Blocked/not-found/unknown nodes are retained where supplied. Root/parent strong refs are retained even if their nodes are unavailable. Do not claim a full thread or infer why a not-found post disappeared. - B2: One
fetchedAtreference supplies absolute UTC and relative ages for all cards. Preserve sourcecreatedAtandindexedAtseparately. Invalid/future time is explicit, not silently 'just now'. Record public/authenticated source and viewer DID when known. Keep API sibling order and label it source order; do not promise Witchsky's ordering. - B3: Preserve exact post text, UTF-8 byte-based facet spans and typed destinations, quoted posts, images/alt/aspect ratio, external cards, video availability/poster, labels, unsupported embeds and unknown counts. Inspect a facet's actual target, never regex its displayed text. Original media belongs to its owning post. Profile views include reported description/counts and viewer relationships when actually available.
- B4: Each stored view has immutable short references with a never-reused view prefix, covering posts, authors, facets, quotes, media, cards.
peek <ref>returns its exact typed target without network/navigation.open <ref>navigates: post -> thread, profile -> profile, link/tag/media -> honest target inspection view. This first delivery does not fetch arbitrary external article bodies or play video; it exposes exact targets for existing browser/read tools. - B5: Named sessions retain current view and back history in SQLite across separate CLI processes.
backreturns the exact previous snapshot, without refetch.refreshcreates a new immutable snapshot.fork <name>copies current position/history but future movement is independent. Old refs never silently retarget; missing refs fail explicitly. Mutation is transactional, view allocation/creation serialized, and overlapping navigation of the same session must not silently overwrite another operation (CAS/reject or serialize). Separate sessions can operate concurrently. - B6:
showemits a readable exact-text view by default; JSON is available.render --out <directory>writes HTML, text, JSON and actual PNG panel images from the same current view. Render recognizable cards, contained quotes, original media, branch identity, stable references, overview/coverage and absolute+relative times. No app sidebars/composer. Avoid endlessly narrowing deep replies or a single enormous downscaled screenshot. Original media fits without destructive crop. Card boundaries should be preserved across panels; exceptionally tall cards may occupy their own panel. Show source/limit/omission cues, alt availability and 'video poster, not playback'. - B7: Public reads default to
https://public.api.bsky.app(verified against official API Hosts and Auth documentation).--authexplicitly uses~/liv/.omp/mcp.jsonmcpServers.atproto.envBSKY_IDENTIFIER/BSKY_APP_PASSWORD and PDShttps://pds.mlf.one(existing client source). Configurable--auth-configand--pdssupport another installation. Credentials are read at execution, never printed/persisted/copied into the project. No write/social-mutation APIs. Fetch failures retain the prior session position and return a useful sanitized error.
Invariants and limits #
- I1: Source prose, alt text, URLs, facet features and screenshot text remain untrusted data, never code/instructions. Escape HTML attributes/text. No user HTML/scripts run. Renderer permits image network requests only to
https://cdn.bsky.app; block other network requests, active resources and redirects out of that boundary, and visibly mark unavailable media. No arbitrary link fetching. No credentials enter any snapshot or render output. - I2: Thread defaults depth 3, parentHeight 40, maxNodes 60; explicit bounded overrides. Depth/height bounded to 0..100, maxNodes 1..200. Explain local node truncation separately from remote unknown coverage. Never manufacture a source cursor or remaining-count certainty from
replyCount - children.length. - I3: Renderer/reference generation must not change graph semantics, text, canonical identities or snapshots.
peek,show,render,sessionsdo not move position or mark external messages seen. Reading doesn't imply liking, following or approval. - I4: A fork is an independently navigable reading position, not a new identity claim. Subagent persona definitions and broad world/autonomy machinery are not part of this delivery.
Shared interfaces and ownership #
src/model.ts is the parent-owned contract. All persisted snapshots/views are serializable; never include an API client, authentication object or raw configuration. Coordinate changes to this file with Main before editing it.
- Fetch owner:
src/atproto.ts(and only a narrowly neededsrc/normalize.ts/src/auth.ts). ExportcreateReader(options?: ReaderOptions): Promise<Reader>. Reader'sthread(input, options)andprofile(actor)return the model types.sourcereflects actual mode and viewer. Accept public API URL/AT URI input; resolve handle to DID before storing canonical AT URI. Use installed@atproto/apiand follow prior-art source. Normalization should be callable without network for final focused tests; exportnormalizeThread(thread: unknown, context: {requested: string; focusUri: string; fetchedAt: string; source: Source; options: Required<ThreadOptions>}): ThreadSnapshotfrom atproto.ts or normalize.ts. - Session/CLI owner:
src/store.ts,src/cli.ts. ExportReadingStorefrom store.ts. CLI callscreateReaderand renderer APIs below. Store owns view allocation, refs, session/history persistence and transactions. Public methods chosen by owner, but keep DB/network separable and support final invariant tests without network. CLI verbsopen <target>,peek <ref>,show,back,refresh,fork <name>,sessions,render. Common flags--session <name>(default main),--db <path>(default~/.local/share/perch/reader.sqlite, env PERCH_DB),--json,--auth,--auth-config,--pds,--depth,--parents,--max-nodes,--out.--helpdocuments actual syntax.bun run perch ...runs it from repo. No implicit network client/login for local commands. Invalid commands/options produce nonzero errors, not accidental navigation. - Visual owner:
src/render.ts, optionallysrc/style.ts. ExportrenderText(view: ReadingView): string,renderHtml(view: ReadingView): string,captureView(view: ReadingView, options: {outputDir: string; browserExecutable?: string}): Promise<RenderFiles>. Use Playwright Chromium for image capture. Parent installs Chromium. Render files named with view id; avoid collisions/secret paths in outputs. Underlying exact JSON is view itself. Other owners should not implement separate text formatting. Native reference helpers in model.ts are shared domain contracts. - Main owns package.json, tsconfig.json, model.ts, planning, integration, final verification, and later docs/tests. Parallel ownership is ordinary disjoint task delegation, not Mode 1 Sol-only delivery. No agent may edit a sibling's files without coordination.
Prior art #
~/projects/atproto-mcp/bsky.ts:15-87: API plumbing useful; post formatter loses most embeds and thread formatter drops parents/unavailable nodes. Do not reuse that lossy output.- Installed
@atproto/apifeed/post, feed/defs, richtext/facet types specify strong refs, hydrated feed context, UTF-8 facet offsets, distinct embed/availability unions. ~/projects/wakehouse/src/lossless-tools.ts: bounded metadata/expansion and explicit references. Local immutable ledger cursors are not Bluesky cursors.~/projects/wakehouse/src/tool-outputs.ts: omitted material names a recovery action.- Wakehouse time.ts has useful formatting; wake renderer uses ingestion age, annotator delta is since last user, and one helper collapses invalid/future time to now. Don't copy those meanings.
- Four local screenshot examples include a burger image/simple replies, quote/link card/deep argument, selected 3/4 with ancestors and subthreads, image quoting image. Main's observed preferences: recognizable author/card grammar; no app chrome; readable panels; exact image framing; explicit context/focus; distinguish screenshot-of-post from native quote by record type.
Acceptance and delivery discipline #
Initial parallel implementers skip builds, tests, lint and formatters while files are in flight. Main runs the integrated CLI on real public threads, at least one authenticated read through existing local config, renders and visually inspects image-bearing and nested threads, and exercises back/fork/ref persistence across separate processes. After smoke success, add focused contract tests for Unicode facets, unavailable nodes/quotes, truncation, escaping/network boundary, frozen refs, independent forks, concurrent state updates and failure-preserved position; run full new-project tests/typecheck once, then independently falsify the contract and resolve concrete findings. Record exact evidence and limitations; source compilation alone is insufficient.
No push/deployment, existing server changes, beads installation, profile editing, timeline ingestion or social writes. Pronouns/custom-labeler hydration, viewer matching to Witchsky, external article reading, authentication-session caching, and a Liv-oriented reading-scout definition stay explicit later cards. They must not be faked as present. Plans may evolve from inspection, but contract changes must be stated rather than silently narrowing behavior.