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

The agent7 of 15

Files

About 5 minutes to read

On this page

Four tools in the file toolset: read_file, write_file, patch, search_files.

None of them ever asks you for permission. They are jailed to one directory, and that jail is the whole of their protection. If you want to know what she can touch, look at the directory — not at the approval log.


Where she works

By default the working directory is the file library:

~/.kotoba/files/

That is the same folder the Files panel shows. She works directly in it, so creating folders, moving and deleting reflect there immediately — no per-session copy, no sync, no duplicates. "Move index.html and its assets into web/" just works and stays browsable.

Set KOTOBA_WORKSPACE_DIR to point her at a real project folder instead. Whatever that resolves to becomes the root every file, shell and code operation is jailed to.

There is also a scratch directory at ~/.kotoba/tmp (KOTOBA_TMP_DIR), wired into shell and execute_code as $TMPDIR. Throwaway work goes there and never clutters your Files. A sweeper removes entries older than 24 hours — but only if it laid its own marker file, which it does only on a directory it created or was handed empty. Point KOTOBA_TMP_DIR at your Downloads folder and the sweep declines rather than eating your work, and says so in the log.


The jail

Every path from the model goes through one function before anything is opened:

  • A relative path is taken relative to the root.
  • An absolute path is allowed only if it already lives inside the root.
  • .. and symlinks are resolved first, so a symlink inside the root pointing outside is rejected.
  • The path need not exist yet, so a write target is fine.

Anything that escapes raises, and the tool answers "That path is outside my workspace, so I won't write there" — recorded as a refusal, which means nothing ran and the row carries no tick.


read_file

Takes: path, start (0-based line, default 0), limit (default 400 lines).

Returns the window inside the same random-tagged fence web_extract uses: the file's contents are marked as content, not as instructions.

Two cuts can happen and both speak. The line window says how many lines follow. The character cut (8,000) says how many characters were dropped and how many lines remain, and tells the model not to treat it as the end of the file. The character cut used to be silent, so a file of few long lines came back mid-word reading as complete.

An empty read is stated as a fact, not returned as nothing:

  • Read notes.txt — the file exists and is empty (0 bytes).
  • Read notes.txt — it has only 12 lines, so start=400 is past the end of the file.
  • Read notes.txt — that range contains only whitespace (the file has 12 lines).

A bare empty string rides the loop's "returned nothing" path — graded as a failure — and the model then reports a file it read as one it could not open, asking you to check a path that exists.

Refuses: a path outside the jail, and a path with no file at it (There's no file at X yet.).


write_file

Takes: path, content. Parent folders are created as needed.

Cap: 5 MB per write. A model that needs more is doing something wrong.

Never prompts. It writes, inside the jail.

Two things happen after a successful write:

  • An artifact frame goes out, badged created or edited — decided by whether the target existed before the tool ran, because afterwards it exists either way and every overwrite was wearing the "created" badge.
  • If the path ends in .md, the URLs actually fetched during this turn are appended as sources. The turn's end revisits the file for any that arrived later.

patch

Takes: path, old_string, new_string.

Simpler than parsing a unified diff: find the snippet, replace it. If the exact text is not found, it retries on a whitespace-normalised basis so minor indentation drift still applies.

Refuses when the normalised snippet matches more than once:

"That snippet appears more than once in <file>, so I didn't want to change the wrong spot — give me a longer, unique piece of text (include a line or two around it) and I'll patch it."

Not found at all is a plain failure, and she says "I couldn't find that part to change — can you show me the exact text?"


search_files

Takes: query, optional path (a subfolder).

Two implementations, chosen at call time.

ripgrep, when rg is on PATH. Regex works. This is the primary path.

A Python walk, when ripgrep is absent — or on KOTOBA_SANDBOX=none, where ripgrep is skipped deliberately. none is the one backend that promises no child process on your machine, and spawning rg would break that promise. The Docker image ships no ripgrep either, so the fallback is the deployed path there.

The fallback is a literal substring search, not Python re — a catastrophic model-supplied pattern like (a+)+$ would pin a worker thread with no way to cancel it. The schema warns about this:

"Prefer a LITERAL substring: regex is honoured only on the ripgrep path, and where that is unavailable a regex simply finds nothing rather than erroring."

The fallback also re-checks the jail on every entry it walks, because os.walk yields symlinks and open() follows them. Without that check it once returned a private key that read_file correctly refuses. ripgrep skips symlinks by default, which is why the two disagreed.

Limits:

Value
Matches returned80
Time budget, work mode20 s
Time budget, a conversation5 s

A capped list says it was capped, and a timed-out scan says so too. "No matches found." after a partial scan would be a lie — and a truncated list that looks like a complete one is the same lie one step further along: the model then counts the results, or reports the last file it can see as the last one there is.


What the panel sees

After shell or execute_code succeeds, the workspace is re-scanned for changes and every new or modified file gets its own artifact frame, plus a files_changed nudge. That is how a file created by a command — rather than by write_file — still appears in your Files panel.