Goal: when you finish an article on the X3, mark that Readwise Reader document archived/seen, so it leaves the OPDS feed automatically. This closes the loop (delivery out via OPDS, read-state back via KOSync).
crosspoint-sync is a KOSync server (TypeScript/Hono + SQLite) with a connector framework (src/connectors/). It already:
- Tracks per-document progress from KOSync and, in
fanout.ts, emits afinishedevent whenpercentage >= 0.98(src/connectors/fanout.ts:66,79), else aprogressevent. Connectors thatcarries: ['finished']get these viapush(). - Ships connectors for Hardcover, Audiobookshelf, Micro.blog, BookFusion, and Readwise.
But the existing readwise.ts connector is the wrong tool for v2: it carries: ['highlight'] only, is hidden: true, uses the classic Readwise API (/api/v2/highlights, /export), and explicitly makes progress/finished a no-op. It's for pushing/pulling highlights, not archiving Reader documents.
So v2 is a genuinely new, unbuilt capability — but it drops cleanly into the existing framework.
A new connector (distinct id from the highlights one) that carries finished and writes to the Reader API:
// crosspoint-sync/src/connectors/readwise-reader.ts (sketch)
const BASE = 'https://readwise.io/api/v3';
// match(): resolve the finished document -> a Reader document id.
// Preferred: our OPDS server stamps the id into the download filename
// (see "id-mapping" below); parse it back here. Fallback: title (unreliable).
async function match(cred, doc /* DocumentMeta */, _http): Promise<Match | null> {
const id = readerIdFromFilename(doc.filename); // e.g. "... [rw-01m29306…].epub"
if (id) return { externalId: id, fromSidecar: true, confidence: 1 };
return doc.title ? { externalId: `title:${doc.title}`, confidence: 0.3 } : null;
}
async function push(cred, m /* Match */, ev /* OutboundEvent */, http): Promise<PushResult> {
if (ev.kind !== 'finished') return { ok: true }; // only act on finish
if (m.externalId.startsWith('title:')) return { ok: true }; // no exact id -> skip archiving
const res = await http(`${BASE}/bulk_update/`, {
method: 'PATCH',
headers: { authorization: `Token ${tokenOf(cred)}`, 'content-type': 'application/json' },
body: JSON.stringify({ updates: [{ id: m.externalId, location: 'archive', seen: true }] }),
});
if (res.status === 401) return { ok: false, needsReauth: true, error: 'unauthorized' };
if (res.status === 429 || res.status >= 500) return { ok: false, retryable: true };
return { ok: res.status < 300 ? true : false };
}
export const readwiseReaderConnector: Connector = {
id: 'readwise-reader',
displayName: 'Readwise Reader (archive on finish)',
tier: 1,
capabilities: { read: false, write: true },
carries: ['finished'],
credentialKind: 'token',
validate, // GET /api/v3/list/?limit=1
match,
push,
};Register it in src/connectors/registry.ts. Idempotency: archiving an already-archived doc is harmless; the runner also de-dupes.
The connector needs the finished book's Reader document id. After reading crosspoint-sync's code, the mechanism is settled:
Title/author metadata match (this is how the framework actually works). crosspoint-sync treats the KOSync document value as an opaque hash — it does not recompute it from the file — and matches books by title/author the firmware sends in the progress metadata (see its connectors/matching.ts: "the EPUB's own title/author … is the real signal"). So the connector builds a candidate pool from the non-archived Reader locations and runs the framework's decideMatch() against the finished doc's title/author.
Why this is near-exact for us (not fuzzy): Readloop authors each EPUB's dc:title to equal the Readwise article title verbatim, so the title the firmware extracts equals the Reader document's title. We still gate at a high confidence threshold (0.85) so a collision never archives the wrong doc.
✅ The connector is written and typechecks against crosspoint-sync's real types — see proposals/crosspoint-sync/ (readwise-reader.ts + notes).
(The earlier "compute a KOReader partial-MD5 hash map" idea is dropped: the server never sees the file and treats the hash as opaque, so it isn't the framework's path. A hash map would only be a fallback if Crosspoint turns out not to send title metadata over KOSync — see open questions.)
Setting Content-Disposition: filename="<title> [rw-<id>].epub" does not work on Crosspoint: confirmed on-device 2026-09-18, Crosspoint ignores the header and names the file from the OPDS entry (aresluna.org - One hundred and thirty-seven seconds – Aresluna.epub), so the [rw-<id>] never reaches DocumentMeta.filename. (Matches upstream PR #2415 "keep server filename" — not default yet.) The Content-Disposition header is kept anyway: harmless on Crosspoint, and readers that honor it (Kobo/KOReader) get nicer filenames.
- ✅ Does Crosspoint save under the server filename? No (2026-09-18) — names by OPDS
<author> - <title>; filename-id ruled out. - ✅ How does the connector map finished→Reader doc? Title/author metadata match — connector written and typechecks (
proposals/crosspoint-sync/). - THE gating unknown: does Crosspoint's KOSync actually send title/author metadata in the progress call? crosspoint-sync's matching assumes the firmware does; confirm on-device. If it doesn't, we'd need a document-hash map (needs Crosspoint's exact hash algorithm) instead.
- Is 98% reachable for short articles on the X3 (does finishing the last page cross
fanout.ts's threshold)? If not, may need a per-connector threshold. - Where will
crosspoint-syncrun — self-hosted on the mini, or the hostedsync.crosspointreader.com? (The connector must be in the build that runs; self-host is needed to add our connector.)
- ✅ Connector written + typechecks (
proposals/crosspoint-sync/readwise-reader.ts). - Self-host
crosspoint-syncon the mini; drop in the connector + register it. - Point the X3's KOSync at it (Settings → System → KOReader Sync). First: verify the progress call carries title metadata (server logs / DocumentMeta) — that gates the whole approach.
- Read an article past 98% → confirm it archives in Readwise and leaves the OPDS feed.
- If solid, PR the connector to
crosspoint-sync. - Validate on-device: read an article past 98% → confirm it archives in Readwise and drops out of the OPDS feed.
- If solid, PR the connector to
crosspoint-sync(they built the framework for exactly this — see their Hardcover/ABS connectors).
Supersedes the earlier plan's standalone rls-kosync repo: the connector belongs inside crosspoint-sync, so v2 is (a) a small Readwise-provider tweak here + (b) a connector PR to crosspoint-sync, not a new repo.
Mirror image of v2 (which pushes finished → archive). Idea: when you've read part of an article in Readwise, a freshly downloaded EPUB should open at that spot.
- Feasible — the plumbing exists.
crosspoint-syncalready does progress fan-in for Audiobookshelf (refreshProgress/fanin.ts): pull a percentage from an external service into the canonical store; the device picks it up on its next sync (which PULLs viaGET /syncs/progress/:document). - Work: make
readwise-readerread-capable (currently write-only) and add a fan-in pull that reads each matched doc'sreading_progress(0–1, from the Reader API) → returns anInboundChange {percentage, finished}. - Caveat — position precision: KOSync resumes to a real xpath position in the specific EPUB, but Readwise only exposes a percentage. crosspoint-sync translates percentage→position from samples harvested during prior device reads, which don't exist for a never-opened download. So on first open you'd get the right status/percentage, but not necessarily an exact-line seek until the device has read it once.
- Scope: v3, after v2 (fan-out/archive) is validated on-device.
- ✅ Phase 1 — pull mechanism built.
readwise-readeris now read-capable withpullProgress()(verified: pulled0.6777for a 68%-read doc). crosspoint-sync's periodic fan-in worker applies it to any already-matched doc → device picks it up on its next sync. So cross-device resume works: a doc the device has synced once, read further in the Readwise app, updates on the device. - ⏳ Phase 2 — first-open resume (the "read 50% in the app, download fresh, resume" case). Needs a match to exist before the first device push, which means seeding it at download time:
- OPDS server computes the EPUB's KOReader binary partial-MD5 (must exactly equal what the device computes — the key risk, verify on-device) and registers
{hash → readwise id}(+ optionally the current progress) with crosspoint-sync. - crosspoint-sync's on-GET refresh (
refresh.ts) is currently hard-coded to BookFusion — generalize it (or rely on the 5-min worker) so the readwise-reader connector is refreshed on pull.
- OPDS server computes the EPUB's KOReader binary partial-MD5 (must exactly equal what the device computes — the key risk, verify on-device) and registers
- Caveat (both phases): Readwise gives only a percentage; exact-line seek depends on a position sample from a prior device read (fresh downloads resume by percentage, not necessarily the exact line).
First-open resume is implemented AND confirmed on-device (2026-09-19): a freshly downloaded article pulled its seeded position and jumped 0% -> ~55%. Note the landing is approximate: Readwise reading_progress (68%) and Crosspoint's byte-position percentage (~55%) are different metrics, so resume lands in the right neighborhood, not the exact line. Perfect alignment isn't achievable (Readwise's % is opaque). Server-side: cold-build of a 68% doc seeded a 67.8% row under the matching KOReader hash. The hash de-risk that made it possible:
Crosspoint computes the KOSync document (binary method) as a KOReader-style partial-MD5 — lib/KOReaderSync/KOReaderDocumentId.cpp: read 1024-byte chunks at offsets getOffset(i) for i = -1..10 (i<0 → 0; else 1024 << (2*i) = 1024·4^i), MD5 the concatenation. Replicated in Node and it produced the exact device hash for a freshly downloaded article (Oreos: 79c3f0ac… == device). So the OPDS server can compute the device's hash from the bytes it serves.
Note: epub-gen stamps a random dc:identifier per build, so the same article regenerated hashes differently — hash the exact bytes served (at serve time), don't re-generate.
Phase-2 build (revised, simplest path):
- In the Readwise provider's
content.epubroute, after building the file: computekoHash(file), fetch the doc'sreading_progress, and seed a progress row in crosspoint-sync keyed by that hash (percentage + a synthetic position;device='readloop'). - First-open
GET /syncs/progress/<hash>returns it → device resumes. No fan-in/refresh.ts change needed. - Seeding mechanism (TBD): direct write to crosspoint-sync's SQLite (co-located; needs busy-timeout) vs. a small authenticated seed endpoint on crosspoint-sync. In rls-desktop both are one app so it's internal.
// KOReader/Crosspoint binary document hash (verified against a real device hash)
function koHash(path){
const crypto=require("crypto"), fs=require("fs");
const CHUNK=1024, OFFSET_COUNT=12;
const fd=fs.openSync(path,"r"), size=fs.fstatSync(fd).size, md5=crypto.createHash("md5"), buf=Buffer.alloc(CHUNK);
for(let i=-1;i<OFFSET_COUNT-1;i++){ const off=i<0?0:CHUNK*(4**i); if(off>=size)continue;
const n=fs.readSync(fd,buf,0,Math.min(CHUNK,size-off),off); if(n>0)md5.update(buf.subarray(0,n)); }
fs.closeSync(fd); return md5.digest("hex");
}