The agent7 of 15
Files
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
artifactframe goes out, badgedcreatedoredited— 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 returned | 80 |
| Time budget, work mode | 20 s |
| Time budget, a conversation | 5 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.
