Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .claude/skills/sync-reader/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
name: sync-reader
description: Sync the personal reading curriculum from the print share to the USB e-reader — extracts the cited range of each active reading-chunk into a per-chunk excerpt epub, priority-named, and retires resolved chunks. Trigger on `/sync-reader [reader_dir share_dir]` or "sync my reader".
---

# sync-reader

Mirrors the active reading curriculum onto the USB e-reader. The intention graph
carries the curriculum as `tactic-reading-chunk-*` nodes
(`attributes.curriculum = {priority, passages: [{work, range}]}`); the author's
DRM-free epubs live on a network "print share". This skill matches each cited
work against a share epub, extracts the cited range at table-of-contents section
granularity into one excerpt epub per chunk, and syncs the active set into a
managed `<reader_dir>/commons-curriculum/` directory with priority-prefixed
filenames — deleting excerpts for retired chunks and never touching anything
outside that managed subdirectory.

## Run it

All logic lives in the package `@commons-systems/sync-reader` and the thin CLI
below. The reader and share are outside the sandbox filesystem allowlist (USB
and network mounts), and writing the config file is too, so run with
`dangerouslyDisableSandbox`:

```
npx tsx .claude/skills/sync-reader/scripts/sync-reader.ts [reader_dir share_dir]
```

- With no arguments, the mounts come from
`<project-root>/dispatch.config/sync-reader.json`.
- With two arguments, they are used directly (`reader_dir` then `share_dir`).

Relay the printed report to the user **verbatim** — it is the author-facing
result. Its groups are:

- **SYNCED** — excerpt written or unchanged (with the filename).
- **MISSING WORK** — no share epub matched; the author acquires a DRM-free epub.
- **AMBIGUOUS** — two-plus epubs matched; the author disambiguates share
filenames/metadata.
- **UNMAPPED RANGE** — the citation could not be resolved to a section; the
author checks it against the epub's table of contents.
- **DELETED (retired)** — excerpts removed because their chunk is gone or done.

Missing/ambiguous/unmapped items are report content, not failures — the command
still exits 0. A non-zero exit means an environment/config/graph-shape error
(the message names the cause).

## First-time setup

If the run reports no config, copy the template and fill in the two paths:

```
cp .claude/skills/sync-reader/sync-reader.example.json \
<project-root>/dispatch.config/sync-reader.json
```

- `reader_dir` — the USB e-reader mount (e.g. `/media/<user>/<device>`).
- `share_dir` — the print-share mount holding the epubs (e.g.
`/mnt/print-share`).

The excerpt filename for a chunk is
`<zero-padded-priority>-<chunk-id-without-tactic-prefix>.epub` (e.g.
`04-reading-chunk-3-kant-humanity-servility.epub`).
179 changes: 179 additions & 0 deletions .claude/skills/sync-reader/scripts/sync-reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// /sync-reader CLI — sync curriculum reading excerpts from the print share to
// the USB reader. Thin orchestration over @commons-systems/sync-reader.
//
// npx tsx .claude/skills/sync-reader/scripts/sync-reader.ts [reader_dir share_dir]
//
// With no positional args, the reader/share mounts come from
// dispatch.config/sync-reader.json (via loadConfig). With two positional args,
// they are used directly. The intentions store is resolved from this file's own
// location, never cwd. Exit 0 when the run completes (missing/ambiguous/
// unmapped items are report content, not failure); exit 1 only for
// environment/config/graph-shape errors.

import { readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
applyMirror,
buildExcerpt,
chunkWorks,
desiredFilename,
loadConfig,
managedDirFor,
mapRangeToSections,
matchWork,
openEpub,
planMirror,
readActiveChunks,
renderReport,
type ChunkOutcome,
type EpubSource,
} from "@commons-systems/sync-reader";

// The script lives at `.claude/skills/sync-reader/scripts/sync-reader.ts`, so
// the repo root is four directories up. Resolve from this file, never cwd.
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = dirname(dirname(dirname(dirname(scriptDir))));
const intentionsDir = join(repoRoot, "intentions");

function fail(message: string): never {
process.stderr.write(`sync-reader: ${message}\n`);
process.exit(1);
}

function resolveMounts(args: string[]): { reader: string; share: string } {
if (args.length === 0) {
const cfg = loadConfig();
if (cfg.kind === "no-config") {
fail(
"no reader/share configured. Create dispatch.config/sync-reader.json " +
"(copy .claude/skills/sync-reader/sync-reader.example.json and fill in " +
"reader_dir and share_dir), or pass them: sync-reader <reader_dir> <share_dir>.",
);
}
return { reader: cfg.reader_dir, share: cfg.share_dir };
}
if (args.length === 2) return { reader: args[0], share: args[1] };
fail("usage: sync-reader [reader_dir share_dir]");
}

