The server half of the assistants (points 4 to 8, D27 to D46): the
operator sets language models in deploy/assistants.env, grants them to
people with limits, and the module forwards their questions with the
operator's key, counting what each used, never what was asked.
- assistantsFrom: ASSISTANTS, ASSISTANT_ID_KEY, ASSISTANT_<ID>_NAME,
_PROTOCOL (anthropic, openai, openai-responses), _BASE_URL, _MODEL,
_KEY, _IMAGES, _PRICE_INPUT and _PRICE_OUTPUT (per million tokens),
ASSISTANT_CURRENCY, ASSISTANT_OWN_KEYS; anything unusable stops the
server as it starts, naming the variable.
- The assistant endpoint (identified): offered, agree (D40), ask as a
Serverpod stream (D35): the text as the model writes it, then the
whole turn, sent once the request is counted; figures, the caller's
own. Checked in order: at most 2 questions on their way per person
(tooManyAtOnce), the grant, the agreement, limits in the pricing's
unit now (limitOutdated), the month's and the day's limits (with when
they reset, UTC), the question (malformed, imagesNotRead, tooLarge
past 400,000 characters, an image over 3,932,160 bytes or more than
20 images); output capped at 16,384 tokens. Each request carries the
person's opaque ID (HMAC-SHA256 under ASSISTANT_ID_KEY, D30). Once
sent, the provider's reply is read to its end even if the app
cancels, and counted on a session of its own; a provider's failure is
logged by kind and status, told as providerKey, providerBusy (with
its wait), providerRefused or providerFailed.
- The assistantOperator endpoint (identified and operator, on the
register): setUp, grants, grant (assistantNotSet, invalidLimit,
notFound), revoke, figures of a UTC month.
- Tables assistant_grant and assistant_figures (per person, assistant
and UTC day), in the module's, Learn's and Todo's migrations. The
daily pruning keeps this month and the two before
(forgetOldAssistantFigures, D41); deletePerson deletes both.
A member offers no assistant (D43).
- instance.basics() gains assistantOwnKeys.
- /privacy (D39, addPrivacyPageRoute, on the register's web server,
before the profile pages' route): made from the instance's settings,
English or German, no JavaScript: who may sign up, where the data and
backups are, what the module keeps (the account, sessions, IP
addresses, the server's log), the host's privacyPagePoints (Learn's:
studysets synced, private ones encrypted on the devices with what
stays readable, what is published, dictionary lookups, the learner
count), each assistant with its model and host, what goes and what is
counted, and whether personal keys are allowed. Never a key.
Tests: 70 on the assistants (grants, limits, refusals in order,
streaming, cancelling, counting, concurrency, the operator's denials,
deletion, pruning), 19 on the settings, 8 on the privacy page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A pure Dart package, no SDKs, one interface (LlmClient) over Anthropic's
Messages API, OpenAI's chat completions, in each known host's dialect and
the local servers' (D45), and OpenAI's Responses API, stateless (D46). A
model's turn keeps what its provider wants back with the tools' results:
Anthropic's thinking blocks, the Responses API's reasoning items and
message phase, Gemini's thought signatures. Failures come by kind, the
provider's words cut and without keys.
Tested on replies written from the providers' documentation, replayed in
pieces of 1, 7 and 4,096 bytes; tool/record_llm.dart records real ones
with a key of one's own, which the tests then run on too (D36).
FakeLlmClient for the code that uses it. BSD-3-Clause, as halkyon_oidc.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Whoever operates the instance approves, refuses and suspends accounts
and names other operators in the app (D21 to D24, D34):
- halkyon_app: InstanceOperatorRepository on
ServerpodInstanceOperatorGateway (the module's instanceOperator
endpoint; refusals as OperatorFailed: notFound, isOperator,
lastOperator, notAllowed for 401 and 403, failed), in AppDependencies.
OperatingCubit counts the accounts that wait: asked as the person's
app opens, after every change, and every 5 minutes only while they
operate the instance, so nobody else's app keeps asking.
InstanceOperatorCard (or its tiles) and WaitingAccountsBadge show
nothing at all for anyone else. InstanceOperatorScreen, laid out as
Settings: the accounts that wait, each showing its four words to read
out before Approve (D33), Refuse for new ones after asking; who
operates, Take back after asking, the last always kept; Name an
operator by handle; Find an account by the start of its handle, with
Suspend after asking and Name as operator. English and German texts
("Admin" as the role word in German).
- Learn: the card right after the Sync card in Settings, which the
cogs' count leads to as their dot leads to Sync; the count on the
Settings cogs of Home and Profile.
- Todo: the tiles in the account sheet and the count on its button.
Todo's server is a member, whose `operating()` is always null, so
nothing shows there until it holds the register.
Tests: the gateway's mapping, the repository, both cubits with their
denials, the card, the badge and the screen; Learn's Settings shows the
card and the count to an operator and neither to anyone else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Every account signs in with SimpleLogin or a password now, so GitHub
goes from the module, the shared app package, Learn and Todo, ahead of
ADR 0017's GITHUB_SIGN_IN_ENDS.
- halkyon_base: no `gitHubSignIn` endpoint, GitHub identity provider or
GitHub refusals; no `githubClientId`/`githubClientSecret`, no
`GITHUB_SIGN_IN_ENDS` and no `forgetGitHubAccounts`; the register
loses `account-with-github`. `SignInWays.gitHub`, the basics'
`gitHubClientId` and `gitHubSignInEnds`, and `PasswordRefusal`'s
`signInAgain` and `noAddress` go. A first password takes a code
mailed to the person's own address; an account without one sets none
(`wrongCredentials`).
- Learn's server: `instance.info()` without `gitHubClientId`, the
pruning without GitHub, and `minimumAppBuild` 24, as builds up to 23
(v0.20.0) require `SignInWays.gitHub`: the server deploys with the
release of build 24.
- halkyon_app: no GitHub button or problems on the start screen, no
password offered after a GitHub sign-in, and `ServerpodAuthGateway`
without the browser half only GitHub used (`(client, base:)`). The
account section offers a first password only to an account with an
address. `LoopbackWebAuth` and the apps' schemes stay, SimpleLogin's
ways back.
- Learn's Profile lists no GitHub; the texts go (English, German).
- Serverpod's `serverpod_auth_idp_github_account` stays, as its
migrations make it; a row goes with its account, and ADR 0020's D36
gives operators the SQL to delete those left.
- deploy/: compose.yaml and .env.example without GitHub. The READMEs,
AGENTS.md files and ADR 0017 amended.
BREAKING CHANGE: builds up to 23 can't read the instance's answers
about an account's ways in; minimumAppBuild is 24.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The server's half of D25: every account has a way in, and the anonymous
accounts left from before go with the daily pruning.
- The module signs nobody in anonymously: `anonymousIdp` and
`gitHubUpgrade` are gone, and so is linking GitHub at the register.
- A password is set only by someone whose address is theirs (a
`firstPassword` code) or who signs in with GitHub alone (a fresh
sign-in and a `signUp` code); an anonymous account is refused
`wrongCredentials` before any code is used.
- SimpleLogin refuses to link onto a caller without `identified`
(`wrongCaller`), the member's endpoint too, so nothing turns an
anonymous account into one with a way in.
- `pruneAnonymousPeople` deletes every anonymous account left, however
new, never one identified. Learn and Todo schedule it as
`prune-accounts` and cancel the old `prune-abandoned-anonymous-people`
entry once.
- Learn: someone without `identified` keeps no files;
`FILE_LIMIT_ANONYMOUS_MB` is gone. `minimumAppBuild` is 23, as builds
up to 22 start with an anonymous account.
- Learn's sync tests make their person and session by SQL in the
development database, as Serverpod stores sessions.
- Docs, store listings and ADRs 0010, 0014, 0016, 0017 and 0019 amended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
OidcBrowser opens the page of an attempt the server began (OidcStart),
waits until the browser comes back by an OidcReturn (the app's scheme,
a verified https address, or the loopback) and returns the provider's
code (OidcCode), its refusal (OidcDenied), or nothing when the person
closed the browser. It checks the returned state against the attempt's,
as the server never compares it; a wrong state, neither one code nor
one refusal, no browser or a failed way back are OidcCallbackExceptions.
halkyon_app's LoopbackWebAuth moves in with its tests, relicensed from
AGPL v3 to BSD-3-Clause by its author, the developer; GitHub's sign-in
keeps using it from there until it ends. The package is BSD-3-Clause,
publish_to: none, as the module (ADR 0023, D2, D3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
halkyon_oidc_server and its generated client halkyon_oidc_client: any
provider following OpenID Connect's code flow, beside Serverpod's own
providers, with a preset for SimpleLogin (no PKCE, no `typ`). Nothing
of Halkyon's is in it; BSD-3-Clause, `publish_to: none` for now.
- Attempts in `halkyon_oidc_attempt` (ADR 0020's D13): the ID
PostgreSQL's gen_random_uuid(), a state and a nonce of 32 random
bytes, PKCE's verifier where used, the caller if signed in; ten
minutes, taken once. A host may begin the state with a marker.
- The code exchanged by the module itself, so a provider out of reach
(a timeout, a 5xx) is told apart from a refused code; the ID token
checked with dart_jsonwebtoken's algorithms (ADR 0020's D12): the
provider's algorithms alone, the key its `kid` names, of the right
type; issuer, audience and `azp`, expiry, time issued, `nbf`, a
minute's leeway; the attempt's nonce; a `sub`.
- Keys kept an hour, fetched again for an unknown `kid` at most once
a minute; discovery checked against the issuer; every request with
a timeout.
- Identities in `halkyon_oidc_identity`: the provider's key and the
`sub`, one per person and provider, deleted with the person.
- The stock endpoint (`OidcIdpBaseEndpoint`), and the parts for a host
with rules of its own: begin, verify, keep and takeKept, link,
accountOf, unlink; merging people; a limit per network.
- The forwarding page, moved here from halkyon_base in the next commit.
The package's main library is the code generator's, as halkyon_base's;
a host imports `package:halkyon_oidc_server/oidc.dart`. ADR 0023's
point 5 no longer names OAuth2PkceUtil, which the module doesn't use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
On 22 Oct a cancelled job left its analysis server running in the
runner, 4 GB, until someone noticed. forgejo-runner v13.1.0 starts every
host step as a process group and sends the group SIGKILL on a cancel or
at timeout-minutes (act/common/cmdgroup.go), and after that no later step
runs, `if: always()` ones neither (they get the cancelled context; only
actions' post steps get a fresh one), so a cleanup at the job's end
can't be relied on. The check job's second step, after the sandbox's
proof, stops every process of a job's working folder (`$HOME/work/`,
also one removed since) that is neither the runner nor the step itself,
TERM then KILL after 5 s, and names each in the log. The runner takes
one job at a time (capacity: 1), so nothing of a running job is touched.
No change on the server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
On 23 Oct a push went red in CI because an endpoint's changed comment
hadn't been generated into the module's client: only CI compared the
committed generated code with the generator's output. tool/check.dart now
runs every server's generation (Melos' `generate`, about 15 s) after
fetching the packages and fails, naming the files, where the working
tree's generated code changed by it; the files stay written anew, to be
committed. It compares the files that differ from the committed ones
before and after by their content, so uncommitted changes count as they
do everywhere else in the checks.
It refuses a Serverpod CLI at another version than the lock holds
Serverpod at (`dart install serverpod_cli@<version>`), and the root's
tests hold CI's SERVERPOD_VERSION to the lock. CI keeps installing the
CLI at that version and puts it on the job's PATH; its own comparison
step goes, as the checks run it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Build 21 (v0.18.0) was the release tool/release.sh was kept for, the one
going with ADR 0014's breaking deploy; every release since is
`dart run tool/release.dart learn`. The comments that named the script
(the F-Droid script, the certificate pin, the Gradle signing) now name
the Dart tool.
Learn's release uploads the snap to the Snap Store's edge channel last,
where v0.18.0 went by hand; the release refuses to start unless
`snapcraft whoami` shows a login.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
ADR 0017's step-3 section: codes by mail sign nobody in, the ways in
for accounts without a working password, changes while signed in,
sessions ended in every service, the apps' screens (the QR package and
the alternatives considered), the keys in the keyring, anonymous
people's keys waiting for step 4, and libsodium on devices (results
copied before they leave an isolate; a C compiler and make for every
build). The module's and halkyon_app's READMEs and the AGENTS files say
the same; the snap's build installs make for sodium's build hook.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
ADR 0017 records the decisions taken in building (SRP's identity, the
decoys, the 72-hour account reset proposed for confirmation, GitHub's
deprecation) and what step 3 must do; the module's README and the
AGENTS files say how password sign-in works.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
ADR 0017's step 1, on libsodium (sodium 4.1.0+1, whose build hook
compiles the libsodium 1.0.22 it ships: a C compiler and make wherever
it builds):
- Argon2id (64 MiB, 3 passes, kept with the account and never below
that) makes a master key, which libsodium's KDF splits into SRP's
secret (x) and the key-encryption key.
- The account's X25519 and Ed25519 key pairs, their private halves
wrapped with XChaCha20-Poly1305 under the KEK and under the recovery
key, bound to their public keys.
- A 24-word BIP-39 recovery phrase (the English list, MIT) and the
recovery key derived from it.
- Data keys sealed to the account with crypto_box_seal, bound to their
IDs; content in ADR 0002's envelope, encrypted with a data key and
signed with Ed25519 (detached), bound to a context the caller names.
- Secrets in libsodium's guarded memory where its API takes them.
- Tests: libsodium's Argon2id known answers, BIP-39's English vectors,
round trips and every refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
ADR 0017's step 1. Generic over group and hash (SrpGroup); production
takes RFC 5054's 3072-bit group with SHA-256. k = H(N | PAD(g)),
u = H(PAD(A) | PAD(B)), K = H(PAD(S)),
M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K),
M2 = H(PAD(A) | M1 | K). A and B must lie in 1..N-1 and u must not be
0; a and b are 256 bits from Random.secure(); proofs are compared in
constant time. x comes from the caller (halkyon_keys makes it); RFC
5054's own x is there for the vectors only.
- The register's half in halkyon_base_server (base.dart: srpChallenge,
srpVerify), the device's written by hand in halkyon_base_client
(srp.dart: SrpClient, srpVerifier). Serverpod's generator cleans and
writes only a client's lib/src/protocol/ (serverpod_cli 4.0.4's
_getDirectoriesRequiringCleaning; a generate leaves srp/ untouched).
- Tests: RFC 5054 Appendix B on both halves; the client against the
register, succeeding and every refusal.
- The module's tests depend on its client, and pub resolves every
member's dev dependencies, so Learn's server image takes the module's
client into its workspace too.
- The format check covers the hand-written half.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
What Learn and Todo each held now comes from halkyon_app:
- AccountTiles: whose account on which instance, switching, signing
out (asking first for an anonymous account, as it deletes it) and
deleting it (asking first), the app's own words passed in.
- showAppAbout: the app's name, version, icon and legalese as it gives
them, the source of what the instance runs (AppDependencies gains
sourceCodeUrl), the app's own parts, the licence list.
- resolveAppLocale: the first preferred language the app speaks, never
by country alone, British English otherwise.
- AssetCountryNames, the default of InstanceGate and InstanceScope:
Unicode CLDR 48.2's territory names in English and German
(tool/country_names.py, 7.6 KB), Hong Kong, Macao, Myanmar and
Palestine by their short names as Learn's world map has them, so
Todo says "Germany (EU)" where it said "DE (EU)".
Learn's texts read as before in every language; its About bloc and
gateway, its country names from the world map and the keys only the
moved code used are gone, as are Todo's copies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
halkyon_base runs as the instance's register or as one of its members.
The register keeps the ways in, handles and profiles; members ask it
over HTTP with the instance's shared secret (instanceSecret), and open
sessions of their own for its accounts, under the same account ID.
- AccountsMode from the environment: INSTANCE_MEMBERS on the register,
ACCOUNT_REGISTER_URL on a member. Learn always holds the register
(Explore joins the profiles); Todo is a member where it is set.
- AccountRegister: LocalRegister, RemoteRegister, an in-memory one in
tests. Calls between services on the web server, refused without the
secret (constant-time compare); a server needing it doesn't start
without it.
- Deleting an account deletes it everywhere: notices per member kept in
the deletion's transaction (account_deletion_notice), told at once and
hourly until confirmed; members prune daily whom the register no longer
knows, never while it knows none of them or can't be reached.
- Deploy: INSTANCE_SECRET and INSTANCE_MEMBERS in compose and .env.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Todo's packages are licensed as Learn's (decided 2026-10-19): the server
under the SSPL, the client and the app under the AGPL. Its AGENTS.md
and READMEs describe the shell it is now; the root's README, lint note
and licence table follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
tool/release.dart releases one app: `dart run tool/release.dart learn
[major | minor | patch] [--dry-run]`, each app configured in Dart beside
it (apps/learn/release.dart). Every step and check of tool/release.sh
is kept: the repository as pushed, resuming after a release commit and
tag, Flutter as CI pins it, the tools, the key and its certificate pin,
secrets asked for without echo and never written down, CI green on the
newest commit, the signed release commit and tag by a key deploys
accept, the APK built from a checkout of the tag and checked, the snap,
the signed checksums, pushing only after both builds passed, the
release on Forgejo, the F-Droid repository, the Snap Store once a
channel is set.
New: tags per app (learn-v0.19.0, Learn's v… counting as its
history); only commits touching the app decide its version (its folder,
the shared packages it uses by path, the root pubspec and lock, its
release files, where its packages were before the move); --dry-run
reads all of it, lists every step and the notes, and what a release
would refuse, changing nothing. The pure parts are tested in
test/release/. Learn releases build 21 with tool/release.sh, then
switches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The root AGENTS.md holds what applies to every app and package (how
work is done, Serverpod, checks with Melos and the tools, CI); Learn's
own is apps/learn/AGENTS.md, Todo's a note that it is still the
scaffold. The root README introduces the project, Learn's moves to its
folder; LICENSE.md says which licence covers which directory, Todo's
packages unlicensed until decided; one CONTRIBUTING.md. ADR 0015
records the decision; ADR 0014 and 0003 note what moved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
halkyon_shared's 96f2d14: an operator's DATA_LOCATION and
BACKUP_LOCATION may name a subdivision (ISO 3166-2, US-CA, DE-BY)
besides the EU or a country, and the app adds "(EU)" to a member
country and the country to a subdivision ("Germany (EU)", "California,
United States") under the most specific flag there is. deploy/.env and
the backups' guide say so; a Storage Box is named by its country now.
The package registers its notices (the flags, CLDR's subdivision
names); Learn's own CLDR notice for the world map stays, its
registration renamed to say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The instances a device keeps an account on, the first start, the
account switcher, signing in and switching person, the gates that build
the dependencies per instance and per person, and the sync service with
its status, card and banner move to halkyon_app (halkyon_shared, ADR
0014), on halkyon_base's client. Learn supplies its frame (name, word,
icon, theme, launch screen, and the two texts only it can say), its
dependencies, whether the device holds content, and its world map's
country names; every text reads as before. An instance counts only
where its basics name the app `learn`, which learnBase now sets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Sign-in (anonymous, GitHub, email codes), an account's ways in and its
deletion, the pruning and the instance's basics move into the
halkyon_base module (ADR 0014), which every Halkyon server includes.
Learn configures it in server.dart and deletes its own data with a
person through the module's hook, in the order it did. The identity
tables move unchanged: the migration records versions only. The app
calls the module's endpoints; instance.info() stays as it was.
BREAKING CHANGE: the sign-in endpoints moved into the module, so apps up
to build 20 can no longer sign in or start; minimumAppBuild is 21.
Deploy this only together with the release of build 21.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The theme, Noto Sans, the window sizes and the widgets every screen is
built of move to halkyon_ui (halkyon_shared), pinned to a signed commit
as the sync fork is, so Halkyon's other apps share them. Learn keeps its
own colours (the FSRS ratings, a starred star) as LearnColors beside the
theme, the CJK fonts with their licences, and its launch screen's word
and icon. Nothing on screen changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Its operator sets DATA_LOCATION and BACKUP_LOCATION in deploy/.env (a
country's code or EU, the backups also none), and instance.info() hands
them on. The first start's tile and Switch account show the flag of
where the data is and the words, as labels ("Data: European Union",
"Backups: Switzerland"), the countries by the world map's CLDR names:
where the disks are, never which law governs. The flags come from the
flag package, which draws flag-icons; its MIT notice joins the licence
list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Under the wordmark on a cobalt wash, two tiles as Proton's account page
shows its services: the build's instance, chosen in one tap, and
another, which unfolds the address to type. A ring from cobalt to
copper, a glow and a tagline come in under the pointer, on the
keyboard's focus, while pressed or open; without a mouse the taglines
always show. The launch screen's "Halkyon" over "Learn" becomes
BrandWordmark, which both show.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The Snap Store and F-Droid listings now say what the demo video says:
every studyset is made by its learners (build one for any subject, or
copy one someone published), the app connects to any instance, the
repository holds everything to run one, and all the code is public
(app AGPL-3.0, server SSPL-1.0). Signing in later keeps everything with
GitHub or an email address, now that both are ways in.
The F-Droid listing takes the snap's description again, as its header
says; it had kept the text from before v0.17.0. AGENTS.md no longer
says it declares TetheredNet, which it dropped once the app could
choose its instance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Explore offered Add on the author's own published studyset and made a
whole second copy of it beside the original. The instance now names
the studyset an entry was published from to its author alone
(`yourStudysetId`, never to anyone else), and Explore shows it as Yours,
opening the original, while it is in the library. The summary still
adds a copy anyway, to see it as learners get it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The offline sync package's shared part now comes from the fork's branch
halkyon/batch-inserts (949a664, signed), adding two changes to the pin.
A merge only deleting rows skips the projection pass when every visible
row below them goes with them (a card deleted on one device, in a
library of 8,000 cards: 5.4 s for the server to merge, now about 35 ms;
4 s on each other device, now about 0.1 s). And a merge the pass leaves
out writes its new rows up to 1,000 to a statement, its proof also
taking nested studysets (a first push of that library: 35 to 41 s on
the server, now about 5; a device receiving it, 60 s, now 44).
A new sync test deletes a card on one device while another rates it:
the merged delete declines the shortcut there, and both devices end
without the card or its rating.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
After a merge or a switch of branch the workspace's resolution could
still be the one from before, and the pre-push hook's analysis failed on
packages it couldn't find (v0.13.0's push, and the stack's on 17 Oct).
The checks now fetch what the checked-out pubspecs and lock name before
anything else, with --enforce-lockfile, so a check never changes the
lock. About 3 s when nothing is missing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The offline sync package's shared part now comes from the fork's branch
halkyon/fast-merges (f062725, signed), adding two changes to the pin: a
merge only inserting new rows proven unable to change the projection
skips its passes (a rating another device made, received in a library of
8,000 cards: 9.5 s, now about 60 ms), and projections are rebuilt once
after the synced tables changed rather than on every session and merge
(upstream #150). AGENTS.md's note on #150 stays until a deploy runs it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A test device synced by calling the engine itself, so its repositories
never heard of what a sync merged; reading the database each time, they
didn't need to. Since cards and reviews are kept until something changes
them, a device went on reading its cards from before the sync, and the
conflict test failed. Devices now sync through their SyncService, as the
app does, and fail when the sync failed. SYNC_TEST_SERVER_DIR names the
server folder whose database the SQL checks read, for a worktree testing
against another checkout's running server. All 30 sync tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The offline sync package's shared part now comes from the fork's branch
halkyon/fast-deletes (b545035, signed), which stacks two changes on the
byte payload fix: onInboundMerge, called only when changes from the
remote peer were merged, which the app now reads again on rather than
after every save's sync; and local deletes that skip the foreign key
projection pass when it can't change it (8 s for one delete in a library
of 8,000 cards, 30 s for a card, now tens of milliseconds).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A device's first sync brings a person's whole library at its end, so an
empty library said "No studysets yet" until then. Until a sync has gone
through on this device for this person, it now says "Your library is on
its way from <instance>…" with a spinner, unless the sync failed. Whether
one has is kept beside the pending count in sync_pending.json (a file
from before counts as synced), read as the person's data opens, and
carried in SyncStatus.syncedHere.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
CoalescingRunner (core/async/) runs work one run at a time and once more
after a run during which it was asked again; watchReads reads a
repository's value so for each listener. They replace nine copies of that
loop: the card, note, path and PDF watches (notes, paths and PDFs read
once per change before, now a burst reads once more), the progress
repository, the material and exam cubits, file uploads, and the sync
service's syncs and pending count. Each now goes on after a failed run
when another was asked for, which some dropped. The card browser keeps
its own loop, as it emits inside the handler that started it.
The card, note and step editors show conflicts through one
ConflictVersions widget, and the note and step editors their saving
through one EditorSaveStatus and SaveStatusLabel.
TagContent, ExamProgress with examCardsToStudy, and PdfChapter move into
their features' models, so widgets no longer import a payload codec, a
service or the PDF engine for a type, and an archive's title is read in
its importer rather than its cubit running the payload codec.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A search matched LIKE '%|eat%' over every entry's barred keys, which no
index can answer, so each one read the whole dictionary: 43-57 ms in
JMdict's 218,863 entries. dictionary_search_key holds a row per key and
loose key with its first one, two and three characters in columns of
their own and one index on them, so the keys a query starts are an
equality the index answers whatever the database's collation (Serverpod's
models can't declare COLLATE "C" or text_pattern_ops). A search now takes
5-26 ms, at 1.4 million rows and 190 MB for JMdict, and its load takes
72 s instead of 33 s.
The loader fills the table beside the entries and loads a dictionary
again when it has none. Until then, in the minutes after the deploying
server starts, the search reads the barred keys as before; those columns
stay until a later release, since the server before a deploy's switch
still searches them. The migration only creates the table and its index.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A path's steps are now made in one batch, as one change
(LearningPathRepository.createSteps), for the path import and the release
copier: each step's change had everything following the paths read again.
On a real library, 60 steps took 3 s, now 0.4 s. An import's progress
moves per path with its steps.
The card browser takes tags off the chosen cards at once
(TagRepository.removeTags) instead of reading every card's tags three
times a card. It keeps each card's search text and A-Z key while the
card's heads and tags are the same, builds no search text without a
search, and works out each A-Z key once instead of at every comparison
(8,067 cards: 250,870 keys and 426 ms, now 8,067 and 94 ms).
Fix: SyncTagGateway's deletes passed a set literal to the id column's
inSet, which Serverpod refuses at run time, as the column is nullable as
declared. Taking a tag off a card, note or path has failed on devices
since v0.1.0; the fakes never met it. The sync test now takes tags off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Studysets, settings, tags and every studyset's progress are now a
LatestValue (core/async/): a screen starting to follow gets the value last
read, and only the first has it read. Before, every screen opening had the
whole table read again and handed to every screen open. While nobody
follows, nothing is kept or read, so the tags are no longer read after
every card change with no tag screen open. StudysetRepository serialises
its reads with SerialQueue instead of a kept future chain.
The card browser, path runner and path list blocs no longer add events
from stream callbacks once closing, which the later arrival of the tags
made one test hit.
Server: publishing gathers each item's tag names in one pass instead of
scanning every assignment per item, and deleting a person deletes their
stored files without reading their bytes back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
On a copy of a real library (8,067 cards, 20,907 reviews, 20
studysets), every open list read every card (0.8 s), every review
(0.25 s) and replayed schedules after every change, each on its own:
the library, every studyset open on the stack and its topics.
- CardRepository keeps each studyset's cards and every card's stamp
until a card changes; a read begun before a change keeps nothing.
- ReviewRepository keeps the whole log once many cards' reviews are
asked for, updated by this device's writes and read anew after a
sync brings others'.
- StudysetProgressRepository works out every studyset's progress once
for every list and hands the last to whoever follows it next;
StudysetListBloc reads it.
A full recount now takes about 65 ms. Home no longer rereads every
revision after each rating.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Studying:
- A round is one list in the order its cards come: those done, the one
shown, those to come. A card due again within the day goes back in
after 4 more cards when rated Bad, at the end otherwise, so the pills
above the card follow it; the buttons say "in 4 cards", "at the end",
"now" (round_order.dart).
- The studied studyset's daily limits hold for everything inside it
together, as Anki's do for subdecks: twenty topics start 20 new cards
a day in all, not 20 each.
- The study header's studyset name shows whole on hover or long press.
- The assistant's button sits at the lower left: in the Library across
from Create studyset, in a studyset above its bar, and higher while
studying, above the buttons that answer the card.
A studyset:
- Progress also counts the learning paths' steps done and lists the
PDFs not read to their end, begun first, each opening the reader,
across its topics (MaterialProgressCubit).
- Next up starts with studying for the exam while one is ahead: its
reviews due and the new cards its pace leaves for the day.
- Learning paths tagged with a level count towards its automatic exam
plan and offer it, as cards do.
- A step's first heading equal to its title is dropped on import and
hidden in paths imported before.
Signing out, or a session the server ended, leads to a start screen
(GitHub, email, without an account, another instance) rather than a new
anonymous account; only the very first start makes one by itself.
Settings names the kind of account and its instance.
Also: the Watch screen reads the cards once for held words and their
progress; its ±10 s buttons tick as the navigation bar does; a map card
keeps its view as its caption changes and brings a picked region
towards the middle; an empty or failed library can be pulled to sync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A word met on Jellyfin is tagged with the show and the episode by the
names Jellyfin gives them, so a server switching its metadata language,
another server, or a rename in Manage tags made a second set of tags for
the same show. Such a tag now also keeps what it stands for: the ids the
show (or the artist, or the film) has with the metadata providers and on
the server, and the episode's number or the song's own id. New words
find their tag by those first, keeping its name; a Jellyfin tag from
before, found by its name, takes them, and a rename keeps them.
They stay in tag schema 2, a deliberate exception to ADR 0002's rule 3:
schema 3 would hide every new Jellyfin tag from v0.17, while an app
that rewrites the tag only drops the ids, and the tag is found by its
name again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A language's release, Japanese's thousands of words, came as one answer
that a phone couldn't fetch within the client's 20 seconds. The instance
now also gives a release out in parts of at most 500 files
(`releasePart`), the first naming the version the rest ask for; the app
fetches it part by part, its progress the first half of adding, asks
again for a part whose connection dropped, and takes an older instance's
release whole. Older apps keep the one-piece call.
Explore said "Couldn't reach your instance" whatever went wrong. It now
says whether the instance couldn't be reached, answered with an error,
or the copy couldn't be written on the device.
A copy's tags are written with one read for each studyset rather than
two for each tag name: 85 reads of every card's tags for a release of
40 cards are now at most 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A word counted as held only in the studyset itself, so adding from a
studyset whose topics held it (Vocabulary, its words in N3) made a
second card, and watching from a top-level studyset put every new word
into it. Now a word is held anywhere in the studyset and its topics,
archived ones too, and its progress shows from that card; new words go
where the person chose for the studyset ("Adds to N3" in the dictionary
and the Watch screen, the studyset and its topics but archived ones,
kept in their settings, schema 10), the studyset itself until then.
Adding a word took about two seconds on 17,000 card tags: each of its
four tags read every card's tags twice. They are now written with one
read, the Watch screen shows the word added at once and takes it back
should writing fail, and it no longer reads the studyset's progress
again after each word. On a copy of a real database an Add takes 0.3 to
0.4 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A learner asks about a published studyset, a topic in it, or one card,
note or step of a release, and others answer, as on Stack Exchange
(ADR 0013): questions and answers in the app's editor without images,
sounds or videos, comments of up to 600 characters under the question
and each answer. There are no votes: the asker accepts an answer, which
comes first, then the oldest; the author's posts are marked. The
instance reads from git's file list which topics a question's target
sits in, so a topic lists what is asked within it and below it.
Posts live in two server-only tables, written through endpoints, so the
synced tables stay as they are and v0.17.0 keeps syncing. Everyone
signed in reads; writing needs an identity and a public profile; the
studyset's author hides posts and blocks people with feedback's blocks;
a withdrawn studyset takes no new questions; anyone writes at most 30
posts an hour. A post with others' replies under it is emptied when
deleted, else deleted; deletePerson deletes what a person wrote the same
way, and a published studyset's deletion takes its discussions with it.
The migration adds Serverpod's gen_random_uuid_v7(), discussion_post
and discussion_seen with their indexes and cascading foreign keys, and
changes nothing else.
In the app, discussions open from a copy's studyset or topic menu, a
studied card's "Ask about this", Explore's summary and the Publish
sheet; the Profile tab's inbox lists those the person follows, and its
count includes what is new in them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Once the person agrees, the device holds MeCab's IPADIC and the
instance's JMdict and cuts lines as Jisho.org does, so their text never
leaves it (ADR 0012). MeCab comes through mecab_for_dart, whose build
hook compiles it; its tokens are grouped by Ve's rules, written anew in
Dart, with に kept a word of its own. Each word is found as it stands or
as a form of a word, else by its dictionary form, else as the longest
words it holds from token to token; longer words over token boundaries
are offered as before.
The curator packs Ubuntu's compiled IPADIC beside the dictionary files
(curate ipadic, and every update run). The server exports its JMdict
from its tables, so the device finds the same keys and keeps the same
versions, and serves both at /device-dictionaries/, named in
dictionary.deviceFiles('ja') with their hashes; Caddy forwards the path.
The app checks each file against its hash, unpacks IPADIC, builds a
SQLite database from the export on an isolate, and asks the instance
for newer files every 30 days, downloading only what changed. The Watch
screen offers the download once per device with its size (34 MB, some
150 MB once set up); Settings downloads and removes them. Without them,
or should cutting on the device fail, lines go to the instance as
before. MeCab's, IPADIC's and Ve's licences join the licence list.
Every build now needs a C++ compiler; mecab_for_dart holds code_assets
at 1.x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A profile now has a page on the web, at the root of the account domain
(https://halkyon.eu/@raisondetredev where the operator set ACCOUNT_DOMAIN,
else on the instance's web server), so the address belongs to the
identity rather than to Learn and stays when other services share it
(ADR 0011). The page shows what the app's profile sheet shows, in English
or German, without JavaScript or cookies, asks search engines not to list
it, marks every link rel="me" so other sites can verify links to it, and
sends a visitor who asked on another host to its own address. Caddy
forwards /@… to the web server, and serves nothing else on the account
domain; compose now passes ACCOUNT_DOMAIN on, which it never did.
A link is verified as Mastodon verifies one: its page links back to the
profile's with rel="me". profile.checkLinks fetches each link's page, at
most ten times an hour, over HTTPS on port 443 and to public addresses
only, connecting to the address it checked and verifying the certificate
for the host, with three redirects, 1 MiB and ten seconds at most, and
keeps what links back on the profile (verifiedLinks, a column of a table
no device syncs). A changed link loses its check, a new handle all of
them. The app shows the page's address to copy, checks a link saved by
itself and on Check links, and marks verified links in the form and in
others' profiles; a check answered after a newer save only adds its
checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Email sign-in's codes are mailed over SMTP (pub.dev's mailer), through
whichever service the passwords name: emailSmtpHost, emailSmtpPort,
emailSmtpUser, emailSmtpPassword and emailFrom (EMAIL_SMTP_* and
EMAIL_FROM in deploy/.env, with EMAIL_SIGN_IN_PEPPER). TLS only:
STARTTLS, or TLS directly on 465. learn.halkyon.eu sends through Proton's
SMTP submission. Without a service, codes still go to the log in
development, and elsewhere there is no email sign-in.
The mail is plain text, in English or German: the code first and in the
subject, so it shows in the notification and the system can offer it,
the instance it signs in to, its 15 minutes, and that it can be ignored;
no links, no tracking, no HTML. A mail that couldn't be sent is a refusal
of its own (mailFailed): nothing of the request is kept, and the log
names the error's type only, as the service's answer may name the
address. A fake SMTP server on loopback drives the real mailer in the
tests: it delivers, and nothing goes to a server without TLS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
People without GitHub can now keep an anonymous account, and sign in on
another device, with an email address and a code mailed to it each time;
no password. The instance keeps only HMAC-SHA256 of the address, keyed
with its emailSignInPepper (ADR 0010): from what someone types it finds
the person and tells whether an address is free, it mails only an address
someone has just typed, and it can't write to anybody otherwise or show
anybody their address. The device remembers the address it signed in
with, in the person's folder, to show it.
It works as GitHub does. A code for a new address makes it the signed-in
person's, who becomes identified and keeps everything. A code for another
person's address gives a switch token, which signs in as them without a
session; a device holding studysets asks first. Asking for a code answers
alike for every address. Codes are eight Crockford characters, valid 15
minutes and three tries (Serverpod's code challenges); three codes an
hour per address, ten per network. In development the code goes to the
server's log; elsewhere there is no email sign-in until a mail service
is set up (instance.info().emailSignIn).
Someone identified may add the other way in: the Profile tab lists how
they sign in and adds GitHub or an address. They are never switched to
another account: an address or GitHub account that is another's is said
to be so. Switching now deletes the account left behind only when it was
anonymous; it deleted any, which only anonymous people could reach so far.
The migration adds email_identity and email_code_request with their
indexes and foreign keys to Serverpod's challenges; nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Export content now asks what to export as: an archive first, a .zip
the Library's Import takes back as the person's own, for a backup or a
move to another instance; Markdown, to read elsewhere, below it. The
archive holds the studyset with everything inside it but what waits in
Recently deleted, laid out as the instance writes a release, unlinked
from Explore; dictionary cards go whole, with the person's edits. Every
file its content names is downloaded first, and an archive missing one
is not kept: the person is told how many and to try again online.
With my progress (ticked at first) adds the reviews, the answers
to learning paths' steps, the pages read in PDFs, and what a release
leaves out as only its author's: the exam prepared for, a card's report,
a PDF's reading deadline. Stars, archived studysets and the tags'
colours come along either way.
The import of v0.17.0 refuses a file it doesn't know, so what it doesn't
read goes into the files it reads, as keys it skips, and the format
stays 1: archive.json's tags, a studyset.json's archived and starred, a
step's answers, a PDF's readings. This import reads them; an archive it
can't read all of still writes nothing, its tags included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A copy's cards, notes and screenshots tied to a page of one of its PDFs
kept the author's PDF id, so the tie led nowhere in every copy from
Explore and every archive imported. The copier now writes the PDFs
first and points what is tied to them at the PDFs it made; an update
does the same, writing a release's new PDFs before the cards tied to
them, so a card the release changes keeps its tie.
The files a copy names were swapped by replacing each author's id in
the payload's text, one after another: an id that was another's new
one swapped twice, and an id written in the text changed too. Now only
whole values are swapped (withOwnIds): a block's asset URL, a PDF's
file, a study button's or an exam plan's studysets, a page's PDF.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The judges' v0.17.0 uses learn.halkyon.eu, which stays at v0.17.0
meanwhile, while main moves on and is tested locally. The deploy job is
switched off until then; afterwards it runs for main again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>