Commit graph halkyon/apps/learn/halkyon_learn_server
Author SHA1 Message Date
2ec58bfaee
fix(deploy): the images drop third_party/'s overrides instead of copying it
Two deploys failed building the images (4 Nov 2026): first the
dictionaries' image lacked third_party/ (pub refuses an override whose
path is missing, exit 66), then, with it copied, pub refused the
overrides themselves, as the three plugins depend on the Flutter SDK,
which the dart image doesn't have (exit 69); every image would have.
No image's package uses them, so tool/workspace_only.sh, which already
narrows the root pubspec for an image, drops the overrides naming
third_party/, and no Dockerfile copies it.

Each image's build stage replayed from this commit with a standalone
Dart 3.13.4, a fresh pub cache, dash and mawk: all four build, and the
commit deployed before fails as the deploy did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-04 13:43:46 +01:00
438ce20dbf
build: Android's built-in Kotlin, the three plugins applying KGP themselves patched in third_party/
AGP 9 compiles Kotlin itself (android.builtInKotlin=true in every app),
and warns that applying the Kotlin Gradle Plugin will soon fail.
flutter_web_auth_2 5.1.0, keyboard_height_plugin 0.3.0 and flutter_tts
4.2.5 still apply it unconditionally, so their pub releases are copied
into third_party/ with only their android/build.gradle changed, and
the root overrides them by path (third_party/README.md says when to
drop each). The servers' images copy third_party/, as pub refuses an
override whose path is missing. third_party/ isn't our own code: left
out of the analysis and the format check.

All four apps' APKs build without the warning. A Kotlin daemon's
session file that slipped into Train's android/ goes, and every app's
android/.gitignore keeps .kotlin/ out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-04 09:32:13 +01:00
3ac1880ac8
build(deploy)!: the instance's compose file is compose.learn.yaml, beside compose.train.yaml and compose.todo.yaml
No file has Compose's default name any more, so .env's COMPOSE_FILE is always set,
naming compose.learn.yaml first (.env.example sets it). compose.yaml stays as a link to
compose.learn.yaml for the one deploy that switches learn.halkyon.eu's .env over: the
deploy's merge runs with the COMPOSE_FILE from before. A later commit removes it.

BREAKING CHANGE: an instance's deploy/.env must set COMPOSE_FILE, starting with
compose.learn.yaml, before the link goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 23:12:15 +01:00
447e00c52a
feat(learn): the assistant's field laid out as Claude's, with files attached and questions spoken, and no Check (ADR 0021, D67)
The question's text stands above its buttons: + attaching files, who
answers as a chip (Effort now a submenu of it), the conversations kept,
and the microphone while nothing is written, which gives way to the
button sending. What goes along is one row above the text, opening a
sheet with how far the assistant looks and the parts to send. A
conversation's follow-up has the same + and microphone.

Files are read on the device: an image goes as itself to an assistant
that reads images, a PDF as its pages' text, a text file as its text,
each cut at 20,000 characters and labelled by its name after the
question.

Voice is the device's own speech recognition (speech_to_text: Android,
Apple, Windows; none on Linux), in the app's language, the microphone
asked for once tapped. The privacy page says that Android's recogniser
may send the sound to Google or the phone's maker.

The field's Check goes, with its LanguageTool sheet (amending ADR 0024);
the editors' underlining and Why? stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 08:46:37 +01:00
5641285d31
feat(learn): Explore by what a licence allows, a skeleton while the library loads, and an assistant's agreement per provider (ADR 0005, rule 12; ADR 0021, D66)
- Explore filters by what a studyset's licence lets a learner do: adapt
  it (no NoDerivatives) and use it commercially (no NonCommercial),
  which the instance reads from the release's licence
  (ExploreQuery.adaptable, commercial) and the app the same way
  (LicenseUse). An instance from before ignores both.
- While the studysets are read, the Library shows shimmering skeletons
  of their cards rather than a spinner: halkyon_ui's new Shimmer and
  SkeletonBox, still where the system asks for no animations.
