Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Contributing and reference6 of 12

The build pipeline

About 6 minutes to read

On this page

pip install kotoba-companion gives you a working web app without Node ever being on the machine. That works because the compiled frontend is copied inside the Python package before the wheel is built.

Two files do it:

FileJob
scripts/build_web.pyruns Node, checks the output, copies it in, stamps it
api/kotoba_build.pythe PEP 517 build backend; refuses to package a build that is absent or stale

api/kotoba_build.py never runs npm. That is the whole point: installing the package must not need Node.

python scripts/build_web.py was not run while writing this page — it rewrites api/src/kotoba/web/. Its individual checks were run against the build already in the tree, and the numbers below are what they returned.

What build_web.py does, in order

Before it starts Node, three refusals:

  1. A stray env file at the repository root. .env, .env.local, .env.production, .env.production.local, .env.development, .env.development.local. Next inlines any NEXT_PUBLIC_* it finds, and those values would then ship to everyone who installs the wheel.
  2. A Live2D model under public/. Any .moc3 or .model3.json. It walks symlinked directories too, because a model linked into public/ was invisible to a plain glob while the build packaged it anyway. A directory it cannot read raises rather than being skipped — a licence guard must not fail open.
  3. No npm on PATH.

Then it builds. npm run build runs with every NEXT_PUBLIC_* stripped from the environment, plus DOCKER_BUILD and KOTOBA_BACKEND_URL, and with KOTOBA_STATIC_EXPORT=1 and NEXT_TELEMETRY_DISABLED=1 set. The static export lands in .next-export/.

Then it checks what came out:

  • All four pages exist: app.html, login.html, setup.html, 404.html.
  • There is at least one .js under _next/static.
  • The stranger scanner finds nothing (below).
  • The metadata scanner finds nothing (below).

Then it copies and stamps. .next-export/ is copied to api/src/kotoba/web/, api/build/ is cleared, and kotoba-build.json is written:

json
{
 "version": "0.10.0",
 "files": 69,
 "web_sha256": "…",
 "sources_sha256": "…"
}

The digest stamp, and what "stale" means

The stamp answers two different questions.

  • web_sha256 and files — is the build intact. Hashed over every file in web/ except the stamp itself, which cannot cover itself.
  • sources_sha256 — has the frontend moved on since. Hashed over app, components, lib, public, next.config.ts, tsconfig.json, global.d.ts, package.json and package-lock.json, skipping any __tests__ directory and all of public/models.

That second one is the one that actually ships broken: the build is perfectly intact, and out of date.

Both digests hash in order of the path text, not by sorting Path objects. PurePath compares case-folded on Windows and plain everywhere else, so an upper-case sibling changes place — and a digest written on one platform then failed to verify on another. This project ships on both.

Measured against the build currently in the tree:

stamp version : 0.10.0   package: 0.10.0
stamp files   : 69      counted: 69     match
web digest    : match
sources digest: match

The scanner that refuses to ship your machine

This is the unusual one, and it is worth knowing about even if you never touch packaging.

next build inlines whatever it finds. A NEXT_PUBLIC_* variable, an address in a config, a tunnel URL from yesterday's testing — all of it gets frozen into a minified chunk, where one string looks like any other. Then it goes to PyPI and installs on everyone's machine. Nobody notices, because nothing is broken; the build simply carries the address of the computer that made it.

So before anything is copied, every file in the export is read and judged.

It calibrates itself to whoever runs it. builder_marks() asks the operating system for this machine's hostname, its fully-qualified name, the current username and the home directory — at scan time, so nothing has to be written down and no name ever appears in the repository. Names shorter than five characters are dropped, as are the 36 names too generic to mean anything (user, runner, ci, ubuntu, docker, github, and so on). On this machine it found 3 marks.

What it refuses, checked in this order:

ShapeExample
An IPv4 or IPv6 literalhttp://192.168.1.42:8000
A loopback host in a URLws://localhost:9777
A private-network namedesktop-abc.lan, anything ending .local, .internal, .home.arpa, .corp, …
A tunnel hostname23 domains, including ngrok, cloudflared, localtunnel, serveo, zrok, bore
The builder's own namewhatever builder_marks() found
A home directory/home/…, /Users/…, C:\Users\…
A source mapa .map file, or a sourceMappingURL comment
A voice agent idagent_…
Any host not on the allowlistanything else with a dot and a known TLD

The order is load-bearing. Every shape that can only be a private machine is judged before the allowlist is consulted, so putting something.lan on the allowlist would not let it through.

The allowlist itself is short and every entry has a reason written beside it: www.w3.org for XML namespaces, react.dev for React's minified error decoder, cdn.jsdelivr.net for a worklet the voice SDK fetches at runtime, api.openai.com for the placeholder shown in Settings, and a handful more.

It reads more than plain text. Before matching, it un-escapes JavaScript's \/ and \u002f, decodes %3A-style percent escapes, and decodes every base64-looking blob it finds and scans that too. It also scans the file names, not only the contents.

A second scanner reads image metadata. PNG, WebP and JPEG files are walked chunk by chunk and any chunk outside the known-good set is reported — EXIF, ICC profiles, XMP, comments. SVG files are checked for editor signatures and RDF metadata blocks. A camera's EXIF or an editor's author field travels with every install otherwise.

Measured against the build in the tree: 0 hits, 0 metadata findings.

And measured firing, against a planted sample:

/home/somebody (home path)
192.168.1.42:8000 (ip literal, url)
desktop-abc.lan:3000 (private name, url)
localhost:9777 (loopback, url)
silly-name.ngrok-free.app (tunnel, url)
sourceMappingURL (source map)

If the scanner stops you, do not add an allowlist entry to make it quiet. Find where the value came from — it is almost always an env file or a shell variable that Next read.

The wheel refuses a stale build

api/kotoba_build.py wraps setuptools. On build_wheel and build_sdist it:

  1. Mirrors the licences. LICENSE and THIRD_PARTY_NOTICES.md are copied from the repository root into api/, because pyproject.toml's license-files cannot reach outside its own directory. The copies are not kept in the repository — they would drift — so a fresh checkout has none, and without this step the wheel would ship with no licence at all. It refuses outright if neither the original nor a copy exists.
  2. Verifies the packaged web UI through api/_web_check.py, which reports every problem in the words the person who has to fix it needs: missing pages, no JavaScript, no stamp, a stamp for another version, a digest that no longer matches, or a frontend that moved on since the build. Then: build it first: python scripts/build_web.py (needs Node).
  3. Clears api/build/lib. setuptools copies there and never cleans it, so an in-tree wheel would otherwise carry every file of every earlier build — renamed chunks, modules deleted months ago. Nothing downstream would notice, because every other check reads the source tree, not the staging.

build_editable is different on purpose: it mirrors the licences and does not require the frontend build, because a contributor runs the dev server. That is why pip install -e works on a fresh clone with no Node at all.

api/_web_check.py lives beside the build backend rather than inside it, so it can be exercised where setuptools is absent. A guard nobody has ever seen refuse is not a guard.

The tests that cover this

bash
pytest api/tests/test_the_wheel_carries_the_web_ui.py -q   # measured: 24 passed
pytest api/tests/test_packaged_data.py -q                  # measured: part of 20 passed

The first skips its build-reading tests when no build exists, so a clone stays usable. Setting KOTOBA_REQUIRE_WEB=1 turns that skip into a failure — see continuous integration.