Configuration and operations5 of 9
The files library
On this page
~/.kotoba/files is where every file Kotoba creates or edits ends up, and — this is the part that
matters — it is also her working directory. There is no per-session copy and no sync step. When she
runs mkdir web && mv index.html assets/* web/, that is your directory being rearranged, and the Files
panel shows the result immediately because the panel reads the directory.
KOTOBA_FILES_DIR moves it. KOTOBA_WORKSPACE_DIR changes something else entirely — see
Pointing her at a real project below.
Getting files in
Copy them into the folder. That is the supported route and the one everything else is built around.
Anything in ~/.kotoba/files is something she can read, edit, run and move.
/attach <path> in the terminal. The behaviour depends on the file:
| Kind | What happens |
|---|---|
.png .jpg .jpeg .webp .gif .pdf | Rides the next turn's context as an image or file part. Nothing is written to disk |
| Anything that decodes as UTF-8 text | Copied into the library, and she reads it there |
| Anything else | Refused: "isn't text and isn't a picture — I'd only see bytes" |
Attach in the browser. Images and PDFs go to POST /api/session/{id}/attachment as a base64 data
URL. They are held in memory for the next turn and are not written into the library.
Two caps apply to attachments. The size one is 14,000,000 characters of the base64 data URL, not of the file — base64 inflates by about a third, so the practical ceiling on a real file is nearer 10 MB. The count one is four files per message; the fifth is refused out loud — "I can only carry 4 files in one message — send this one and I'll take the next lot after it" — rather than dropped in silence, which is what used to happen. The terminal checks the raw bytes against the same number first, so a large file is refused before it is encoded.
There is no upload button and no upload endpoint. The Files panel offers listing, previewing,
downloading and deleting; the API has GET, POST /api/files/seen and DELETE, and nothing that
writes a file. Putting one in is a copy into the folder, or /attach.
Getting files out
- The folder itself. It is a normal directory; open it, sync it, back it up.
- The Files panel lists everything newest-first with a new/edited tag, previews it, and has a Save button that downloads the file.
GET /api/files/open?path=…serves one file for the in-app viewer.GET /api/files/raw/{path}serves it under a real path, so a page's relativestyles.cssandscript.jsresolve to sibling URLs. The first hit carries?token=, mints a short-livedkfcookie scoped to that prefix alone, and redirects to the same URL without the token — so the address bar and history never keep it.DELETE /api/files/{path}removes one.
Every file served this way leaves with X-Content-Type-Options: nosniff and, when it is a document, a
sandbox Content-Security-Policy that puts it on an opaque origin with connect-src 'none'. A file she
wrote is a file a model wrote; it does not get to run as your app.
The jail
Every path a tool hands the library goes through validate_within_dir, which resolves .. and
symlinks and then checks containment. The path need not exist yet, so write targets are fine.
The two halves behave differently, and both behaviours are deliberate:
Writing falls back to the basename rather than refusing, so a file is never lost and never written outside. Measured:
save_text("notes/a.txt", …) → notes/a.txt
save_text("../../escape.txt", …) → escape.txt (inside the library)
Reading and deleting refuse outright:
resolve("../../escape.txt") → None
resolve(".index.json") → None
delete("./.index.json") → False
The index sidecars — .index.json and its .index.json.lock — are never served, never deletable and
never pruned. That is not tidiness. A single leading ./ once walked past the older check, and losing
the index does not cost one file: the update path re-reads the index it has just unlinked, gets {} on
failure, and writes that back — so every file's new/edited state goes at once. The lock file is how two
processes coordinate.
Symlinks planted in a working directory are not followed into the library either: an import step resolves each candidate and skips anything that does not stay inside.
Caps, and the one that does not apply
| Cap | Value |
|---|---|
| Text written through the library | 200,000 characters, then truncated with … (truncated) |
| An image decoded into the library | 12,000,000 bytes, else refused |
| File count | 500 — but see below |
Nothing is deleted on a file count in the default setup. The 500-file cap only runs when the library
is a mirror of a separate working directory, where every file is a copy and the cap bounds a cache.
By default the library is the working directory, so its files are your only originals — and a
git clone in there would push the count over the cap and delete your work. The pruner checks and
returns.
Pointing her at a real project
Set KOTOBA_WORKSPACE_DIR to a folder and that folder becomes the jail root for every file, shell and
code operation. Measured:
| default | KOTOBA_WORKSPACE_DIR=/path/to/project | |
|---|---|---|
| working directory | ~/.kotoba/files | /path/to/project |
| files library | ~/.kotoba/files | ~/.kotoba/files |
| library is the workdir | yes | no |
In that mode the library becomes a mirror: files she makes are copied into it so the Files panel can
show them, the 500-file cap starts applying, and the two directories are no longer the same place. The
terminal's /attach knows this and says so — a text file copied into the library when she reads
somewhere else gets the row "copied to … — but she reads …, so put it there instead".
What the index tracks, and what it does not
.index.json records only the new/edited badge, the creation time and whether you have opened a file.
No file ever disappears with it, because the listing reads the directory rather than the index. That is
exactly why a silent bug there went unnoticed for a long time: two writers were losing 79% of each
other's updates, and the only symptom was a badge that did not appear.
Files created by raw shell — a git clone, an npm install — are already on disk and need no copying,
but they do get index entries so their badges work. That stamping is one lock and one write for the
whole batch; per-file it was quadratic, and measured at 3.4 s for 1000 files and 13.4 s for 2000, in a
thread that runs after the tool returned, where no spinner covers the freeze.
The listing skips a file whose own name starts with a dot — but not a file inside a dot-directory.
Measured: with .dotfile, plain.txt and .hidden/inside.txt in the library, the panel is shown
plain.txt and .hidden/inside.txt.