- The scope's "Include private studysets" is offered only while the
  person has a private studyset.
- An agreement to what goes where is per provider (protocol and host):
  agreeing to one assistant agrees to every assistant of that provider,
  those granted later too, and the agreement sheet says so. No
  migration: it is written on every grant of the provider, so taking
  one back keeps it for the others, and read per provider from any of
  them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 05:19:18 +01:00
24ed63585e
feat(base)!: one budget per person for all their assistants, the effort each takes, the operator's lists by page
A grant now says only which assistant a person may use, and keeps their
agreement; the limits are the person's (assistant_budget): one per UTC
day and month in money and in tokens, each optional, money counting the
assistants with prices, tokens every one (Halkyon Learn's ADR 0021, D58,
D59). assistantOperator.grant gives several people several assistants
with one budget, change sets one person's exactly, revoke takes back
everything; grantees and granteesOf list people with their assistants,
budget and use, 50 at a time. The migration carries each person's grant
limits into a budget: per unit, their sum, a grant without a limit making
it none.

assistants.env gives each assistant a default and a highest effort
(_EFFORT, _MAX_EFFORT; refused at start where the provider takes none);
ask takes one within that range, refusing another (invalidEffort), and
caps the output at its ceiling (D63).

instanceOperator.waiting and accounts come 50 at a time from an offset,
and counts gives the rows of the operator's screen (D62).

BREAKING CHANGE: the offer, the grants and the operator's lists changed
shape; Learn's server now asks for build 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 16:21:53 +01:00
19dd4d9c4e
fix(learn): the instance asks LanguageTool for its languages again a minute after it didn't answer
Switching the sidecar on learn.halkyon.eu's server recreated the server
beside LanguageTool, whose start takes a minute: the server's first look
at the languages failed, and it looked again only an hour later, as
after one that worked. Until then instance.info() named no languages,
and an app that asked meanwhile keeps that answer, so its editors check
nothing until it starts anew. A look LanguageTool doesn't answer is now
tried again a minute later, by a timer, until it answers, the languages
known before kept meanwhile; one that worked still counts for an hour.

The server's tests take fake_async (^1.3.3, as Learn's app, already in
the lock) for the timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 16:21:52 +01:00
973063a225
build(learn): the server's image holds every package it resolves
All checks were successful
CI / check (push) Successful in 33m55s
CI / deploy (push) Successful in 2m20s
The deploy of 46be9983 failed while building the server's image: the
Dockerfile copies halkyon_core since ADR 0021's step 1 (a060fbc2), but
.dockerignore still left it out of the build's context, and halkyon_llm,
which the module forwards the operator's assistants with since step 2,
was never copied at all. Nothing was switched: the instance kept running
v0.20.0's server.

