Releases: miisodev/BrainLLM
Release list
v11.0.0 — Everywhere
v11.0.0 — Everywhere
Every client, every endpoint — plus a maintenance pass that fixes the brain itself.
Every client, every endpoint
Legacy SSE transport. Streamable HTTP replaced SSE as the MCP transport in the 2025-03-26 spec revision, but a long tail of clients still speaks nothing else — older Cursor builds, Continue, and anyone pinned to a pre-streamable Python SDK. BrainLLM now serves the full legacy wire contract natively: GET /sse opens the stream (first event names the POST endpoint with the session id), POST /messages ingests one JSON-RPC message per request. Implemented over Bun's web-standard request/response pair — the SDK's own SSE transport is Express-shaped and unusable here — with 30-second keepalive pings for proxies that time out silent connections. It sits behind the same authentication gate, the same CORS policy, and the same idle eviction as /mcp: an older transport must never mean a weaker door.
RFC 7591 dynamic client registration. The OAuth server previously spoke only Client ID Metadata Documents — which Claude selects, and which almost nothing else implements. opencode (MCP TS SDK ≤ 1.29) refuses to authenticate at all without a registration_endpoint. /register now mints opaque reg_* client ids, validated against the same redirect rules CIMD applies, stored in the persisted OAuth state under a 200-registration ceiling. The consent screen learned to identify non-URL clients honestly: a registered client is shown by its self-declared name, labelled as self-reported, while CIMD clients keep the DNS-and-TLS-vouched host. Both mechanisms coexist; Claude's flow is untouched.
Path-suffixed authorization-server metadata. /.well-known/oauth-authorization-server/mcp now exists alongside the root variant, per RFC 8414's path-insertion rule — some discovery implementations derive the AS metadata location from the resource path rather than the issuer, and refusing the variant reads as "no OAuth here".
A landing page at the root. A human or probing client landing on the origin now gets a page naming the server, the endpoint to point a client at, and what authentication expects — with the consent screen's no-external-request policy, for the same reasons. A bare 404 read as "nothing is here".
The brain maintains itself
Four defects and gaps, all confirmed in daily production use, all fixed in the maintenance engine:
- The pending-addendum counter lied.
session()reportedpending.addendums: 6whileaddendum()found 0 — three times, across two days. The counter matched the bare word "Addendum", which prose mentions of the tool match freely; the search required the structural marker (an h2–h4 "Addendum —" heading). Both now share one marker, so the one field added to stop the close protocol reciting empty steps can no longer manufacture one. - The entity-corruption detector stopped flagging documentation. A note that describes the double-escape trap necessarily stores its signature — inside a code element, deliberately. Five consecutive flags across two days were all this class. Classification now strips code/pre spans and re-tests: only matches outside them are corruption,
repairnever unwinds inside a span, and a documenting note stays silent unless repair was explicitly requested. revise(section=)catches typos. A section miss within edit distance of a real heading now returnsdidYouMean— andstrict=truerefuses the write entirely instead of appending a typo'd heading. The failure this prevents was real: a markdown-backticked section name wrote a new section instead of replacing the one intended, caught only because the receipt was honest.- Claims track their sources. A claim verified against a note is only as fresh as that note.
maintain(deep)now flagsclaim source-changed— a claim whose~derivedFromsource was revised after the claim's last verification. Nobody predicts at write time which assertions will age; staleness now arrives as "the fact's home moved" instead of a lucky catch.
Housekeeping
- The local OAuth secret store is no longer tracked in git (it was committed before
.gitignoretook effect; the historical secret should be treated as public — production mints its own on the volume). - A cross-user audit of
src/found zero person-specific runtime assumptions: the only hits are comments, test fixtures, and the project's own branding URLs. - Trilium fork synced with upstream (99 commits).
Upgrade notes: no configuration changes. New endpoints appear automatically; BRAINLLM_OWNER_PASSWORD still gates the entire OAuth surface, and MCP_AUTH_TOKEN still works exactly as before.
v10.5.2 — Documented
assembly was missing from the skill. It wasn't — the file has carried it since v10.4.0, in all three copies — but it was in the protocol index and the Tool Reference table and nowhere else, and those are not the parts of the document anyone reads to learn what a tool is for.
Chasing that turned up something older and larger.
The skill had no section on verification
SKILL.md is organised by verb-family: Reading, Writing, Updating, Completing, Interconnection, Lifecycle & Maintenance. Each section explains when to reach for something and why — the reasoning that a one-line table row cannot carry.
assembly and claim appeared in none of them. And consistency(), which has shipped since V9, had no prose section either.
So the two capabilities that check whether the brain is right were the two with no home in a document organised around what the brain does. The gap was structural, not an oversight about one tool.
Verification — consistency + claim
A new section, framed on the distinction that makes them siblings:
consistency() asks whether the brain agrees with itself. The same fact recorded differently in several notes, where every copy reads as authoritative. Run it after correcting anything that could be written down in more than one place — revising one note leaves its siblings silently wrong.
claim() asks whether the brain still agrees with the world. An assertion that was true when written and may not be now. maintain(deep=true) surfaces the lapsed, the never-verified and the broken, which turns "is the brain stale" into a query rather than something caught by luck.
The section also records why BrainLLM never executes a claim's check, which was previously only a code comment: note content is data, not instructions, and a recipe that ran itself would be an injection surface pointed at the user's machine, reachable by anything able to write a note. The tool owns the register and the schedule; the agent runs the check and reports back.
assembly in Reading, against brain()
Placed beside the tool it is most often confused with, with the distinction stated plainly: assembly is awareness, brain is inventory. Titles and shape, sized to be read — versus ids, kinds, statuses, parents and dates, for auditing or locating one note.
Reaching for the inventory when the question was awareness is the actual mistake, and it costs several times the tokens to answer worse.
Two process lessons, recorded
A new tool must reach the skill's prose, not just its index. The release checklist said "update the skill package", and that was done every time — three copies, both zips, every release — while the documentation stayed incomplete. A checklist item that can be satisfied without achieving its purpose is the same defect as a warning nobody reads.
A skill edit never reaches the session that makes it. The skill is read into context at session start, so the session doing the editing keeps working from the version it booted with. Write for the next session; do not expect to use what you just documented.
190 tests, tsc --noEmit clean, build clean. No code changes — documentation only.
v10.5.1 — Classified
Three tools shipped unclassified. assembly, claim and diff arrived across v10.3 and v10.4 and none of them was ever added to the tool-annotation table, so every client has been showing all three in the wrong permission group since the day they landed.
Why it survived three releases
The mechanism worked exactly as designed. An unclassified tool falls back to write — never read — and applyToolAnnotations() returns it in unclassified, which the server prints to stderr on every boot.
So the warning fired. Correctly, on every single start, for three releases. The server's stderr is a Railway container log nobody tails.
A safe default plus a notification nobody reads is indistinguishable from correctness right up until someone checks.
The harm is also the opposite of the obvious one. No write leaked into the read-only group — the fail-safe prevents that. It was two genuine reads stuck in the write bucket, so assembly and diff prompted on every call. That is precisely how a user learns to answer "always allow" once and stop reading prompts, which is the same failure as a maintenance flag that always fires.
The classification
assembly and diff are reads. Neither writes anything.
claim is append, not write. It is mode-inferred and its modes disagree: listing and reading write nothing, registering is idempotent (deduped by assertion), and recording a verification appends a dated line every time. A tool gets one annotation, so it takes the weakest guarantee any of its modes can honour — a second verify call really is a second effect. Classifying it by its cheapest mode would be the exact mistake the table exists to prevent.
The full surface, now that it is actually split:
| read | write | append | destructive | |
|---|---|---|---|---|
| Core (42) | 23 | 13 | 4 | 2 |
| Raw / full mode (33) | 11 | 13 | 5 | 4 |
Core and raw are composed, not nested
registerTools() took a mode flag and reached into tools-advanced.ts itself, so "core" and "full" were one tangled surface with the boundary expressed as a boolean halfway down a 3,800-line file.
The caller composes them now: core is core, full is core plus the raw set. What a mode contains is visible where the decision is made, and the two sets are provably disjoint — the SDK throws on a duplicate name, which is how the new test found a bug in itself on the first run.
Tests that make this class impossible rather than unlikely
src/annotations.test.ts fails when any registered tool is unclassified in either mode, when the table names a tool that no longer exists, or when a tool claims to be both read-only and destructive.
It also pins the tool counts at 42 / 33 / 75, deliberately. Those numbers appear in the README, the BLUEPRINT and three pages of the landing site; nothing derives them, and they have gone stale on three separate releases. Adding a tool now fails a test — which is the reminder to update the places that state the count. A tripwire, not an assertion.
190 tests, tsc --noEmit clean, build clean. 42 core tools, 75 with full mode.
v10.5.0 — Recognisable
V10.4.1 proved ownership of the package. This release makes the one page a user actually sees look like the thing they are trusting.
It is also the first release that published itself end to end — tag, gates, npm, registry, verification — with nothing left for anyone to remember.
The consent screen
If you connect BrainLLM to Claude, the authorization screen is very likely the only BrainLLM interface you will ever look at. Until now it was a generic light/dark card with a purple button, bearing no visual relationship to the product it was granting access to.
That is not a cosmetic problem. A consent screen is the single page where someone must be certain what they are looking at, and "this doesn't look like the thing I installed" is exactly the doubt it must not create.
It now carries the landing site's design: the #0a0a0f ground with its amber radial glow, Space Grotesk headings, the raised card on a hairline border, a monospace chip for the requesting host, and the site's own 3×3 node mark — six lit, three dark.
The error page shares the same shell. It was previously inline styles on a bare <body>, which meant the two pages reachable in this flow looked like they came from different products — and the unbranded one was the page users hit when something had already gone wrong.
It makes no external requests, deliberately
The landing site pulls Space Grotesk and Inter from Google Fonts. The consent screen does not, and that is a decision rather than an omission to tidy up later.
A third-party request on this page would tell that host someone is authorizing access to their memory, add a dependency the authorization flow cannot function without, and place a blocking request on exactly the networks most likely to restrict it.
The font stacks name the same faces first, so a machine that already has them renders identically. The brand mark is CSS dots rather than an image for the same reason — no request, and no rasterised copy that can drift from the vector.
A test asserts the rendered page contains no external src or href at all, so the reasoning does not have to be rediscovered by whoever next edits the file.
The security property is now tested, not just commented
The screen names the host of the client_id URL — the one part of a client's identity that DNS and TLS vouch for. It never displays client_name, which is self-asserted text in a document the requester controls and could just as easily read "Anthropic".
That was previously guaranteed by a comment. It now has tests: the host is displayed and HTML-escaped, carried query parameters are escaped, and the error state keeps the form intact.
The release verifier was wrong on every successful release
v10.4.1 published correctly to both registries and the workflow reported failure. The verify step called npm view immediately after npm publish, npm's CDN had not propagated, and it announced "the publish did not take effect" about a publish that plainly had.
A check added to prevent false confidence produced a false alarm instead, which is the worse of the two failures — a red that is routinely wrong is a red nobody reads.
It now retries for three minutes against both registries, prints what it actually saw on each attempt, and on genuine exhaustion says to check the registry directly rather than assume failure, because the publish step reporting success is stronger evidence than a verifier's timeout. It also genuinely checks the MCP Registry now, rather than echoing the response and passing regardless.
First fully automatic release
Everything downstream of git push origin v10.5.0 happened on its own: gates, build, 181 tests, tag-versus-manifest agreement, release-metadata validation, npm publish with SLSA provenance, MCP Registry publish, and outside verification of both — authenticated end to end by OIDC, with no secrets stored anywhere.
The failure this removes had happened twice: six commits shipped between v10.1.0 and v10.2.0 with npm still serving 10.1.0, and v10.3.0 shipped complete with docs, site and a live deploy while every registry stayed put. Both times every local signal was green.
181 tests, tsc --noEmit clean, build clean. 42 core tools, 75 with full mode.
v10.4.1 — Registry ownership
V10.4 added the awareness read. This release is about ownership — proving it, and never again having to remember to claim it.
BrainLLM is now in the MCP Registry as io.github.miisodev/brainllm, and every release from here publishes itself.
The registry entry, and why it took four releases
server.json had been maintained and version-bumped since v10.1 — correct name, correct package, correct environment variables. It had never been published. Nothing was broken; the file simply looked like a completed integration, and a local artifact that looks like a shipped thing is invisible until someone queries the outside world.
Publishing it required proving ownership of the npm package. The registry does not take the claim on faith: it fetches registry.npmjs.org/brainllm/<version> and reads an mcpName field off the published package.json, which must equal the server name.
That has an ordering consequence worth stating, because it is the kind that costs a release to learn: npm versions are immutable, so the proof has to be baked into a version that npm actually serves. v10.4.0 could never have been registered, no matter what was done to the working tree. Hence 10.4.1.
Releases now publish themselves
.github/workflows/publish.yml runs on any v* tag and does everything downstream of it — gates, build, tests, npm, the registry, then verification of both from outside.
No secrets. Both publishes authenticate by OIDC from the workflow's own identity: npm trusted publishing, and mcp-publisher login github-oidc for the registry, which proves the io.github.miisodev/* namespace from the repository owner. npm also attaches SLSA provenance automatically.
The alternative was a granular npm token, and it is worse in a specific way. The default expiry is 30 days, and an expired token fails npm publish with a 404 on PUT rather than a 401 — npm answers unauthenticated writes to an existing package that way so it does not leak whether the package exists. Read literally, it says the package does not exist. That exact message cost time on the day this was set up. Worse, npm publish --dry-run succeeds against a dead token, because it never authenticates.
This closes a failure that had happened twice. Six commits shipped between v10.1.0 and v10.2.0 with npm still serving 10.1.0. Then v10.3.0 shipped complete — docs, landing site, live deploy — while npm, the tags, the releases and the registry all stayed exactly where they were. Both times every local signal was green: clean tree, passing CI, a successful deploy. A push satisfies all of them while leaving untouched every registry a user installs from.
The bug that would have shipped
server.json was invalid against its own declared schema, and had been for four releases: a 259-character description against a 100-character limit. Nothing validated it, so the first signal would have been the registry rejecting the publish — after npm had already published immutably, forcing a 10.4.2 to fix a typo-scale problem.
scripts/validate-release.ts now runs in CI on every push, not at release time. It checks four things, each one a failure that has actually occurred here:
server.jsonagainst the schema it declares — not a pinned one, because the file names its own contract and validating against anything else lets the two drift while still reporting greenpackage.jsonmcpNameequalsserver.jsonname- All four version manifests agree
- The namespace matches the identity the publish will authenticate as
The publish workflow adds a fifth: the tag must equal every manifest. Six version sites across five files have drifted independently more than once.
$schema also moves to 2025-12-11. The two schemas were diffed rather than bumped on faith — no properties added or removed at any level, and packages[].version relaxed from required to optional. Strictly more permissive.
The OAuth pages look like the product now
The consent screen was a generic light/dark card with a purple button, bearing no relationship to the application it authorizes access to. That is not cosmetic: a consent screen is the one page where someone must be certain what they are looking at.
It now carries the landing site's tokens — the #0a0a0f ground and its amber radial glow, Space Grotesk headings, the raised card on a hairline border, a monospace host chip, and the brand's own 3×3 node grid. The error page shares the same shell; it was previously inline styles on a bare body, which made the unbranded page the one users hit when something had gone wrong.
It makes no external requests, deliberately. The landing site pulls its fonts from Google Fonts; this page does not, and that is a decision rather than an omission. A third-party request on a consent screen tells that host someone is authorizing access to their memory, adds a dependency the authorization flow cannot function without, and blocks on exactly the networks most likely to restrict it. The font stacks name the same faces first, so a machine that has them renders identically, and the brand mark is CSS rather than an image for the same reason. A test asserts the page contains no external src or href at all.
Five new tests also pin the security property that was previously only a comment: the screen names the host — the one part of a client's identity DNS and TLS vouch for — and never client_name, which is self-asserted text in a document the requester controls and could just as easily read "Anthropic".
Also
CI discovers test suites by glob instead of naming them. The explicit list meant a new suite sat outside CI until someone remembered to add it, and nothing failed when they didn't — it happened to oauth.test.ts for a full release, and to kinds.test.ts on the day it was written. Both times the omission looked exactly like success: green.
ajv was promoted from a transitive dependency of the MCP SDK to an explicit one. A release gate reached through someone else's dependency tree fails open the moment they reorganise it.
181 tests, tsc --noEmit clean, build clean. 42 core tools, 75 with full mode.
v10.4.0 — Awareness
V10.3 made the tools honest. This release adds the one read that was missing — what does the brain already hold — and, in building it, found a class of content that had been quietly unfixable since the day it was written.
assembly()
"What do I already know here" had no cheap answer. brain() is an inventory — id, kind, status, parent, dates and relations for every note — which is the right shape for auditing or locating one note, and the wrong shape for orienting before deciding whether to look something up.
assembly() answers that question instead: every note by title, grouped under the surface it lives in, with each surface's purpose alongside it. Three choices follow from the question:
- Dated collections collapse to a count and a span. Sessions, diary entries and logs are all titled
[yyyy-mm-dd]and are the bulk of the brain by note count. Listing them is a wall of dates carrying none of their content —"47 entries, 2026-06-30 → 2026-08-16"is the informational content of that list. - Domains nest under their book. The real shape of what the brain knows. The flat listing is the shape that got a domain misread as empty and cost an 8,259-byte note.
- Threads group by status. The working set is the actionable part; a resolved thread is history.
area= zooms into one surface. Each surface's purpose is read from the container note, not hardcoded — a copy in the tool would silently disagree with the note the moment either changed, and the note is the one the user can see.
Container purposes can now be healed
Serving that text exposed something that had been invisible since bootstrap: container purposes are written once and are then unreachable. revise() refuses containers via isContainer(), so nothing in the tool surface could update one, and a purpose that went stale stayed stale for the life of the brain.
The live instance still described Insights as holding "per-day logs of how its own content changed" — written 30 July, and wrong from the moment Insights gained the rendered graph and the claims register. Nobody had a reason to read that note, so nobody did.
bootstrap()'s re-run path now re-engraves them. It compares each container against CONTAINER_PURPOSES — one exported map shared by creation and refresh, because two copies of those strings would reintroduce exactly the drift this fixes one level up — writes only genuinely different text, and reports every change in purposesRefreshed. It overwrites a note the user can see, so it is never a silent side effect of a call made for another reason.
Singleton leaf notes are deliberately excluded: their bodies are content the assistant maintains, and re-engraving one would destroy it.
The general lesson
Nothing about the Insights wording changed in this release. It had been wrong for weeks, sitting in a note nothing had reason to open. Building something that displayed it found it immediately — which generalises: the cheapest way to discover stale text is to give something a reason to read it.
Also in this release
CI now discovers test suites by glob rather than naming them. The explicit list meant a new suite sat outside CI until someone remembered to add it, and nothing failed when they didn't — it happened to oauth.test.ts for a whole release, and to kinds.test.ts the day it was written. Both times the omission looked exactly like success: green.
CLAUDE.md gains a full release checklist covering every distribution BrainLLM ships through — tag, GitHub release, npm, the MCP registry — and the instruction to verify each one from outside the repo. A push satisfies every local signal while leaving every registry a user installs from untouched, which had by then happened twice.
176 tests, tsc --noEmit clean, build clean. 42 core tools (32 universal verbs + 10 surface reads), 75 with full mode.
v10.3.0 — Honest tools
V10.2 made the project reachable. This release makes its tools honest — it closes a register of 21 defects and requests accumulated across two weeks of daily use, and the through-line is that almost every one of them reported success while being wrong.
Three of them shipped once with a passing test suite, because the suite tested what the code did rather than what it was for. Each now carries a regression test asserting the failure mode.
The corruption that kept coming back
escapeHtml() was a blanket & → & with no entity guard, and it runs on every paragraph, heading, list item and table cell taking the markdown path. Any plain body already carrying an entity — an copied out of a prior read, a stray <, a URL with an ampersand — was escaped a second time and stored as visible literal text.
This was declared fixed in August and recurred twice. The reason is precise and worth stating: the August work hardened looksLikeEncodedHtml() and decodeEncodedHtml(), which are the detector and the repairer. The producer sat one function away and was never touched. The guard now applied is the same one decodeEncodedHtml() was already using.
maintain() had the matching blind spot — it searched only for the literal &lt;, while the commonest corruption this bug produces is &nbsp;. It now matches any doubly-escaped entity, and maintain(repair=[noteId]) unwinds one level in place with a revision taken first.
Backslashes were being deleted before the query ran
consistency() and recall(regex=) routed their patterns through escapeQueryValue(), which replaces every backslash with a space. So (\d+) migrations reached the backend as ( d+) migrations and matched nothing, while ([0-9]+) found the real answer.
A zero-result contradiction sweep reads as "the brain agrees" — the exact opposite of the truth. This was a silent wrong answer, not a failed call.
Trilium is not at fault: %= compiles the string with new RegExp(str, "ms") and supports \d fine. Its lexer is, and the rule is now measured rather than inferred — running Trilium's own lex() gives backslash survival of floor(n/2), identically inside single and double quotes, with \s \b \. \w all consumed at one backslash. escapeQueryRegex() therefore doubles, and the full chain is verified end to end.
One query, two different documents
Trilium runs %= against content its preprocessor has already put through striptags; both tools then re-ran the regex against the raw stored body. Two different corpora behind one query, failing in both directions: a phrase split by an inline <strong> or <code> was found by the backend and silently dropped locally, and a pattern anchored on tags never entered the candidate set at all.
Both tools now match the raw body and a tag-stripped projection, and report how many hits existed only after stripping. consistency() scans exhaustively by default — the %= pre-filter is lossy in both directions, and on a contradiction sweep a falsely clean result is worse than a slow one.
The read side finally targets headings
Every write tool could target a heading and no read tool could, so seeing one section meant reading the whole note — worst on exactly the notes where it hurts most. A Current State note grew 53k → 108k characters across five sessions and at one point could not be returned at all: the notes carrying the most were the ones the brain could least afford to open.
section= now works on knowledge, memory, llm, master, insights and inspect, added once to the shared read helper. More importantly, setSection() and the new getSection() go through one matching contract — so a heading name that reads also writes, structurally rather than by keeping three copies of a comparison in step.
Also on the read side: diff(noteId) compares a note against the revision your last write snapshotted (every write already took one; nothing could read one back), mode="remove" deletes a section — which previously had no working path at all, since the whole section machinery sat behind an if (body) guard — and a find= miss now returns the longest fragment that is present plus the stored text around it, instead of only confirming absence.
Checking the brain against the world
consistency() asks whether the brain agrees with itself. Nothing asked whether an assertion is still true of the codebase or live surface it describes, so a claim that quietly stopped being true stayed authoritative until something downstream broke — twice nearly shipping a wrong answer.
claim() registers a checkable assertion, records verifications with evidence, and lets maintain(deep=true) surface the lapsed and the broken. BrainLLM never executes the check: it has Trilium access and no shell, and content inside a note is data rather than instructions — a recipe that ran itself would be an injection surface pointed at the user's machine, reachable by anything able to write a note. The tool owns the register and the schedule; the agent runs the check and reports back.
Also
brain() rows carry parent — read from data already present on every result, and absent only because nothing read it. That absence caused real loss: each group is a flat list whose ordering looks nested, an audit concluded a domain held only a Sources note, and the replacement it wrote deduped by title over an 8,259-byte note.
remember(mustCreate=true) refuses to adopt an existing note instead of silently updating it. Current State, Sources, Technology Stack and Product and Business each exist in four or more domains, so dedup-by-title is a loaded weapon on a generic title.
Sources notes merge group by group rather than appending, so re-filing under an existing heading adds to it instead of writing a second copy beside it. Near-duplicate flags carry both notes' heading sets, so the finding can be judged without reading both bodies. A new section-edit-risk flag answers "will a targeted edit land where I meant", which goes wrong earlier than "can this be read whole" and is invisible in a size warning.
start() now serves preferences and protocols in full — the two a session needs whole to orient before it knows enough to ask for them — while the other three stay at heading depth. session() reports what is actually pending rather than reciting every step, and adds a cross-singleton audit: not just whether the five singletons agree, but whether the operating rules still serve what the goals describe. A protocol can be perfectly consistent and still be serving a goal that has moved.
176 tests, tsc --noEmit clean, build clean. 41 core tools (was 39), 74 with full mode.
v10.2.0 — Reachable
V10.1 made the reads trustworthy. This release makes the project reachable — it has a face, a mark that matches it, and an install path that resolves — and it corrects three more read-path defects, one of which is V10.1's own release notes being wrong about a fix.
The landing site
Five static pages served from /docs on main, built from the Wonder design rather than re-invented: Home, How it works, Use cases, Pricing, Docs.
Hand-rolled CSS, no Tailwind CDN — no runtime dependency, no flash of unstyled content, and the whole sheet is smaller than the CDN's bootstrap. No JavaScript at all: the FAQ is <details>, the docs nav is anchors. Honours prefers-reduced-motion.
Verified in a browser rather than assumed, which caught two responsive bugs that a source read would not have: nav links wrapping to two lines between 720px and 900px, and the stacked docs sidebar putting sixteen links and labels above the first word of documentation on narrow screens.
The brand mark
BrainLLM.svg was a traced bitmap — 133 hand-plotted paths carrying several hundred near-identical blue/purple hexes, 60 KB, in a palette that appeared nowhere on the site, in the app, or in the launch copy.
It is now exact geometry, and the same figure the site's wordmark carries: a 3×3 grid on a 512 canvas, r=38 on a 92px pitch, six nodes lit in #f59e0b and three dark in #3f3f46 over a #0a0a0f plate. 60 KB → 1.6 KB. It reads as a small graph rather than a brain, which is what the product actually is — typed nodes, some connected, some not yet.
The plate is part of the mark, not a backdrop: rasterised icons land in favicon and connector slots over unknown backgrounds, and amber dots alone vanish against a light one.
BrainLLM.png is now generated by bun run icons alongside the four icon sizes. It was previously a hand-exported raster — which is precisely why the README kept showing the old artwork after the mark had changed. Every brand raster now has one source, so that drift cannot recur.
Also: full favicon set plus theme-color, and og:image made absolute — scrapers fetch it out of page context, so a relative path resolves against their host and the card renders blank.
Read path
section= and outline() disagreed on a heading's name. setSection() built <h3>\s*target\s*</h3> and tested it against raw HTML, so a heading carrying any inline markup could never match — its inner HTML is not its text. headingOutline() strips those tags. The two halves of one contract disagreed, and outline() was handing callers a name that section= would then refuse.
The refusal is not an error. A section= miss silently appends a new section — so trusting outline()'s own output produced duplicates rather than a failure, twice on one note before it was noticed. Both sides now compare stripped heading text, making the contract single-valued: any name outline() prints is a name section= matches. Five regression tests, including that invariant asserted over a body mixing plain, <code>, <strong>/<em> and entity headings.
recall(regex=) over-matched on alternation. Trilium's %= filter widens: zzzznotrealzzzz|triliumnext/trilium:latest returned five notes where three contained either alternative, and a six-term staleness sweep returned a note containing none of them. This is the tool you reach for to ask "has this wrong claim leaked anywhere else" — and a false clean sweep reports the exact opposite of the truth.
%= is now the candidate filter only; every candidate is re-tested against the real regex before it is returned, with dropped candidates reported rather than hidden. An invalid pattern errors up front instead of being handed to the backend.
This corrects the record. V10.1's notes said regex was "fixed, verified with both controls". That verdict came from one simple pattern and was over-generalised — a control proves the case you ran, not the claim you want to make.
consistency() read group 1 instead of the first group that captured. An alternation puts the value in whichever branch matched, so a([0-9]+)|b([0-9]+) leaves m[1] undefined whenever the second branch wins. The call then silently degraded to presence mode and reported "the pattern has no capture group" about a pattern that plainly has two. Found by using the tool for its intended job — a staleness sweep over this brain's own tool counts — one day after shipping it.
Audit path
The unlabeled-node sweep no longer flags the user's own folders. Extending the check to Knowledge/Master in V10.1 found a genuine invisible note and a false positive: a book the user created to group per-month notes was reported as a note that had lost its label. Knowledge/Master holds text notes, so a book with children there is human organisation. Skipped — while Knowledge/Domains stays exempt from the skip, because its children are books and genuinely want #noteType=domain.
Housekeeping
start-trilium.ps1 hardcoded the author's install path and reported port 37840 while every config example used 8080 — a documented seam that had drifted into a contradiction. It also framed the failure wrongly: the brain this repo serves runs hosted, so "tools time out" almost never means "start the desktop app". Removed, and all four references rewritten to point at the real diagnosis — the backend named by TRILIUM_BASE_URL is unreachable, and since every ETAPI call is bounded at 30s with one retry on idempotent reads, a timeout is the backend rather than the tool.
config.example.json now offers three labelled options — npx, local build, and remote — each with a comment saying when to pick it, plus a note that hosted Claude surfaces need OAuth rather than a bearer token. The npx option is only possible at all since the package gained a bin in V10.1.
Release hygiene
All seven version sites now move together. The documented rule covered six; docs/index.html gained a version pill with the landing site and was never added to the set, so the site would have advertised the previous version against this release.
These six commits sat on main for a day with no tag, no release and no npm version — everything green locally, nothing reaching a user. Worth stating plainly, since it is the same class of defect as the rest of this release: the artefact and the thing it is supposed to govern drift independently, and only one of them is real at run time.
v10.1.0 — Trustworthy reads
The first tagged release. Every fix here sits in the read or audit path, and they shared one signature: returning something plausible instead of erroring. The write path was already guarded, templated, deduped, snapshotted and linted — for a system whose whole value is being trusted later, that asymmetry ran the wrong way.
New — consistency(pattern, domain?, kinds?)
The cross-note agreement check. Give it a regex with one capture group naming the value that should agree:
consistency("(\d+) Titan mailboxes")
consistency("BRAINLLM_MODE[=: ]+(\w+)")
It returns every note asserting a value, grouped, with agreement: "unanimous" or "DISAGREEMENT".
Nothing else detected the same fact recorded differently across several notes, where every copy reads as authoritative. recall() ranks by relevance; maintain() checks structure; a correction applied to one note leaves its siblings silently wrong.
Read path
recall()title matching is OR + per-token scoring, not AND, with an exact-title bonus. A note titled exactly Tool Surface previously scored zero from titles fortool surface full mode— "full" and "mode" are not in its title — and lost to full-text noise on recency.- The empty-result hint no longer recommends
remember()-ing the content, which walked callers into duplicating something already stored.
Audit path
maintain(ack=)now covers every pass. Theentity-corruptedandrevision-bloatpasses bypassed the acknowledgement filter, so an acknowledged finding re-fired every run — the exact failureack=exists to prevent, occurring inside the ack mechanic.- The structural lint window rotates by date and names what it skipped. It read the same 40 of ~70 notes every run, leaving a third of the brain an unnamed permanent blind spot.
- The lint checks completeness, not just well-formedness. A thread that lost its Resolution and a Sources note with no Revision table both passed every check that existed.
- Near-title duplicate subjects are flagged within a domain. Equality-matching fires on the case that never happens; nobody writes a byte-identical title.
- The unlabeled-node check covers Knowledge/Master and Knowledge/Domains, whose omission let an entire strategy layer sit invisible to every read path.
The untyped-note trap — three halves of one problem
A note missing #noteType is invisible to every read. Previously there was no way back:
- Dedup now falls back to title-and-container, so
remember()adopts an untyped note instead of minting a duplicate beside it. label()can repairnoteTypeon a note that has none (still refuses to change or remove an existing one).undelete_notere-applies the label set for its container — Trilium's restore drops attributes, which is the root cause of the incident where two thirds of a brain went invisible.
Silent-loss guard
forget(hard=true) now refuses a note with children or multiple parents, naming what would be destroyed — Trilium deletes the whole subtree on the last branch.
Structure
The thread Resolution guard rejected the placeholder that remember() itself writes and template("thread") documents, so a thread repaired via revise() could not be made to match one created via remember(). It now rejects only a filled or duplicate Resolution.
Packaging
npm install brainllm works for the first time. The package had no bin, no main, and no files — so npm fell back to .gitignore, which ignores dist/, meaning the tarball would have shipped all of src/ and none of the built bundle.
CI
GitHub Actions was disabled at the repository level while the workflow reported state: active, which is why seven commits triggered zero runs. Re-enabled, plus an explicit tsc --noEmit step (bun build does not type-check) and oauth.test.ts added to the run — the CIMD impersonation and code-harvesting tests were outside CI entirely.