On this page
skryba writing-style harvest
Collect the person's own writing from where it accumulates by itself — a
folder of prose, the messages they send, the notes they keep, what they type
to coding agents — into the writing-style store,
by hand or on a Stado schedule, without anyone running add again.
Invocation
skryba writing-style harvest add <NAME> --kind <files|messages|notes|transcripts> [--path <PATH>] --min-words <N> --max-words <N>
skryba writing-style harvest list
skryba writing-style harvest show <NAME>
skryba writing-style harvest edit <NAME> [--path <PATH>] [--min-words <N>] [--max-words <N>] [--reset]
skryba writing-style harvest remove <NAME>
skryba writing-style harvest run [<NAME>...]
skryba writing-style harvest schedule show
skryba writing-style harvest schedule set --cron <CRON> --tz <TZ> --host <HOST>
skryba writing-style harvest schedule remove
Kinds
| Kind | --path |
What a run reads |
|---|---|---|
files |
a file or directory, required | a file read whole; a directory read exactly as add reads one. On a Brama route, one typed decision-model request selects include/skip for each entry; it needs --brama-url and BRAMA_TOKEN_ROLE, not a chat --model. A direct model service needs its URL, bearer file and --model for its JSON selection. A missing route or credential fails that harvester with its cause; a directory is reread whole every run, and what is already held counts in skipped |
messages |
a macOS Messages chat.db, required |
the messages the person sent after the saved message id; short sent text remains an unfinished run per chat until later sent text reaches --min-words, even across scheduled runs; a reply ends that run without importing the other person's words |
notes |
a macOS Notes NoteStore.sqlite, required |
every note's text; reread whole every run, so an edited note adds what changed |
transcripts |
a Transcript Lake data directory, optional | user-role turns with recorded personal authorship, at or after the last timestamp; rows with recorded synthetic, agent or sidechain origin are excluded, not guessed from their prose |
Transcript import requires the Lake event's extra.attribution to be user.
A user role without that source-recorded authorship is not evidence of who
wrote the words, regardless of runtime; it is excluded rather than guessed.
An already ingested event without attribution is not retroactively enriched
by a newer adapter.
Messages are read with the imessage-database crate, which tracks Apple's
schema and decodes the archived body newer messages keep their text in;
reactions, announcements and SharePlay rows are skipped. A sent row without a
chat association keeps its own message id as its source instead of joining
unrelated messages under an invented chat. An undecodable sent body fails the
harvester with its row id and underlying decoder cause. Notes keep each body
as a gzip-compressed protocol buffer, and its text is the longest plain-text
string in it. A note whose body cannot be read from SQLite, decompressed,
parsed or read as text is skipped with its native row id, failed operation and
cause in unreadable_notes;
the count alone is not a diagnosis. Both databases are opened read-only.
Reading them needs Full Disk Access for the process that runs Skryba — the
terminal, or the Stado
worker for a scheduled run; without it the database refuses to open and the
run says so.
Transcript Lake owns runtime-specific parsing. Skryba imports only canonical
user events with extra.attribution=user, and excludes recorded synthetic,
sidechain and programmatically sourced prompts. It does not infer authorship
from words, Markdown, links or punctuation: a human-authored turn may contain
any of these. Source adapters such as OMP, Pi-family, Kimi and Qwen retain
explicit author provenance; older or unlabelled user events, including
records whose native format does not establish authorship, are not silently
treated as your writing. A tool result is not a user event. The transcript
cursor replays its last
timestamp so a later turn sharing that timestamp is not lost; passages
already imported count as skipped. --reset re-reads from the beginning
after a source has been reclassified or migrated.
Every harvested passage is cut by the harvester's own --min-words and
--max-words with the same rule add uses. A Messages cursor carries the
highest observed message id and the still-short author text for each chat.
When the run reaches the chosen floor, it imports that author text and clears
the pending fragment; lowering the floor can make an existing fragment ready
on the next run even without a new message. A reply discards a still-short
fragment. --reset clears the cursor and fragments and rereads the source.
A passage carries its kind (files, messages, notes, transcripts),
which the sample plan divides by. The source is
the original file path for files, and <kind>:<name>/<item> otherwise.
Use skryba writing-style sources to see the exact path, then
skryba writing-style remove <FILE_PATH> --yes to take back that file's
writing. For Messages, Notes or transcripts, remove <kind>:<name> --yes
takes back every passage that harvester contributed. Removing a harvester
registration alone leaves its passages in place.
Commands
add registers a harvester and reads its source once to prove it can be
read, with the same reader run uses; nothing is added yet. edit changes
the path or bounds, and --reset forgets where the last run stopped, so the
next run reads the source from its start. Changing a path requires --reset:
the previous source's cursor and unfinished Messages text cannot apply to the
new one. An edit refuses if a run or another edit changed that registration
after it was read; it does not erase a newer cursor or unfinished Messages
text. remove stops harvesting one place only if the registration is still
the one it read; a concurrent run or edit refuses the stale removal. Passages
it added stay.
run reads every harvester, or the named ones, from where each stopped.
For each source, it commits new passages and the cursor together only while
the registered path, bounds and previous cursor still match what it read.
An edit, reset or another run cannot admit stale writing or advance a stale
cursor: the command reports the observed source state and leaves that run's
passages out. One harvester failing does not stop the others: they add their
writing, and the command then fails naming every harvester that did and why.
schedule set has Stado run skryba writing-style harvest run on a five-field
cron in an IANA time zone, pinned to one Stado registry host — the machine
whose files, Messages and Notes the harvesters read — with overlapping runs
skipped. The command Stado runs is this skryba binary's own path, shell-quoted
even when its path or schema contains spaces or apostrophes, with
SKRYBA_DATABASE_SCHEMA when one is set. Skryba keeps only the schedule's id;
schedule show reads cron, host, next due time, last fire and last submitted
job id back from Stado, then reads that job's current state, terminal flag and
recorded error with stado machine status <JOB_ID>. A failed status read is
reported separately as last_job_read_error, rather than calling the job
healthy. A fire or a submitted job is not proof of a successful import:
inspect its log with stado job watch <JOB_ID> --json.
schedule remove deletes the Stado schedule and clears its registered id.
If a previous Stado deletion succeeded but Skryba could not clear that id,
retrying schedule remove reads the complete Stado schedule list: only an
observed absence clears the stale registration. If Stado cannot be read or
still lists the schedule, Skryba keeps the id and names both operations.
To run a schedule now without waiting for the cron, use
stado schedule run --retry-token <TOKEN> <ID> --json, then
stado job watch <JOB_ID> --follow --json; check the terminal job outcome and
skryba writing-style sources for imported passages. Without Stado, run
harvest run from any scheduler.
Output
The writing-style report, with:
harvesters [{name, kind, path, min_words, max_words, cursor, last_run_at,
last_added, created_at, updated_at}]
harvested [{harvester, kind, read, unreadable, unreadable_notes?,
added, skipped, cursor}]; run only; details absent when none failed
schedule {schedule_id, cron, tz, host, command, enabled, next_due_at,
last_fired_at, last_job_id, last_job?: {state, terminal, error},
last_job_read_error?, fire_count}; show and set only
schedule_removal {schedule_id, already_absent}; remove only. A true
already_absent means Stado's complete list verified absence
before Skryba cleared its stale record.
For Messages, both reports display only the last observed message id as
cursor. The database also retains still-short author text per chat so the
next run can finish it; that private text is not printed in CLI reports or
scheduled job logs. A stored numeric cursor from an earlier version is
migrated with an empty unfinished-run state; use harvest edit <NAME> --reset
to revisit earlier short runs, with already imported text deduplicated.
Example: learn from how you text
Choose word bounds for each source and a schedule for the host that actually holds it. Skryba does not infer these values from a different author's text.
skryba writing-style harvest add my-messages --kind messages \
--path ~/Library/Messages/chat.db --min-words <MESSAGE_MIN> --max-words <MESSAGE_MAX>
skryba writing-style harvest add my-notes --kind notes \
--path "~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite" \
--min-words <NOTE_MIN> --max-words <NOTE_MAX>
skryba writing-style harvest add agent-chats --kind transcripts \
--min-words <TRANSCRIPT_MIN> --max-words <TRANSCRIPT_MAX>
skryba writing-style harvest run
skryba writing-style harvest schedule set --cron <CRON> --tz <ZONE> --host <HOST>
Refusals
harvester name <NAME> is not valid: use lowercase letters, digits or hyphens, starting with a letter.a harvester needs a positive --min-words no larger than --max-words, not <MIN>..<MAX>.--max-words <MAX> exceeds the database's BIGINT range (at most <LIMIT>)— bounds must fit the persisted column rather than wrap.a <KIND> harvester needs --path naming what it reads— every kind buttranscripts.files harvester path <PATH> does not exist.opening the Messages database <PATH> (macOS lets a process read it only with Full Disk Access: grant it to the terminal or the Stado worker running skryba in System Settings › Privacy & Security)with SQLite's cause below it — a wrong path, or a process without Full Disk Access; andreading the message table of <PATH>.decoding sent message <ID> in <PATH>with the decoder's error — the message could not be read as text, so the harvester does not advance its cursor past it.opening the Notes database <PATH> (macOS lets a process read it only with Full Disk Access: …)in the same words, andreading the note bodies of <PATH>, each with SQLite's cause.unreadable_noteslists{id, operation, cause}for each skipped native note: reading note body, gzip decompression, protocol-buffer parse or plain-text extraction. The other readable notes still import; inspect the recorded row id and cause rather than treatingunreadableas proof they were read.transcript-lake is not installed: install it with `stado product install transcript-lake`, or put the `transcript-lake` binary on PATH.;Transcript Lake data directory <PATH> is not a directory;`transcript-lake query` exited with <STATUS>: <STDERR>.- A successful
transcript-lake query --jsonmust return UTF-8 JSON rows ([]is an empty result). Non-UTF-8 output, no JSON result, or a result that is not rows ofts,session_idandbodyrefuses that harvester; its cursor is not advanced. Inspect the Lake command's output and state instead of treating an empty stdout as no authored turns. Transcript Lake user turn at <TIMESTAMP> has no session id; cannot identify its source— Skryba refuses the transcript harvester rather than merging an unattributed turn into a guessed source.harvester <NAME> already exists in <STORE>; change it with `skryba writing-style harvest edit <NAME>`.harvester <NAME> is not in <STORE>; held harvesters: <NAMES>; add it with `skryba writing-style harvest add …`—show,edit,removeand a namedrun.harvest edit needs --path, --min-words, --max-words or --reset; nothing was asked to change.no harvester is registered in <STORE>; add one with `skryba writing-style harvest add …`—runwith nothing registered.harvest run: <F> of <N> harvesters failed; the others added <A> passages to <STORE>:followed by one line per failed harvester with its cause.harvester <NAME> changed or left <STORE> while its run was reading; original <SOURCE>, observed <SOURCE>; no passages or cursor from this stale run were committed— the source was edited, reset, removed or advanced by another run. Both source descriptions include the public cursor position, not still-short private Messages text. Rerun against the observed source.harvester <NAME> changed or left <STORE> while it was edited; original <SOURCE>, observed <SOURCE>; retry against the observed source— an overlapping run, edit or removal changed the registration. The descriptions show only public cursor positions.harvester <NAME> changed or left <STORE> while it was removed; original <SOURCE>, observed <SOURCE>; retry against the observed source— removal did not delete a newer registration or pending cursor. The descriptions show only public cursor positions.changing harvester <NAME>'s path in <STORE> requires --reset: its saved cursor and unfinished author text belong to the previous source— choose--resetto read the new source from its start.Stado does not know host <HOST>; `stado registry host list` names the hosts it does, with Stado's own refusal below it.a harvest schedule already exists in <STORE>: Stado schedule <ID> runs `<COMMAND>` at <CRON> on <HOST>; remove it with `skryba writing-style harvest schedule remove` first.- If two callers set a schedule concurrently, only one id is recorded. The other receives
a harvest schedule was recorded concurrently in <STORE> (held schedule: <ID>); the newly created Stado schedule must be removed; Skryba attempts that removal and reports whether it succeeded. If the competing record was already removed, the refusal says that no id remains. no harvest schedule is recorded in <STORE>; set one with `skryba writing-style harvest schedule set --cron <CRON> --tz <TZ> --host <HOST>`.stado is not installed: a harvest schedule is a Stado schedule; install Stado, or run `skryba writing-style harvest run` from your own scheduler, and`stado <ARGS>` exited with <STATUS>: <STDERR>for every Stado refusal.- If Stado creates a schedule but Skryba cannot record its id,
schedule setremoves it and reports the recording error. If that rollback also fails, the refusal names the Stado schedule id and both errors. If Stado removes a schedule but Skryba cannot forget its record,schedule removenames the id and storage error; a laterschedule showrefuses with the recorded id and Stado's read failure instead of pretending the missing schedule still runs. Retryschedule removeto verify absence againststado schedule list --jsonand clear the stale id. If the list is unreadable or still contains the id, the refusal names the Stado removal and list causes and leaves the registration intact; if the row changed concurrently, the refusal names the original and observed ids instead of deleting another schedule.
Maintained as part of the website-owned Skryba documentation corpus.