function assertMount(path: string, label: string): void {
let st;
try {
st = statSync(path);
} catch {
fail(`${label} "${path}" is not accessible — verify the mount is present.`);
}
if (!st.isDirectory()) fail(`${label} "${path}" is not a directory.`);
}

function findEpubs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...findEpubs(p));
else if (entry.name.toLowerCase().endsWith(".epub")) out.push(p);
}
return out.sort();
}

async function main(): Promise<void> {
const { reader, share } = resolveMounts(process.argv.slice(2));
assertMount(reader, "reader_dir");
assertMount(share, "share_dir");

const chunks = readActiveChunks(intentionsDir);

// Open every share epub once for metadata + section extraction. A corrupt
// share file surfaces as a clear error (naming the file) rather than being
// silently skipped.
const sources: EpubSource[] = [];
for (const path of findEpubs(share)) {
sources.push(await openEpub(path));
}

const desired = new Map<string, Uint8Array>();
const pending: { chunkId: string; work: string; filename: string }[] = [];
const outcomes: ChunkOutcome[] = [];

for (const chunk of chunks) {
// A chunk recorded before its passages are structured has nothing to
// extract yet — surface it so the author fills it in, rather than skipping.
if (chunk.passages.length === 0) {
outcomes.push({ kind: "incomplete", chunkId: chunk.id });
continue;
}
// A chunk citing passages from more than one work cannot be excerpted to a
// single source epub: matching only the first passage's work would map the
// rest against the wrong epub (a silent wrong excerpt). Report it as
// unsupported instead — clear error over defensive fallback.
const works = chunkWorks(chunk);
if (works.length > 1) {
outcomes.push({ kind: "multi-work", chunkId: chunk.id, works });
continue;
}
const primary = chunk.passages[0];
const match = matchWork(primary.work, sources);
if (match.kind === "missing") {
outcomes.push({ kind: "missing", chunkId: chunk.id, work: primary.work });
continue;
}
if (match.kind === "ambiguous") {
outcomes.push({
kind: "ambiguous",
chunkId: chunk.id,
work: primary.work,
candidates: match.indices.map((i) => sources[i].path),
});
continue;
}

const source = sources[match.index];
const spineSet = new Set<number>();
let unmapped: { range: string; reason: string } | null = null;
for (const passage of chunk.passages) {
const mapped = mapRangeToSections(passage.range, source.toc, source.spineItems.length);
if (mapped.kind === "unmapped") {
unmapped = { range: passage.range, reason: mapped.reason };
break;
}
for (const idx of mapped.spineIndices) spineSet.add(idx);
}
if (unmapped) {
outcomes.push({
kind: "unmapped",
chunkId: chunk.id,
work: primary.work,
range: unmapped.range,
reason: unmapped.reason,
});
continue;
}

const spineIndices = [...spineSet].sort((a, b) => a - b);
const ranges = chunk.passages.map((p) => p.range).join(", ");
const title = `${primary.work} — ${ranges} (chunk ${chunk.priority})`;
const bytes = await buildExcerpt(source, spineIndices, { title });
const filename = desiredFilename(chunk.id, chunk.priority);
desired.set(filename, bytes);
pending.push({ chunkId: chunk.id, work: primary.work, filename });
}

const managed = managedDirFor(reader);
const plan = planMirror(desired, managed);
applyMirror(plan, managed);

const written = new Set(plan.writes.map((w) => w.filename));
for (const p of pending) {
outcomes.push({ kind: "synced", ...p, wrote: written.has(p.filename) });
}

process.stdout.write(renderReport({ outcomes, deleted: plan.deletes }));
process.stdout.write("\n");
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((err: unknown) => {
fail(err instanceof Error ? err.message : String(err));
});
}
4 changes: 4 additions & 0 deletions .claude/skills/sync-reader/sync-reader.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"reader_dir": "/media/<user>/<device>",
"share_dir": "/mnt/print-share"
}
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"packages/htmlutil",
"packages/idbutil",
"packages/intentionsutil",
"packages/sync-reader",
"landing",
"packages/local-first",
"packages/mediautil",
Expand Down
1 change: 1 addition & 0 deletions packages/sync-reader/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "@commons-systems/config/eslint";
20 changes: 20 additions & 0 deletions packages/sync-reader/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@commons-systems/sync-reader",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/"
},
"dependencies": {
"@commons-systems/intentionsutil": "*",
"@xmldom/xmldom": "^0.8.13",
"jszip": "^3.10.1",
"unzipit": "^1.4.3"
}
}
Loading
Loading