Experimental Bluesky client for agents
perch docs PLAN.md
10 kB
Markdown
at main

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 fetchedAt reference supplies absolute UTC and relative ages for all cards. Preserve source createdAt and indexedAt separately. 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. back returns the exact previous snapshot, without refetch. refresh creates 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: show emits 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). --auth explicitly uses ~/liv/.omp/mcp.json mcpServers.atproto.env BSKY_IDENTIFIER/BSKY_APP_PASSWORD and PDS https://pds.mlf.one (existing client source). Configurable --auth-config and --pds support 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, sessions do 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 needed src/normalize.ts/src/auth.ts). Export createReader(options?: ReaderOptions): Promise<Reader>. Reader's thread(input, options) and profile(actor) return the model types. source reflects actual mode and viewer. Accept public API URL/AT URI input; resolve handle to DID before storing canonical AT URI. Use installed @atproto/api and follow prior-art source. Normalization should be callable without network for final focused tests; export normalizeThread(thread: unknown, context: {requested: string; focusUri: string; fetchedAt: string; source: Source; options: Required<ThreadOptions>}): ThreadSnapshot from atproto.ts or normalize.ts.
  • Session/CLI owner: src/store.ts, src/cli.ts. Export ReadingStore from store.ts. CLI calls createReader and 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 verbs open <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. --help documents 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, optionally src/style.ts. Export renderText(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/api feed/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.