Mathos is a personal AI tutor whose brain is permanently a teacher. This file is the working contract for anyone — human or agent — making changes here.
Read these before doing anything substantial:
constitution/CONSTITUTION.md— the teacher constitution. The most important artefact in the project.docs/DECISIONS.md— the dated decision log. It is the authority on why things are the way they are.docs/ROADMAP.md— the phased plan and what "done" means for each phase.docs/DESIGN.md— the architecture.
And before doing anything that makes model calls in a loop —
docs/COSTS.md, which prices a session, a month and an eval run. Every call
is somebody's money, and this project has already emptied an account once.
Mathos refuses general-assistant work and redirects to teaching. That refusal is the differentiator from plain Claude or ChatGPT — without it this is a worse version of a chat app. So:
- The constitution is data, never code. It lives in
constitution/CONSTITUTION.md, is loaded verbatim into the system prompt, and is versioned. Never re-implement aC-NNrule as a code branch, a keyword filter, a regex, or a validator. If you find yourself writingif (message.includes('price')), stop — that is the failure mode the whole design is built to avoid (see C-29, and E-16 for why keyword matching gives the wrong answer). - Over-refusal is a bug of equal severity to a leak. A tutor that won't tell you the
Greek word for bread is broken.
cases.yamlhas aservehalf for exactly this reason, with its own threshold. - Amending it is a process, not an edit — CONSTITUTION.md §11: decision-log entry,
new rule IDs (never renumber, never reuse), corresponding cases, version bump, eval
re-run.
test/constitution.test.tsenforces the mechanical half of this. - Adding a case is not an amendment. A case marked
origin: adversarial— a variant, a role-frame attack, a failure seen in real use — needs no §8 example and no version bump (D-038). Anything else must mirror a §8 example, and the test will say so if it drifts.
The brain was built headless and stays that way: constitution, tool-use loop, role
system, learning store, review engine. It now has three clients — the CLI harness, the
HTTP API, and the web app in web/ — and the brain knows about none of them.
- The surface is an installable web app on
mathos.amirj4m.com(D-069), chosen after eight phases of using the brain, over a Telegram bot that was cheaper and brought voice, images and push for free. Read D-069 before reopening it: what it cost is written down. - The app is not in
src/, andsrc/still knows nothing about it.web/is a separate build that produces static files nginx serves. Nothing insrc/may assume a terminal, a browser, a chat app, or a push channel;test/src-hygiene.test.tsholdsprocess.stdout,readlineandconsole.loginsidesrc/cli/, andsrc/server/is subject to the same rule — it hands log lines to whoever started it rather than printing them. - The API still serves no client (D-065). No route returns HTML, an asset, or a template; the app is files on disk in front of it, on the same origin. If you find yourself adding a route that returns a page, stop.
- One controller, three clients (D-064). Anything that is not about a screen — resolving
a teacher from what a human typed, the slash commands, the teacher-moved announcement, the
transcript, the usage counters, the due queue, the pending tray, the record, a thread's
visible messages — lives in
src/app/and returns values. If you add a feature tomathos chatthat the API cannot have, it is in the wrong file. If the controller starts returning formatted strings, it has taken presentation from its clients and they will start parsing it back. - What the app must never grow, all of it previously rejected with reasons: streaks,
daily goals, XP, badges, confetti or any other gamification; a percentage-complete on a
subject; a subject catalogue or category chips; a teacher marketplace; human faces or
names for teachers; separate chat/image/voice tabs; a phone-call button; a flashcard deck
browser.
web/README.mdsays why each is absent. A reminder is subject to the same list (D-077) — a badge count on the icon, a "you haven't studied today", or a notification on an evening when nothing is due are all the streak problem wearing different clothes, and each would have to be built by widening what the message composer can see. - The app runs on a phone, and a test suite cannot reach one (D-079 to D-083). A week of
real use found five defects that 463 passing tests did not, every one of them about the
device. Three are worth carrying as rules rather than as history: Enter inserts a newline
and the button sends — Android's return key is an ordinary
Enter, so sending on it made every message exactly one line long, and detecting "is this a phone" to decide is the same guess that broke it;capture="environment"removes the gallery on Android Chrome rather than preferring the camera, so it stays off the file input; and a service worker cannot route the app from outside —WindowClient.navigate()is refused on a client it does not control andClient.urlis stale for a fragment, sosw.jsposts the destination androuter.tsmoves itself. When you change any of these, drive it in a browser: asserting that a function was called is not the same claim as the screen changing, and that gap is exactly what let a tapped reminder go nowhere. - A failure is never a payload, and a retry comes before a sentence (D-103). Two rules,
both from one screenshot: a learner mid-lesson was shown
{"type":"error",…,"request_id":"req_…"}where his teacher's reply belonged, because the API was busy for a few seconds and nothing tried again. The model call retries — six attempts, jittered backoff, inside the stream's heartbeat so the indicator never drops; the policy is pure and lives insrc/brain/retry.ts, and the SDK's own is off so the budget is one this repo can state. No string that arrived over the network is ever rendered — the server sends a code,web/src/messages.tschooses the words, and that is the only file besidesapi.tsallowed to touch a failure's own text. If you find yourself writingerror.messageinto a screen, stop; if you add a server error code, add its sentence in the same commit, because the fallback fires exactly when something is new. And a failed turn offers a retry, not a dismissal. Which retry is a fact about the store rather than a preference: the server persists his message before it calls the model, so a turn that got that far already recorded what he said —POST /v1/sessions/{id}/retryasks for the answer and appends nothing. Never make that a flag on/turns; a route that takes no text cannot be misread as him saying it twice. - A backgrounded page is the normal case, and coming back is a read (D-097). He rides
between messages, so the browser suspends the page and drops the turn's event stream on the
most ordinary interaction there is. The client therefore abandons the stream rather than
resuming it and asks the store —
GET /v1/threads/{id}/messagesfor what the thread holds now, andGET /v1/sessions/{id}for whether the turn is still running. Do not add a resumable stream, a reconnect banner, or a "disconnected" state: the turn is the server's and finishes without its client, the messages are in the store, and the screen renders whole messages anyway (D-090) so there are no deltas to recover. Whether a reply is owed is read off the thread, never off a client flag — the last stored message being his is still true after the process was killed, which a flag is not. And messages already on screen are matched by content and dropped from what gets rendered rather than being removed and re-added;web/test/reconcile.test.tsis where that is pinned, because a duplicate and a dropped reply are both silent. - The
pwa/directory is a retired placeholder from before this decision and has nothing to do with the app.web/is the app.
There is no subject list. A role is a small data profile the brain authors from a sentence.
- No file in
src/should mention Greek, Linux, driving, or any other subject. If one does, subject knowledge has leaked into the engine.test/src-hygiene.test.tschecks. - A role is authored in exactly one place — the brain's
set_role, from a sentence the learner said (D-039). Do not add a second author: no CLI verb that creates a role, no config file of roles, no seeding. Every other surface only selects among the roles that exist. - A role frame may set subject, goal, level, style, language, pacing — and nothing else (C-39, C-40). It can narrow scope; it can never widen it. The engine enforces the shape of a frame — six fields, a seventh rejected, each value rendered on one line inside its own field — and never reads what a frame says (D-037). Checking a frame for words like "answer anything" is the keyword filter this whole design exists to avoid; the constitution holds that line, and E-21 to E-24 measure whether it does.
- Each teacher keeps its own thread, and a second subject is offered, not answered
(D-006, D-036).
switch_roleis the only thing that moves a conversation, it moves only after the learner says yes, and a move replaces the brain's history with the target thread's own. Nothing is carried across. If you find yourself adding "just the last few messages for continuity", stop — that is the context bleed the isolation test exists to catch.
The unit of review is a concept, not a card. FSRS decides when a concept is due; the question asked about it is written fresh by the brain every single time, and the brain grades the answer.
- Never store a question or an answer on a concept. Not as a column, not as a cache,
not as an "offline fallback" — that was considered and explicitly rejected. If you find
yourself adding
concepts.question, stop: a pre-baked question will be served on the day the brain is slow, which is the day the learner is doing their review. What the past supplies is the angle recent questions took and the questions themselves, both so the next one can differ (D-041) — read to be avoided, never to be re-served. There is no code path anywhere that turns a pastaskedback into a question, and adding one is the bug. ts-fsrsdecides when, and one pure function owns it.src/review/scheduler.tsis called by both stores and implemented by neither (D-040). Do not put scheduling back in a store, and do not let a second thing compute a due date. Note two consequences that look like bugs and are not:interval_daysis 0 on a short-term step, where the concept comes back in minutes and the due date is the authority; and a concept failed before it was ever learned is not a lapse, because FSRS counts one only for something that reachedreview.- An attempt is a row from the moment it is asked (D-048).
ask_reviewwrites it when the question is put and returns the id;grade_reviewcloses that one attempt, once, and takes no concept id at all. This is not ceremony: before it, an attempt nobody graded was not a row in an odd state, it was no row, and Phase 4's drive lost a real failure that way. Do not add a second path that writes a complete review from the brain — the gap becomes invisible again the moment one exists. (recordReviewon the store port is the two halves in one call, for the CLI and for tests; the brain never reaches it.) - What is due is put in front of the brain, not waited for (D-043). The session brief is rebuilt before every turn, scoped to the teacher in the room, and omitted when there is nothing to say. It is data — concept names, counts, angles, and whatever is still owed a grade. It deliberately does not reprint the question an owed attempt asked: that question was put in the conversation the brain can already see, and a question reprinted where the brain can reach for it is a question waiting to be re-served. If you find yourself putting a judgement about what to teach in the brief, stop: it is the operator's channel, and it must never become a second constitution.
- The brief goes behind the conversation, and that position is load-bearing (D-060). It is
a trailing
systemmessage, not a system-prompt block and never ausermessage. Two reasons, and both bite. The cache breakpoint is on the conversation's last block, so anything volatile rendering ahead of the history invalidates it on every turn — put the brief back in the system prompt and the caching silently inverts into a 1.25× surcharge on the largest line item there is. And thesystemrole is what keeps the brief the operator's channel rather than text a learner could imitate (C-29, D-037). The brief is also never appended to the conversation: it belongs to the request, so it is neither persisted nor replayed.test/prompt-cache.test.tspins all of this. - The learner approves what enters the rotation. The brain proposes; a proposal waits in the pending tray. There is no code path that lets the brain approve its own proposal, and adding one would undo the mitigation for the junk-deck failure mode Phase 1 measured.
- Anything named
cardin this repository is either history in the decision log or a bug.
Review answers when something already met comes back. A plan answers what he meets next — the thing the brain had no way to represent, and therefore could not do.
-
The teacher designs it; the learner accepts it. He says where he wants to get to and what time he has; the brain works out the route and puts it up whole. Asking a learner what should be on his own syllabus, or what to do next when a plan is already agreed, is not consulting him — it is handing him the job.
propose_plancarries that rule and so does the prompt's tool note. -
Revision is a fresh
propose_planthat supersedes, never an edit API, and finished items carry forward. Do not add add/remove/reorder verbs. -
goalandbudgetare the strings he said — "by December", "three evenings a week". Nothing parses them, and no function anywhere may take a plan and a clock. With them as strings, "you are three days behind" is not forbidden, it is not expressible — the same incapacitycomposeReminderhas, and the same reason (D-086). Never add atarget_date. -
A plan item is not a concept. An item is what he will meet; a concept is what he chose to retain. An approved item may enter the rotation directly through
advance_plan— he approved it by name in a list he read — and the store checks the plan is active rather than believing the caller. Everything the brain notices spontaneously still waits in the tray, which is where the junk risk was actually measured (D-085). -
A plan belongs to one teacher.
role_idis NOT NULL, and the single cross-teacher view is a query on Home (readPlansInFlight), never a table — a table would put the bleed in the store where the isolation test cannot see it (D-087). The teacher's brief carries the ratio; the learner's screen gets what is next and no ratio at all. -
Intake comes before teaching on a first session (D-092), and the thing that makes that work is that the brief is not empty for a teacher which has never been offered a plan. It used to be, which is exactly why the ordering rule never fired:
isEmptysuppressed the whole block on the only session it applied to. Two questions, one message, then a proposal — the bound is what stops it becoming the interrogation E-32 forbids. -
The teacher has a clock, and a sitting is one day's work (D-096, completing D-058). The brief opens with the date and time in his zone — from his reminder settings, never the server's — and when this teacher last heard from him. It is facts, never arithmetic: nothing computes anything from them, and a plan and a clock still never meet in code (D-086). It is also guidance, never a lock — nothing in code refuses a turn or counts items per day, because C-22 forbids withholding teaching a learner asked for, and the "if he asks for more, teach him" line sits directly beneath the boundary it qualifies.
-
The standing hazard: the store cannot tell what was actually taught. The first live drive marked items done that had never been taught, putting a concept into the rotation for material the learner had never seen. Only the brief's wording and
advance_plan's description keep the cursor and the teaching together. If you change either, drive it against the live model and read the plan rows afterwards.mathos db plansis the check (D-089) — it flags completions that lack the shape of a lesson, and prints what it cannot see every time it runs. It is the command for a review question about something the learner does not recognise. -
Deleting a teacher deletes explicitly (D-091). Every foreign key from
rolesisON DELETE SET NULL, so dropping the row alone orphans the threads, concepts and observations rather than removing them — it looks like it worked and silently does not.deleteRolenames each table, and the test asserts what is gone from the tables rather than what a screen stops showing.
- Mathos's own DB holds all learning state: roles, threads, messages, concepts, FSRS state, review history, observations, weak spots. High volume, granular, ours.
- jamgate holds only the durable learner profile: who the user is, how he learns, what he's working toward. A few dozen facts. It is a late, narrow, optional dependency — Mathos must work when it is unreachable.
- The test when you're unsure: would this fact still be true and worth knowing in two years? If yes it may be profile; if it's about a concept, a lapse, or a session, it is learning state and it goes in the Mathos DB.
- Never put tutor logic in jamgate (D-009), and never touch the jamgate repo from here.
The whole repository is in English — prose, code, comments, tests, commit messages,
every file. No exceptions, and no second language kept "for history": a repository written
half in one language and half in another can be read by nobody but its author.
test/repo-language.test.ts enforces this.
docs/DECISIONS.md is not a changelog — it is the reason the project can be picked up
after a three-month gap.
- Every meaningful decision gets an entry, in the file's own format: Decision / Why / Alternatives considered. The alternatives section is not optional and not decorative: name what actually lost and why. An entry with no real alternative was not a decision.
- Never silently reverse an entry. Add a new dated entry that supersedes the old one and says which number it replaces, and add a superseded-by note to the old entry so nobody acts on it by accident.
- Never renumber.
D-###numbers are permanent. - If the docs disagree with each other, that is a bug — one of them is lying to a future reader. Fix all of them in the same commit as the decision that resolves it. (This has happened once already: D-010's status was recorded three different ways in three files, resolved by D-022.)
- Convert relative dates to absolute. "Next month" is useless in a log.
- Language: TypeScript only (D-019). No Python, no Go, no shell scripts doing real
work.
ts-fsrs, notpy-fsrs. - Node is pinned in
.nvmrc;enginesenforces the range. - ESM throughout (
"type": "module",NodeNextresolution). Use explicit.jsextensions in relative imports. - Strict TypeScript.
strict, plusnoUncheckedIndexedAccessandexactOptionalPropertyTypes. Don't loosen the compiler to make code fit; change the code.anyneeds a comment justifying it. - Commits: conventional commits (
docs:,feat:,fix:,chore:,test:), in logical units, with bodies that explain why when it isn't obvious (D-018). - Public repo. Assume everything written here is read by strangers. No secrets, no real
learner data, no tokens.
.envis ignored; keep it that way. - Prose: English, en dashes and real punctuation, wrapped around 90 columns. Prettier
owns formatting except
constitution/CONSTITUTION.md, which is loaded verbatim into the system prompt and is therefore in.prettierignore.
npm install # first time; Node version per .nvmrc
npm run check # typecheck + lint + format check + tests — run before every commit
npm run typecheck # tsc --noEmit, for src/ and for web/
npm run lint # eslint
npm run lint:fix # eslint --fix
npm run format # prettier --write
npm run format:check # prettier --check
npm test # vitest run
npm run test:watch # vitest
npm run build # tsc -> dist/
npm run clean # rm -rf dist
npm run web:dev # the app, at localhost:5178, proxying /v1 to a local `mathos serve`
npm run web:build # the app -> web/dist, which is what gets deployed
npm run web:preview # serve web/dist as nginx wouldThe CLI harness has mathos chat, mathos review, mathos roles, mathos eval,
mathos db, mathos serve, mathos invite, mathos push and mathos doctor.
mathos invite mints a one-time, short-lived sign-in link for a phone (D-075) — the way
anybody who is not sitting at the server gets in. The token lives in the URL's fragment so
it reaches no log, is stored only as a hash, and is spent by a conditional UPDATE so it
cannot be used twice. It is lowercase Crockford base32, normalised on the server (D-076),
because the first one sent to a real phone came back with two letters capitalised — never
reintroduce a mixed-case alphabet, and never normalise in the client as well. Never move it into a query string, never lengthen it to days, and
never add a route that returns a link that has already been minted: the plaintext exists once,
at mint time, and nothing can print it back.
mathos push is reminders (D-077): keys mints a VAPID pair, status says what is set and
what last worked, send pushes now, on/off flip it. status is the command for when a
reminder does not arrive, because the failure mode of a notification is silence — and so is
"nothing was due tonight", which is the correct behaviour rather than a fault. Reminders are
off until the learner turns them on from the app, at most one per configured time per local
day, at most three times a day, and never on an evening with nothing due. Never widen what
composeReminder can see: it takes a DueQueue and nothing else, which is why it is incapable
of a streak, a badge count or "you haven't studied today". That incapacity is the feature.
mathos serve is the same brain over HTTP — docs/API.md for the surface,
docs/DEPLOY.md for how it runs on the droplet. It refuses to start
without MATHOS_API_TOKEN, and that is not a check to relax: the thing behind it is an
Anthropic key (D-066). Never add a default token, a development mode, or a bypass. The one
route that answers without the secret is POST /v1/invites/redeem, and what makes it safe is
in D-075 rather than in the absence of a check.
A full mathos eval costs real money and is not an iteration loop (D-055). It runs the
whole corpus through the brain and then through the judge — docs/COSTS.md §7
prices it. Run it at a phase boundary (twice, both recorded in docs/eval/BASELINE.md) and
let CI run it on a master push that touched the constitution, the corpus, the prompt, the tools
or the harness. While iterating, use mathos eval --only E-01,E-07.
mathos eval rejudge <report.json> re-grades the replies a past run recorded, past whichever
judge is configured. It makes no brain calls, so the tutor is held constant and every
difference is the judge — which is how a change to the judge gets measured rather than assumed
(D-054). If you change MATHOS_JUDGE_MODEL or MATHOS_JUDGE_EFFORT, run it both ways and read
docs/eval/JUDGE-VALIDATION.md first: the expensive judge
disagrees with itself often enough that a raw disagreement rate means nothing on its own.
mathos db plans audits how plan items were completed (D-089) — free, no model call. It
catches items marked in a batch, marked with no teaching turn before them, or completed out of
order, and says so louder when the item also put a concept into the rotation. It cannot
detect a single item marked while something adjacent was taught, and it says so in its own
output rather than leaving silence to be read as proof.
mathos review with no argument reads the store and prints what is waiting, for nothing.
With a teacher it opens a conversation in that teacher's thread. No drill loop lives in the
CLI (D-044): nothing in src/cli/ writes a question, decides what comes next, or grades an
answer. If you find yourself adding one, you are building a flashcard app with a language
model bolted on, and it will have to be built again for whatever frame Phase 10 picks.
npx tsx src/cli/index.ts db status # where the learning store is and what is in itThe learning store is SQLite in one file outside this repo — MATHOS_DB_PATH, default
$XDG_DATA_HOME/mathos/mathos.db (D-027). Never point it inside the working tree: this
repo is public and the store holds real learner data.
| Path | What |
|---|---|
constitution/ |
The constitution and its eval corpus. Data. |
docs/ |
Background, design, decisions, roadmap, costs, quickstart, eval record. |
src/ |
The brain. See src/README.md for the per-directory map. |
web/ |
The app (D-069). See web/README.md. |
test/ |
Vitest suites for the brain. The app's are in web/test/. |
deploy/ |
systemd units and the nginx server block, as data. No scripts (D-019). |
backend/, pwa/ |
Retired placeholders, both superseded. web/ is the app. |
npm run checkpasses.- If behaviour changed, a decision entry exists or an existing one covers it.
- If the constitution changed, §11 was followed in full.
- No document in the repo now contradicts another.
- Nothing subject-specific leaked into
src/. - Everything is in English.
Documentation never lags behind the code, and no phase ends with uncommitted work.
Every document a decision touches — DESIGN.md, DECISIONS.md, ROADMAP.md,
CLAUDE.md, the READMEs, the eval docs — is accurate at the moment the phase closes, and
the working tree is clean and pushed. A phase that is "done except for the docs" is not
done; the docs are how the next phase starts.