Three things that turned out to be one thing: Nova can format a reply properly, move files in both
directions, and keep those files somewhere everyone can reach them.
Merged in #31.
Rich replies (Bot API 10.1/10.2)
sendRichMessage is a method, not a parse_mode — the reply goes out as raw GFM and Telegram
renders real tables, checkboxes, heading levels and nested lists.
Used selectively: a plain answer still goes out as a plain message, because mixing the two
surfaces makes font weight and spacing jump mid-conversation. needsRich() decides per message,
after stripping code fences — a fence is not a reason to escalate.
Three failure modes, three behaviours. The rule that matters is never re-send a transient
failure, which is how a user ends up with the same answer twice:
| Failure | Response |
|---|
| Method unknown (older Bot API) | latch off for the process, fall back to plain |
| Content rejected (bad markup) | fall back to plain, once |
| Transient (429/5xx) | honour retry_after, retry the same send |
Also fixes a pre-existing truncation: chunk() dropped everything past 4000 characters of a line
containing no newline, so a long paragraph lost most of the answer. It now hard-splits and re-opens
straddled code fences.
Files, both directions
In — photos, albums, video, audio, voice notes, documents. An album arrives as ONE turn, debounced
by media_group_id: answering the first item treated item #2 as a new question and interrupted the
run item #1 had started. Images and PDFs reach the brain as native file parts, text-ish files
inline. Anything over Telegram's 20 MB bot-download cap is reported to the model rather than silently
dropped. Every upload also lands in the drive's inbox/.
Out — attach_file sends a real file: a rich media block for images/video/audio, a native
document bubble otherwise, a file card in both web chats.
Chat action is held for the whole run (Telegram expires it after 5 s) and switches to
upload_photo / upload_voice / upload_video / upload_document for what is about to arrive.
Nova Drive
One folder — $DATA_DIR/files. The pre-drive workspace/ is renamed into it on boot, so Nova's tools
and the user's files are one tree: a chart bash renders is a file the user downloads a second later.
Three doors, one implementation:
| Door | Who | Proof | Reach |
|---|
/drive/api/* | the owner | hosted-chat cookie, or CONTROL_TOKEN when the dashboard proxies | everything |
/nova/drive/* | a mesh agent | its own mesh-signed credential | only what a grant covers |
/d/<token> | anyone | the link | one file or folder, within its limits |
A grant is authorization — the credential already proves identity. Scoping is prefix+separator, so a
grant on shared/ deliberately does not cover shared-notes/. Public links take any combination of
expiry, download cap (1 = one-time) and argon2 password; answering the password sets a cookie scoped
to that one link, so a folder share does not ask per file.
Every mutation and every download records its actor (user / nova / agent:<id> /
share:<token> / telegram) — the only way a shared drive answers "did that link get used?". Text
overwrites and deletes keep bounded snapshots, so an edit is restorable; a rename carries its history.
Path safety is one tested function: lexical .. rejection plus a realpath check on the deepest
existing ancestor, because string cleaning never catches link -> /etc. HTML and SVG always download
— serving attacker-authored markup inline from the agent's own origin is stored XSS. Range is
honoured everywhere, so video scrubs.
Nova's own tools are ungated, since NOVA_SYSTEM_TOOLS is about giving it a shell, not about reading
a CSV the user just uploaded: list_drive, read_drive_file, write_drive_file, move_drive_file,
delete_drive_file, share_file, grant_agent_access.
Backups are a hand-written streaming ZIP — no dependency, no zip in the image, one file in memory at
a time.
Attachments in chat
Both web chats show a file card: glyph and tint by type, name, size, image thumbnail, click to
download. The bytes still never ride the reply — a 20 MB attachment would be re-serialized on every
poll of the task — but the reference now carries the file's size and drive path, which is the
difference between prose and a file the user can take.
The hosted chat fetches from its transcript row rather than the drive path: the row is what the
reply was, so the download survives Nova moving or deleting the original.
Upgrade notes
$DATA_DIR/workspace is renamed to $DATA_DIR/files once, on boot. Anything referring to
workspace/ is stale.
TELEGRAM_RICH=0 disables rich replies without a rollback.
- No breaking API changes; file parts on the wire gain optional
size and path.