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

Configuration and operations5 of 9

The files library

About 6 minutes to read

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:

KindWhat happens
.png .jpg .jpeg .webp .gif .pdfRides the next turn's context as an image or file part. Nothing is written to disk
Anything that decodes as UTF-8 textCopied into the library, and she reads it there
Anything elseRefused: "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 relative styles.css and script.js resolve to sibling URLs. The first hit carries ?token=, mints a short-lived kf cookie 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

CapValue
Text written through the library200,000 characters, then truncated with … (truncated)
An image decoded into the library12,000,000 bytes, else refused
File count500 — 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:

defaultKOTOBA_WORKSPACE_DIR=/path/to/project
working directory~/.kotoba/files/path/to/project
files library~/.kotoba/files~/.kotoba/files
library is the workdiryesno

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.