CI never builds the images (its runner has no Docker, by design), so
test/deploy/images_test.dart now holds each Dockerfile to its package's
path dependencies, dev dependencies included as pub resolves them: every
one copied, kept in the narrowed workspace, and not left out by
.dockerignore. It failed on both before this fix. Built locally since:
the server's image builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 11:01:29 +01:00
f766a880ac
feat(learn): LanguageTool as a sidecar, reached through the server
compose.languagetool.yaml runs LanguageTool's open-source server
(meyay/languagetool 6.8-13, pinned by digest) on `edge` with no site and
no published port, as user 61081 on a read-only filesystem, without a
log. The server alone reaches it (LANGUAGETOOL_URL): grammar.check is
for those signed in, takes 20,000 characters, 60 checks a minute each
(counted in memory), maps a studyset's language to LanguageTool's code
and keeps nothing. instance.info() names the languages it checks; the
privacy page says what goes there. sidecars/README.md has the steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 07:17:10 +01:00
bf46f8c460
feat(learn): a private studyset goes to an assistant only once agreed
ADR 0021, D9 and D17. Before a question sends a private studyset's
content to a remote model, through the parts chosen or a wider reach
with private studysets switched on, a warning names the studysets, the
provider's host and its terms (where checked: Anthropic, OpenAI,
Google, OpenRouter), and is asked once per studyset and assistant: the
agreement is kept in the person's sealed settings (schema 13,
privateAgreements, folded only where both rows hold it), shown in the
studyset's privacy dialog with Take back, and marked on the folded
button while it holds. Follow-ups and Try again ask again once taken
back, as the scope keeps where the parts came from (partsFrom). No
warning for an own assistant on this machine (loopback or localhost,
halkyon_llm's LlmEndpoint.onThisMachine); the local network may be
shared.

Texts that step 3 made untrue: the consent sheet now says what an
assistant looks up goes too and that conversations are kept encrypted
for the person's devices; the privacy dialogs no longer mention
showing a studyset to Claude; the privacy page says what is looked up
and when a private studyset goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 07:17:09 +01:00
b965e05fdb
feat(learn): conversations with assistants, kept sealed (ADR 0021, step 3)
The first part of step 3, decided as D47 to D52 in ADR 0021: where a
conversation shows (a sheet over the screen), where kept ones are found
(the field), the scope's chip and that it starts with only what is
ticked, how conversations are kept, and what a device that can't seal
does.

Two synced tables: assistant_conversation (its title, assistant and
scope) and assistant_turn (one question and everything up to its answer,
never changed once written, ADR 0001, rule 2), so two devices continuing
a conversation offline lose nothing. SyncConversationGateway seals every
row with the person's key, a turn bound to its conversation, and reads
no row that isn't sealed: a device that can't seal keeps nothing (D52,
ConversationNotKept). Deleting one blanks it and its turns first (ADR
0002, rule 2). A turn keeps every message a follow-up sends back (tool
calls and results, the providers' own blocks), but no image, only that
one was sent.

The instance's privacy page says conversations are kept, encrypted, and
what the instance sees of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 07:17:08 +01:00
dbae802915
feat(base,learn,todo): the operator's assistants, and the instance's privacy page (ADR 0021, step 2)
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>
2026-10-26 07:17:06 +01:00
a060fbc21d
feat(base)!: sign-ups as the operator says, waiting accounts, operators (ADR 0021, step 1)
SIGN_UP takes open (unset), approval or closed; anything else stops the
server as it starts. Closed refuses a SimpleLogin sign-up before its
handle is counted, an address sign-up only once the code has proved the
address, so nothing tells whether an address has an account. Approval
lets a new account wait, at most ten at once: no session is issued for a
waiting or suspended account, only a pass (32 random bytes, kept as its
SHA-256, 30 days, five per account) and four BIP-39 words the operator
reads out to match it. The pass looks, forgets, and deletes an account
that was never approved; nothing else.

Operators hold the scope operator, given first by SQL (D32), then named
in the app: InstanceOperatorEndpoint lists, approves and refuses waiting
accounts, finds accounts, suspends them (ending their sessions), and
names and takes back operators, never the last. account.operating()
counts the waiting for the badge. Members ask the register (five new
operations, a refusal kind signUp), and say closed while it is out of
reach. Never-approved accounts go after 14 days in the daily pruning.

The migration only adds the tables waiting_account and waiting_pass, so
the server before it keeps working on the new schema.

BREAKING CHANGE: a register and its members must run this version
together: a member asks the register operations an older one doesn't
know.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-26 07:17:05 +01:00
4e33133733
feat(base,app)!: GitHub signs nobody in any more (ADR 0020, D36)
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>
2026-10-26 07:16:39 +01:00
87671208df
feat(base)!: no anonymous accounts on the server (ADR 0020, D25)
All checks were successful
CI / check (push) Successful in 30m13s
CI / deploy (push) Has been skipped
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>
2026-10-24 16:14:42 +02:00
7eb1961b9f
feat(base): handles hidden until their people go public, every answer about a new handle counted (ADR 0020, D5)
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.

So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.

Halkyon Learn's handle dialog words the new refusal (English, German).

Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 09:17:49 +02:00
090d901e92
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.

- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
  waits under the attempt gets, in one transaction, an account
  (identified), the link, a profile with the handle and the keys; then a
  session (D18). Refused, the identity still waiting, for a setup out of
  range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
  take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
  its handle, and keeps the sign-in ten minutes under its attempt, the
  account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
  password is proven with its proof key, for a nonce, bound to the base
  URL the device addressed; wrong proofs counted per session as ADR
  0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
  instance API carry the four new operations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 09:17:48 +02:00
42d06f7ee8
feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin`
for the app and platform (the state's marker for the forwarding page), at
most 30 an hour per network; `complete` with SimpleLogin's code, signed out
a session for the account with that SimpleLogin ID, signed in SimpleLogin
added to the caller's account, who becomes identified and anonymous no
longer. Someone new gets no account yet: their identity waits 30 minutes
under the attempt's ID (D16), with a handle suggested from their name,
`anna_mueller` (D17), for the sign-up of step 3.

The register alone holds the instance's SimpleLogin app
(`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a
member asks it for both steps and issues its own session. `SIGN_IN_WAYS`
says which ways the register offers, the instance's basics and the ways
in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is
offered, and Learn's and Todo's pruning forgets the unfinished sign-ins
hourly. No migration: the tables are halkyon_oidc's, already in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 09:17:48 +02:00
620ad270db
refactor(base): SimpleLogin's forwarding page on the OpenID Connect module (ADR 0023)
halkyon_base depends on halkyon_oidc_server, and its forwarding page
is now the module's (`addOidcCallbackForwarding`) with Halkyon's table
of apps and its page in English or German; its tests keep what is
Halkyon's own, the rest is tested in the module.

Every host now includes the module: migrations `…-oidc-module` in
halkyon_base, Learn and Todo create its two tables (no data touched)
and record its version; Learn's devices get them empty, as they have
halkyon_base's server-only tables. halkyon_base_client depends on
halkyon_oidc_client, whose models its generated code uses, and Learn's
server image copies both packages into its narrowed workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 09:17:47 +02:00
8d6785f5c6
feat(base): SimpleLogin's forwarding page and Learn's App Links statement (ADR 0020, step 1)
SimpleLogin takes only https redirect addresses, each registered exactly,
so the instance's SimpleLogin app registers one page on the register's
web server, /simplelogin-callback, which sends the browser on to the app
that began the sign-in: a 303 to the desktop's loopback (127.0.0.1, a port
per app) or the phone's scheme, chosen by the marker the state begins with
(`<app>-<platform>.<rest>`, the rest 1 to 512 characters of base64url),
from a fixed table, so it sends nobody anywhere else. It forwards the code
or SimpleLogin's error (`deny`) with the state and nothing else, refuses
anything malformed, repeated or not a GET with a page that repeats nothing
it was given, and keeps and logs nothing (no-store, no referrer). It is
built ahead of the sign-in itself so development can sign in through
learn.halkyon.eu's page, which the development app registers too.

Android's assetlinks.json for Halkyon Learn signed with the release key
is answered by the Caddyfile on learn.halkyon.eu alone: the one domain the
project's builds name, and the server knows no app's key. A root test
holds its fingerprint to tool/release/android-certificate.sha256. The
app's intent filter comes with the sign-in; iOS's association waits for
an Apple team ID.

The profile page's language choice and look move to common/web_page.dart,
shared by both pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 17:48:31 +02:00
ca24eb9cf9
feat(assistant)!: no hand-off and no connector; the field searches the web (ADR 0022)
The developer decided on 23 Oct to drop the hand-off to an AI assistant
outside the app, and Claude's connector with it, in v0.19.0, the app and
the server together: build 22 raises minimumAppBuild anyway, so no app
calling the connector's endpoints uses the instance after it. An instance
is to have one way to an assistant, its operator's (ADR 0021, proposed).

The app: the field at the foot of Home, and the button in the Library, on
a studyset and while studying, search the web alone, through the engine
chosen in Settings or Android's own search, with the parts of a card the
person picks; a studyset's name and description are never offered. Gone:
the assistants a question opened in (AiAssistant, AssistantHandOff), the
switch between them and the web, Settings' choice of assistant, Connect
Claude with its route, cubit, repository and gateway, and their texts. A
settings file from before keeps the old keys until the next change of a
setting writes it anew; nothing reads them.

The server: lib/src/assistant/ (MCP, its OAuth, the five tools, connect
codes and tokens), AssistantEndpoint, the daily forgetting of the
connector's secrets (its scheduled call cancelled at start), its lines in
the deletion hook, instance.info().assistantConnectorUrl (nullable, so an
older app reads it as none), and Caddy's forwarding of /mcp, /oauth/* and
/.well-known/oauth-*. The migration drops the connector's five tables,
children first:

  DROP TABLE "assistant_token" CASCADE;       -- tokens of a connection
  DROP TABLE "assistant_grant" CASCADE;       -- OAuth codes of a connection
  DROP TABLE "assistant_connection" CASCADE;  -- a person's connected client
  DROP TABLE "assistant_connect_code" CASCADE;
  DROP TABLE "assistant_client" CASCADE;      -- OAuth clients registered

No other table references them, so CASCADE drops only their own foreign
keys. For the minute between the maintenance run and the switch, the old
server answers the connector with errors and fails to delete an account
(its hook still deletes from these tables); ADR 0022 says so.

BREAKING CHANGE: a connected Claude gets no answer from the deploy on, and
the app no longer opens a question in an AI assistant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 17:48:30 +02:00
8ea2f04384
style(privacy): the server part's directives and quotes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:11 +02:00
d9b5e06618
fix(privacy): the instance's side of the reading of private studysets (ADR 0019)
An upload said to be encrypted must start as a sealed file does (HLKF,
version 1), so a readable file can't skip the PDF check by saying so. A
person's room counts the files a live asset row names and those claimed
within the week of grace, so a private studyset's rewrite near the limit
no longer fails for a week while pruning keeps the replaced files. Copy
reports are limited to 60 an hour per person. Tests: what goes with the
person, `counted` refused to anonymous people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:11 +02:00
a1c5ac3f86
feat(privacy): the instance's side of private studysets (ADR 0019)
- `content_key`, a synced table in the person's space, keeps their data keys
  sealed to their account (a private studyset's, their personal one);
  `learning_path_answer` gains `keyId` and `nonce` for answers in private
  studysets. Additive only, on the server and the device.
- Publishing refuses a private studyset, and a tree holding a private row
  (`PublishRefusal.private`); a private studyset elsewhere no longer breaks
  publishing a readable one. Encrypted tags are named as the device names
  them (`tagNames`): a name for a tag that isn't the person's, or an
  encrypted tag of the release without one, refuses it
  (`tagNamesMismatch`); a readable tag keeps its own name.
- The connector leaves private studysets out and says how many there are.
- The learner count reads no payload any more: Count me is kept by the
  instance (`counted_learner`) and the device reports which publications
  its copies come from (`learning_copy`, accepted only while counted, each
  report replacing the last); only the reporter's own studysets count.
  Deleting a person deletes both.
- An upload says whether its file is encrypted: only a readable one over
  20 MB is checked to be a PDF, and an encrypted one may be 4 KiB larger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:08 +02:00
f9880e6989
fix(auth): GitHub-only accounts go 30 days after GitHub's end, on the register alone, one failure holding back none
The reading of the full reading's fixes found that a member, which keeps
no addresses, would delete everyone it holds with a GitHub ID as if
GitHub were their only way in (D1); that each deletion delivered every
pending notice, so with a member hanging the daily run grew quadratic,
and one failing person aborted it, forgetting no GitHub ID (D2); and that
a GITHUB_SIGN_IN_ENDS set wrong deleted data at the next daily run.

forgetGitHubAccounts now does nothing on a member and waits 30 days after
the end (gitHubAccountsKeptAfterEnd), keeping the accounts and IDs while a
wrong date can be put right; deletes each GitHub-only account on its own,
a failure logged by type, that person's GitHub ID kept for the next run;
and tells the members once, after all of them (deletePerson's tellMembers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:59 +02:00
274b796faa
fix(auth): a hold-out moves a reset on only by its own time, and a reset lives 30 days at most
The reading of the full reading's fixes found that every refusal at the
address's ceiling moved a reset to 72 hours after it (A1): someone who
knows only the address kept the owner's own reset from ever taking effect
with one burst every three days, and kept a forgotten one alive (A2).

Now a hold-out that begins before a reset takes effect moves it on only by
the time it holds the owner out and no hold-out counted before did
(account_reset.heldOutUntil, under a row lock), so the owner gets 72 hours
outside hold-outs in total; and every reset not carried out lapses 30 days
after it was asked (resetLivesAtMost), whatever moved it, besides a week
after it took effect.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_reset" ADD COLUMN "heldOutUntil" timestamp without
time zone; nullable, existing rows counted from now on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:59 +02:00
4d1cd2045c
fix(auth): codes after the recovery phrase count apart from the password's
The full reading's M3: whoever knows the password made the account's codes
wait, and that wait refused the phrase's owner their reset too, so they were
held out of both without end. Codes now count per account and per way they
came (SecondFactorPath: after the password, after the phrase), each with
its own waits; a code accepted either way clears both.

Migration (module, Learn's and Todo's servers; Learn's device schema
rebuilds the server-only table):
  DROP INDEX "second_factor_failures_person_idx";
  ALTER TABLE "second_factor_failures" ADD COLUMN "path" text NOT NULL
    DEFAULT 'password'::text;
  CREATE UNIQUE INDEX "second_factor_failures_person_idx" ON
    "second_factor_failures" USING btree ("authUserId", "path");
Existing rows are the password's, unique per account, so unique per
account and way too.

Also the hosts' generated future calls, for the pruning's doc comment the
lapsed resets' commit changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:52 +02:00
43fcc8aac2
fix(auth): a reset not carried out lapses a week after it took effect
The full reading's M2: a reset of the authenticators asked and forgotten
left the account to the password alone for good, and an account's reset
could be carried out a year after it was asked. Now a reset that took
effect and wasn't carried out lapses a week later, as never asked: the
password alone no longer signs in, a code still does, and asked again it
waits anew with a new mail. A reset of the authenticators carried out never
lapses (the password alone signs in until one is confirmed). The daily
pruning forgets lapsed rows (forgetLapsedResets).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:51 +02:00
5e7d7f4e36
feat(auth): a session ended for its wrong proofs warns its owner
The tenth wrong owner proof in a row ends a session (the third
reading); someone holding that session tried the password or its
codes. The register now keeps the moment with the account's password
(`account_password.sessionEndedForProofsAt`): the account's sign-in
ways and its next password sign-in carry it, until the owner dismisses
it (`password.dismissSessionEndedWarning`, their session alone: it
hides a warning and secures nothing) or sets a new password. A member
ending its own session tells the register
(`noteSessionEndedForProofs`); a register out of reach leaves the
owner unwarned, the session ended all the same.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "sessionEndedForProofsAt"
timestamp without time zone; nullable, so every row keeps its values
and has no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:51 +02:00
1d4320370b
fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:48 +02:00
e8c4a3d472
fix(auth): a session's wrong codes end the session, not the account's codes
Wrong codes for removing an authenticator, new recovery codes or confirming
one counted toward the account's waits (the re-review's third finding): a
stolen session alone, without the password, could keep the owner's codes
waiting for up to a day at a time, renewed without end, holding them out
of signing in and of changing the password, the way to end that session,
and nobody was mailed. Those codes now count per session, where the session
is (the register's own, or a member's, which forwards the code): each try
counted before its code is checked (one statement, so tries sent at once
count each), a code accepted clearing the count, and the tenth wrong in a
row ending the session (sessionEnded; the device says it was signed out).
A try that checked no code (a refusal of something else) is taken back.
The account's waits now count only codes after a right password, and the
per-account ten tries an hour, which a session could fill as well, go.

Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "session_code_failures" ("id" bigserial PRIMARY KEY,
"sessionId" uuid NOT NULL, "authUserId" uuid NOT NULL, "failures"
bigint NOT NULL, "updatedAt" timestamp NOT NULL) with a unique index on
"sessionId": a new table, nothing touched. Its rows go with a code
accepted, with the person, and hourly once their session is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:47 +02:00
4a32f150db
fix(auth): codes wait after ten wrong in a row, and the owner is told
Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.

Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:44 +02:00
aea0c447fc
fix(auth)!: the recovery phrase is proved by a key of its own
A key proof was a signature by the account's signing key, which every
signed-in device keeps in its keyring: whoever could read one, with a
code from its authenticator, set a new password without the password or
the phrase (the second reading's H1). The phrase now proves itself with
an Ed25519 key derived from its bits alone (halkyon_keys'
recoveryProofKeysOf, KDF context hlkrprf1, a sibling of the recovery
key), whose public half every setup, new phrase and account reset sends
and the register keeps (account_password.recoveryProofKey); the device
derives the secret half only while the phrase is typed. An account
without one (none exist outside development) proves no phrase until it
sets a new one; decoys check against a decoy proof key alike.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "recoveryProofKey" bytea;
nullable, so rows made before keep working and no data is touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:43 +02:00
ae57cbe864
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.

The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.

BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:41 +02:00
d00da1f24a
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.

A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.

A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:40 +02:00
aedd03661a
feat(auth): Learn's and Todo's servers take password sign-in
Their migrations add halkyon_base's five tables for passwords,
authenticators, recovery codes, resets and sign-ins begun (nothing
else), and their pruning forgets sign-ins never finished and
authenticators never confirmed, hourly, and every GitHub ID once
GitHub sign-in has ended, daily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:11 +02:00
bc499713b8
feat(crypto): SRP-6a, the register's half and the device's
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>
2026-10-22 18:11:08 +02:00
65c5cd740d
feat(accounts): one account across an instance's services (ADR 0016)
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>
2026-10-22 18:11:07 +02:00
4107cc3087
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.

Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:07 +02:00
af1dbee509
build: Melos, a Dart check and every path for the single repository
- Melos 8.9 as the root's dev dependency, with scripts across the
  workspace (analyze, test:dart, test:flutter, generate, check) and its
  filters (--diff, --include-dependents); cli_util overridden to 0.5.2
  for it, since Learn's flutter_launcher_icons asks for 0.4 and uses only
  its logger, unchanged in 0.5.
- tool/check.dart in place of tool/check.sh: packages, formatting of our
  own files, analysis, the tools' tests, then every package's tests
  through Melos (--changed-since=<commit> for only what changed and what
  depends on it). tool/pre-push is the hook: the signature check, then
  the checks. CI generates every server's code and runs the same check.
- The images build from the root with a workspace narrowed to what they
  need (tool/workspace_only.sh); compose, the deploy's migration test,
  the snap, the release script, the editor's launch files and Learn's
  asset tools (run from apps/learn) follow the new layout.
- The project's source is named as project-halkyon/halkyon (source link,
  snap, F-Droid listing, release uploads); the AGPL packages carry the
  licence's own text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:04 +02:00
9643412be9
build: one workspace for every app and the packages they share
The root pubspec lists Learn's, Todo's and the shared packages, which
the apps now take by path instead of pinned commits of halkyon_shared:
one resolution, one lockfile. Learn's versions stay as they were; Todo
moves to Serverpod 4.0.4 and Dart 3.13 with the rest (its generated code
unchanged), drops google_fonts, which it never used, and a template
line Dart 3.13 refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:03 +02:00
649499be23
chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:02 +02:00