Merge remote-tracking branch 'origin/main' into screens

# Conflicts:
#	.claude/settings.local.json
#	CLAUDE.md
#	bolt_pet/controller.py
This commit is contained in:
2026-07-28 16:19:26 -06:00
12 changed files with 1029 additions and 37 deletions
+13 -1
View File
@@ -40,7 +40,19 @@
"Bash(git add *)",
"Bash(git merge *)",
"Bash(echo \"=== EXIT: $? ===\")",
"Bash(git commit *)"
"Bash(git commit *)",
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest tests/ -q)",
"Bash(.venv/bin/pytest tests/test_desk_api.py tests/test_desk_files.py tests/test_desk_voice.py tests/test_desk_status.py tests/test_desk_keys.py tests/test_desk_guild_action.py tests/test_desk_admin.py tests/test_desk_billing_auth.py -q)",
"Bash(docker inspect *)",
"Bash(python3 -m json.tool)",
"Bash(docker restart bolt *)",
"Bash(curl -s -m 5 \"http://localhost:5002/desk/health\")",
"Bash(python3 -c ' *)",
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/ -q)",
"Bash(docker exec bolt *)",
"Bash(QT_QPA_PLATFORM=offscreen /root/Documents/bolt-pet/.venv/bin/pytest /home/themajesticmagician/Documents/Bolt-Pet/tests/test_file_ops.py -q)",
"Bash(grep -rn *)",
"Bash(git ls-tree *)"
]
}
}
+6
View File
@@ -176,6 +176,12 @@ ELEVENLABS_VOICE_ID=
#SUDO_ASKPASS_HELPER= # blank = auto-detect
#SUDO_COMMAND_TIMEOUT_SECONDS=180 # long enough for a human to answer
# ── File delivery (optional) ────────────────────────────────────────────────
# The server's deliver_files tool ("send me that report") queues workspace
# files on this session; the pet fetches and saves them automatically.
#RECEIVE_FILES=true
#DELIVERED_FILES_DIR=~/Downloads/Bolt
# ── Misc (optional) ──────────────────────────────────────────────────────────
#COMMAND_TIMEOUT_SECONDS=30
#HEARTBEAT_INTERVAL_SECONDS=60
+58 -5
View File
@@ -68,10 +68,10 @@ logs a missing-config message and exits its thread instead of starting.
a `QWidget` directly. Also drives the periodic heartbeat
(`_maybe_heartbeat`, gated by `HEARTBEAT_INTERVAL_SECONDS`) which lets the
server push proactive spoken announcements between user turns, and on the
same tick re-evaluates nap state and drains queued desktop notifications.
It owns the live wake-word threshold (`wake_threshold()` is passed to
`listen_for_wake_word` as a *callable* so the tray slider takes effect
mid-listen) and the conversation `history`.
same tick re-evaluates nap state, checks for delivered files, and drains
queued desktop notifications. It owns the live wake-word threshold
(`wake_threshold()` is passed to `listen_for_wake_word` as a *callable* so
the tray slider takes effect mid-listen) and the conversation `history`.
- **`server_client.py`** — HTTP client for the desk API, dependency-free
beyond `requests` so it's easy to mock in tests. `converse()` loops relaying
server-issued shell commands (`run_local_command`, executed via
@@ -79,7 +79,24 @@ logs a missing-config message and exits its thread instead of starting.
`/desk/tool_result` until the server sends a final `reply` (capped at
`_MAX_RELAY_HOPS`). This is the same "full desktop control" trust model as
the server repo's other desk clients — commands only ever originate from
the user's own voice/click requests in their own session.
the user's own voice/click requests in their own session. `list_outbox_files`
/ `download_outbox_file` hit the same `/desk/files` and `/desk/files/<id>`
endpoints the server's `deliver_files` tool queues onto — see `file_delivery.py`.
- **`file_delivery.py`** — the filesystem half of receiving files the server
queues via its `deliver_files` tool (`ai/desk_api.py` in the main tmn-api
repo — "send me that report" during a conversation spools the matched
workspace files, zipping multiple into one, onto the session's outbox).
`controller._check_deliveries` lists `/desk/files` and downloads anything
queued — right after a conversation/notification turn (the common case)
and once per heartbeat tick for anything queued out-of-band — saving each
under `DELIVERED_FILES_DIR` (default `~/Downloads/Bolt`). Downloading a
file dequeues it server-side, so it's only ever handed out once; `save()`
never overwrites an existing download, suffixing `" (1)"`, `" (2)"`, ... on
a name collision. `sanitize_filename()` reduces a server-supplied name to
its bare filename (`Path(...).name`), which is defense-in-depth against a
delivered name that's secretly a path, since a per-user desk API key means
the name isn't always coming from someone as trusted as the owner. Toggle
off entirely with `RECEIVE_FILES=false`.
- **`audio/`** — `mic.py` (energy-based VAD utterance capture, ported from the
server repo's `bolt_desk.py`), `wake_word.py` (openWakeWord `thunderbolt.onnx`
detection + `NearMissLog` for threshold tuning — see below), `stt.py`
@@ -109,6 +126,35 @@ logs a missing-config message and exits its thread instead of starting.
Query verbs (`monitors`, `read`) are answered in `_handle_command` rather
than by `pet_actions.describe()`, because their output *is* the point: it
goes back up the tool-result relay for Bolt to use in his reply.
- **`file_ops.py`** — `filectl` pseudo-commands, checked in `_handle_command`
right after petctl and before falling through to a real shell command.
Executing arbitrary commands already worked via the shell relay
(`run_local_command` — see `server_client.py` below); what filectl adds is
a *reliable* way to do the read/write/edit/list slice of that, since
getting the model to hand-roll a shell heredoc for multi-line content full
of quotes/`$`/backticks is failure-prone — and `list` exists as its own op
(rather than relying on the model shelling out to `ls`/`dir`) because this
project is cross-platform and the model shouldn't have to guess which
listing command applies on Windows vs. Linux vs. macOS; one glob-based op
(`pattern`, default `*`; `recursive` for `rglob` instead of `glob`) covers
all three. Wire format is `filectl <json>` where `<json>` is a
**single-line** compact JSON object —
`{"op": "list"|"read"|"write"|"edit", "path": ..., ...}` — not a multi-line
marker block (an earlier design): the server relays this as the argument
to the ordinary `command` tool marker, and that marker's extractor
(`ai/agents/default.py` in the main repo) only captures up to the next
newline, so anything genuinely multi-line silently got truncated no matter
how the prompt worded it. JSON sidesteps that for free — `json.dumps`
already encodes embedded newlines as the two characters `\n`, not a real
line break, so multi-line file content still fits on the one physical line
the extractor sees. `edit` requires the old text to match exactly once —
same discipline as this project's own code-editing tool — and raises
rather than guessing if it's missing or ambiguous. This doesn't expand
what the server can do to this machine (a relayed shell command could
already overwrite anything the desktop user can write — see the security
notes below); it's a safer path to the same capability. Pure parsing
(`parse`) is separated from the filesystem I/O (`execute`), matching
pet_actions.py's parse/describe split.
- **`screen_context.py`** — active-window title (xprop/xdotool, Win32,
osascript) appended to each utterance via `context_for()`, plus
`is_fullscreen_active()` for do-not-disturb. Text only — the desk API takes
@@ -296,6 +342,13 @@ matches the trust model of the server repo's other desk clients. Keep
`DESK_API_KEY` private and don't expose the desk API port to the open
internet.
`file_ops.py`'s `filectl` read/write/edit pseudo-commands ride that same
relay and are bound by the same trust model — no path is off-limits beyond
normal filesystem permissions for the desktop user, exactly like a relayed
`cat`/`sed`/`rm` already isn't. They don't grant the server anything a shell
command couldn't already do; they just make the read/write/edit path
reliable instead of relying on the model getting shell quoting right.
`sudo_askpass.py` widens that further, by design: with `SUDO_ASKPASS_PROMPT`
on (the default), a relayed bare `sudo` is rewritten to `sudo -A` and the
password is collected in a desktop dialog, so commands can escalate to root
+11
View File
@@ -185,6 +185,17 @@ NOTIFICATION_BRIDGE = os.environ.get("NOTIFICATION_BRIDGE", "false").lower() in
NOTIFICATION_FILTER = os.environ.get("NOTIFICATION_FILTER", "")
NOTIFICATION_MIN_INTERVAL_SECONDS = float(os.environ.get("NOTIFICATION_MIN_INTERVAL_SECONDS", "60"))
# ── file delivery ────────────────────────────────────────────────────────
# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo)
# queues workspace files on this session — e.g. "send me that report" — for
# the client to fetch via GET /desk/files. Downloading a file dequeues it
# server-side, so each one lands here exactly once.
RECEIVE_FILES = os.environ.get("RECEIVE_FILES", "true").lower() in ("1", "true", "yes", "on")
DELIVERED_FILES_DIR = Path(
os.environ.get("DELIVERED_FILES_DIR") or str(Path.home() / "Downloads" / "Bolt")
).expanduser()
# ── conversation history ────────────────────────────────────────────────────
HISTORY_LIMIT = int(os.environ.get("HISTORY_LIMIT", "100"))
+77 -31
View File
@@ -20,9 +20,9 @@ from typing import Optional
from PySide6.QtCore import QObject, Signal
from . import (
config, history as history_mod, monitors as monitors_mod, notifications,
pet_actions, quiet, screen_context, screen_text, server_client,
speech_text, updater,
config, file_delivery, file_ops, history as history_mod,
monitors as monitors_mod, notifications, pet_actions, quiet,
screen_context, screen_text, server_client, speech_text, updater,
)
from .audio import barge_in, mic, stt, tts, wake_word
from .state import PetState, PetStateMachine
@@ -259,6 +259,7 @@ class PetController(QObject):
self._state.transition(PetState.IDLE)
return
self._check_deliveries()
self._speak(reply)
self._state.transition(PetState.IDLE)
@@ -289,42 +290,57 @@ class PetController(QObject):
def _handle_command(self, command: str) -> str:
"""Server-relayed command. `petctl ...` drives the pet's body and
never reaches a shell; everything else is a real command, exactly as
before (see the security notes in the README)."""
`filectl ...` does local file read/write/edit — neither ever reaches
a shell; everything else is a real command, exactly as before (see
the security notes in the README)."""
try:
action = pet_actions.parse(command)
except pet_actions.ActionError as exc:
self.log.emit(f"petctl: {exc}")
return f"[pet] {exc}"
if action is None:
return server_client.run_local_command(command)
self.log.emit(f"Pet action: {action}")
if action is not None:
self.log.emit(f"Pet action: {action}")
# Queries answer from here rather than from pet_actions.describe():
# their output *is* the useful part, and it's what the server reads
# back off the tool-result relay.
kind = action["action"]
if kind == "monitors":
return monitors_mod.describe(self._monitors, self._pet_monitor)
if kind == "read":
return self._read_screen(action["target"])
if kind == "jump":
# Queries answer from here rather than from pet_actions.describe():
# their output *is* the useful part, and it's what the server reads
# back off the tool-result relay.
kind = action["action"]
if kind == "monitors":
return monitors_mod.describe(self._monitors, self._pet_monitor)
if kind == "read":
return self._read_screen(action["target"])
if kind == "jump":
try:
target = monitors_mod.resolve(
self._monitors, action["target"], self._pet_monitor
)
except ValueError as exc:
self.log.emit(f"petctl jump: {exc}")
return f"[pet] {exc}"
# Hand the window a resolved index, so it can't re-resolve the
# spec against a different screen ordering.
self.action.emit({"action": "jump", "monitor": target.index})
return f"[pet] jumped to monitor {target.label}"
if kind == "nap":
self.set_napping(bool(action["enabled"]))
self.action.emit(action)
return pet_actions.describe(action)
try:
file_action = file_ops.parse(command)
except file_ops.FileOpError as exc:
self.log.emit(f"filectl: {exc}")
return f"[filectl] {exc}"
if file_action is not None:
self.log.emit(file_ops.describe(file_action))
try:
target = monitors_mod.resolve(
self._monitors, action["target"], self._pet_monitor
)
except ValueError as exc:
self.log.emit(f"petctl jump: {exc}")
return f"[pet] {exc}"
# Hand the window a resolved index, so it can't re-resolve the
# spec against a different screen ordering.
self.action.emit({"action": "jump", "monitor": target.index})
return f"[pet] jumped to monitor {target.label}"
return file_ops.execute(file_action)
except file_ops.FileOpError as exc:
self.log.emit(f"filectl: {exc}")
return f"[filectl] {exc}"
if kind == "nap":
self.set_napping(bool(action["enabled"]))
self.action.emit(action)
return pet_actions.describe(action)
return server_client.run_local_command(command)
def _read_screen(self, target: str) -> str:
"""`petctl read` — OCR a screen and hand the text back to the server."""
@@ -487,10 +503,39 @@ class PetController(QObject):
except server_client.ServerError as exc:
self.log.emit(f"Couldn't forward notification: {exc}")
return
self._check_deliveries()
if reply.strip():
self._speak(reply)
self._state.transition(PetState.IDLE)
# ── file delivery ────────────────────────────────────────────────────
def _check_deliveries(self) -> None:
"""Download anything the server has queued via deliver_files —
called right after a conversation/notification turn (the common
case: "send me that file") and once per heartbeat for anything
queued out-of-band. Best-effort: a failure here is logged, not
raised, so it can't sour a turn that already got its spoken reply."""
if not config.RECEIVE_FILES:
return
try:
queued = server_client.list_outbox_files()
except server_client.ServerError as exc:
self.log.emit(f"Couldn't check for delivered files: {exc}")
return
for entry in queued:
file_id = entry.get("id")
name = entry.get("name") or file_id
if not file_id:
continue
try:
data = server_client.download_outbox_file(file_id)
except server_client.ServerError as exc:
self.log.emit(f"Couldn't download {name}: {exc}")
continue
path = file_delivery.save(config.DELIVERED_FILES_DIR, name, data)
self.log.emit(f"Received file: {path}")
# ── auto-update ──────────────────────────────────────────────────────
def _maybe_update(self) -> None:
@@ -546,6 +591,7 @@ class PetController(QObject):
return
if self._napping:
return # quiet hours: still answers when spoken to, just doesn't start
self._check_deliveries()
self._drain_notifications()
if self._state.state != PetState.IDLE:
return
+54
View File
@@ -0,0 +1,54 @@
"""Saves files the server queues via its deliver_files tool (ai/desk_api.py
in the main tmn-api repo) to a local downloads folder.
The server side of this is already generic — any desk client can list
GET /desk/files and fetch GET /desk/files/<id> (see server_client.
list_outbox_files / download_outbox_file) — so this module is just the
filesystem half: turn a server-supplied display name into a safe path and
write the bytes.
Pure filename/path logic lives here so it's testable without touching a real
mic/network; the only I/O is the final write in save().
"""
from __future__ import annotations
from pathlib import Path
_FALLBACK_NAME = "delivered_file"
def sanitize_filename(name: str) -> str:
"""Reduce a server-supplied name to a bare filename. Defends against a
delivered name that's actually a path (../../etc, an absolute path, ...)
— Path(...).name strips every directory component, and anything that
collapses to nothing (or "." / "..") falls back to a generic name."""
candidate = Path(str(name or "").strip()).name
if candidate in ("", ".", ".."):
return _FALLBACK_NAME
return candidate
def unique_path(directory: Path, name: str) -> Path:
"""*name* under *directory*, suffixed " (1)", " (2)", ... if that name is
already taken — a delivered file never overwrites an earlier download."""
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
candidate = directory / name
if not candidate.exists():
return candidate
stem, suffix = candidate.stem, candidate.suffix
n = 1
while True:
candidate = directory / f"{stem} ({n}){suffix}"
if not candidate.exists():
return candidate
n += 1
def save(directory: Path, name: str, data: bytes) -> Path:
"""Write *data* under *directory* as *name* (sanitized + uniquified),
returning the path written."""
path = unique_path(directory, sanitize_filename(name))
path.write_bytes(data)
return path
+280
View File
@@ -0,0 +1,280 @@
"""Local file read/edit/write, intercepted from the server-relayed command
channel the same way pet_actions.py intercepts petctl (see server_client.
run_local_command / controller._handle_command). Whatever text the server's
"command" tool sends is just a string this repo is free to interpret before
it ever reaches subprocess — a `filectl` pseudo-command is one such
interpretation, giving the model a way to read/write/edit files on this
machine without constructing a raw shell heredoc, where quoting, `$`,
backticks, and embedded quotes make anything beyond a one-liner failure-prone.
Executing arbitrary commands already works today (that's exactly what
run_local_command/subprocess.run does) — filectl doesn't add that ability,
it only makes the read/write/edit slice of it reliable. It also doesn't
expand what the server can already do to this machine: a relayed shell
command could already overwrite any file the desktop user can write (see the
security notes in CLAUDE.md) — filectl is a safer *path* to the same
capability, not a new capability.
Wire format: `filectl <json>`, where <json> is a single-line, compact JSON
object — critically, ONE LINE. The server's "command" tool marker only
captures the argument up to the next newline (see TOOL_SPECS/
_extract_all_tool_calls in the main repo's ai/agents/default.py — "command"
is not declared multiline), so a marker-delimited multi-line payload (this
module's first design) silently got truncated at the first line no matter
how the prompt worded it. JSON sidesteps that for free: json.dumps() already
encodes embedded newlines as the two characters "\n", not an actual line
break, so arbitrarily multi-line file content still fits on the one physical
line the extractor captures.
filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}
filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}
filectl {"op": "write", "path": "<path>", "content": "<text>"}
filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}
"pattern"/"recursive" (list) and "start"/"end" (read) are optional. `list`
exists even though a real `ls`/`dir` shell command already works, because
this repo is cross-platform (Windows/macOS/Linux) and the model shouldn't
have to guess which listing command applies on this machine — one glob-based
op covers all three. Must be invoked as the argument to the
ordinary `command` tool marker (e.g. `command: filectl {"op": "write", ...}`)
— see the pet-only paragraph in the main repo's ai/desk_api.py
_system_context for the exact instruction the model is given, including the
"keep it one line" requirement.
Pure parsing (parse) is separated from the filesystem I/O (execute) so the
syntax is unit-testable without touching disk, matching pet_actions.py's
parse/describe split.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
_PREFIXES = ("filectl", "file")
# Keeps a runaway read/write/list from blowing up the tool_result relay (and,
# for read/list, from flooding the model's context with a giant response).
_MAX_READ_BYTES = 200_000
_MAX_WRITE_BYTES = 2_000_000
_MAX_LIST_ENTRIES = 500
HELP = (
'filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}\n'
'filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}\n'
'filectl {"op": "write", "path": "<path>", "content": "<text>"}\n'
'filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}\n'
"Must be all on one line — this rides the single-line \"command\" marker."
)
class FileOpError(Exception):
"""Bad filectl syntax or a filesystem error — reported back to the
server as command output, exactly like ActionError in pet_actions.py."""
def is_file_command(command: str) -> bool:
stripped = (command or "").strip()
if not stripped:
return False
first_word = stripped.split(None, 1)[0]
return first_word.lower() in _PREFIXES
def parse(command: str) -> Optional[dict]:
"""Parse a `filectl <json>` string into an action dict, or None if this
isn't a filectl command at all (caller should try the next handler, or
fall back to a real shell command). Raises FileOpError on a filectl
command that doesn't make sense."""
if not is_file_command(command):
return None
stripped = command.strip()
_, _, rest = stripped.partition(" ")
rest = rest.strip()
if not rest or rest.lower() in ("help", "-h", "--help"):
return {"action": "help"}
try:
payload = json.loads(rest)
except json.JSONDecodeError as exc:
raise FileOpError(f"couldn't parse filectl JSON ({exc}); usage:\n{HELP}") from exc
if not isinstance(payload, dict):
raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}")
op = str(payload.get("op") or "help").lower()
if op == "help":
return {"action": "help"}
if op == "list":
path = _required_str(payload, "path")
pattern = payload.get("pattern", "*")
if not isinstance(pattern, str) or not pattern:
raise FileOpError('"pattern" must be a non-empty string')
return {
"action": "list", "path": path,
"pattern": pattern, "recursive": bool(payload.get("recursive")),
}
if op == "read":
path = _required_str(payload, "path")
return {
"action": "read", "path": path,
"start": _line_number(payload.get("start"), "start"),
"end": _line_number(payload.get("end"), "end"),
}
if op == "write":
path = _required_str(payload, "path")
if payload.get("content") is None:
raise FileOpError('write needs "content"')
return {"action": "write", "path": path, "content": str(payload["content"])}
if op == "edit":
path = _required_str(payload, "path")
if payload.get("old") is None or payload.get("new") is None:
raise FileOpError('edit needs "old" and "new"')
old, new = str(payload["old"]), str(payload["new"])
if old == new:
raise FileOpError("old and new text are identical — nothing to edit")
return {"action": "edit", "path": path, "old": old, "new": new}
raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")
def _required_str(payload: dict, key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value.strip():
raise FileOpError(f'filectl needs a non-empty "{key}"')
return value
def _line_number(value, label: str) -> Optional[int]:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise FileOpError(f"{label} must be an integer line number, got {value!r}")
return value
def describe(action: dict) -> str:
"""Text handed back to the server before execute() runs — mirrors
pet_actions.describe, used only for the log line."""
kind = action.get("action")
if kind == "help":
return "[filectl] help"
return f"[filectl] {kind} {action.get('path', '')}"
def execute(action: dict) -> str:
"""Actually perform a parsed filectl action, returning the text to send
back to the server as the command's output. Raises FileOpError on any
filesystem problem, same as a bad-syntax parse error."""
kind = action.get("action")
if kind == "help":
return HELP
if kind == "list":
return _do_list(action)
if kind == "read":
return _do_read(action)
if kind == "write":
return _do_write(action)
if kind == "edit":
return _do_edit(action)
return "[filectl] ok"
def _resolve(path_str: str) -> Path:
return Path(path_str).expanduser()
def _do_list(action: dict) -> str:
path = _resolve(action["path"])
if not path.is_dir():
raise FileOpError(f"no such directory: {path}")
pattern = action["pattern"]
glob = path.rglob if action["recursive"] else path.glob
try:
entries = sorted(glob(pattern), key=lambda p: str(p).lower())
except OSError as exc:
raise FileOpError(f"couldn't list {path}: {exc}") from exc
if not entries:
return f"[no entries matching {pattern!r} in {path}]"
truncated = len(entries) > _MAX_LIST_ENTRIES
lines = []
for entry in entries[:_MAX_LIST_ENTRIES]:
rel = entry.relative_to(path)
if entry.is_dir():
lines.append(f"{rel}/")
continue
try:
size = entry.stat().st_size
except OSError:
size = -1
lines.append(f"{rel}\t{size}B")
if truncated:
lines.append(f"... truncated at {_MAX_LIST_ENTRIES} entries (of {len(entries)}) — narrow \"pattern\"")
return "\n".join(lines)
def _do_read(action: dict) -> str:
path = _resolve(action["path"])
if not path.is_file():
raise FileOpError(f"no such file: {path}")
try:
data = path.read_bytes()
except OSError as exc:
raise FileOpError(f"couldn't read {path}: {exc}") from exc
if len(data) > _MAX_READ_BYTES:
raise FileOpError(
f"{path} is {len(data)} bytes, over the {_MAX_READ_BYTES}-byte filectl read limit — "
'pass "start"/"end" to read a slice instead'
)
try:
text = data.decode("utf-8")
except UnicodeDecodeError as exc:
raise FileOpError(f"{path} isn't valid UTF-8 text: {exc}") from exc
lines = text.splitlines()
start = max(1, action.get("start") or 1)
end = min(len(lines), action.get("end") or len(lines))
if not lines:
return "[empty file]"
return "\n".join(f"{i:>6}\t{lines[i - 1]}" for i in range(start, end + 1))
def _do_write(action: dict) -> str:
path = _resolve(action["path"])
content = action["content"]
if len(content.encode("utf-8")) > _MAX_WRITE_BYTES:
raise FileOpError(f"content is over the {_MAX_WRITE_BYTES}-byte filectl write limit")
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
except OSError as exc:
raise FileOpError(f"couldn't write {path}: {exc}") from exc
return f"[filectl] wrote {len(content)} chars to {path}"
def _do_edit(action: dict) -> str:
path = _resolve(action["path"])
old, new = action["old"], action["new"]
if not path.is_file():
raise FileOpError(f"no such file: {path}")
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise FileOpError(f"couldn't read {path}: {exc}") from exc
count = text.count(old)
if count == 0:
raise FileOpError(f"didn't find that text in {path} — nothing changed")
if count > 1:
raise FileOpError(
f"that text appears {count} times in {path} — filectl edit needs a unique match; "
"include more surrounding context"
)
try:
path.write_text(text.replace(old, new, 1), encoding="utf-8")
except OSError as exc:
raise FileOpError(f"couldn't write {path}: {exc}") from exc
return f"[filectl] edited {path}"
+31
View File
@@ -115,6 +115,37 @@ def converse(
raise ServerError(str(payload.get("error") or "unknown server response"))
def list_outbox_files(timeout: float = 15.0) -> list:
"""Files the server has queued for this session via its deliver_files
tool (e.g. "send me that report" during a conversation) — each entry has
id/name/size. Downloading one (download_outbox_file) dequeues it
server-side, so a file is only ever handed out once."""
try:
response = requests.get(
f"{config.SERVER_URL}/desk/files",
params={"session_id": config.SESSION_ID},
headers=_headers(), timeout=timeout,
)
response.raise_for_status()
return list(response.json().get("files") or [])
except Exception as exc:
raise ServerError(f"couldn't list delivered files: {exc}") from exc
def download_outbox_file(file_id: str, timeout: float = 60.0) -> bytes:
"""Fetches and dequeues one file listed by list_outbox_files()."""
try:
response = requests.get(
f"{config.SERVER_URL}/desk/files/{file_id}",
params={"session_id": config.SESSION_ID},
headers=_headers(), timeout=timeout,
)
response.raise_for_status()
return response.content
except Exception as exc:
raise ServerError(f"couldn't download delivered file {file_id!r}: {exc}") from exc
def report_status(timeout: float = 15.0) -> Optional[str]:
"""Heartbeat — lets the desk API attach a pending spoken announcement
(proactive nudges, reminders fired since the last heartbeat) that the pet
+135
View File
@@ -5,6 +5,7 @@ the live wake threshold.
Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen.
"""
import json
import sys
from pathlib import Path
@@ -79,6 +80,62 @@ def test_petctl_nap_also_flips_the_controller_state(ctrl):
assert ctrl._napping is True
# ── filectl routing ──────────────────────────────────────────────────────────
# filectl's wire format is a single-line JSON envelope (see file_ops.py's
# module docstring for why) — build commands with json.dumps so the tests
# don't hardcode escaping by hand.
def _filectl(payload: dict) -> str:
return "filectl " + json.dumps(payload)
def test_filectl_commands_never_reach_the_shell(monkeypatch, ctrl, tmp_path):
ran = []
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
target = tmp_path / "a.txt"
output = ctrl._handle_command(_filectl({"op": "write", "path": str(target), "content": "hello"}))
assert ran == []
assert target.read_text() == "hello"
assert str(target) in output
def test_filectl_edit_round_trips_through_the_relay(monkeypatch, ctrl, tmp_path):
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
lambda cmd: (_ for _ in ()).throw(AssertionError("should not shell out")))
target = tmp_path / "a.py"
target.write_text("x = 1\n")
output = ctrl._handle_command(
_filectl({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"})
)
assert target.read_text() == "x = 2\n"
assert str(target) in output
def test_bad_filectl_syntax_is_reported_back_not_executed(monkeypatch, ctrl):
ran = []
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
output = ctrl._handle_command("filectl {not valid json")
assert ran == []
assert "[filectl]" in output
def test_filectl_execution_failure_is_reported_back_not_raised(monkeypatch, ctrl):
output = ctrl._handle_command(_filectl({"op": "read", "path": "/no/such/file.txt"}))
assert "[filectl]" in output
def test_ordinary_commands_still_run_locally_alongside_filectl(monkeypatch, ctrl):
ran = []
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
lambda cmd: ran.append(cmd) or "[exit 0]\n")
ctrl._handle_command("df -h /")
assert ran == ["df -h /"]
# ── barge-in ────────────────────────────────────────────────────────────────
def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch, ctrl):
@@ -383,3 +440,81 @@ def test_near_misses_are_recorded_for_the_tuner(ctrl):
ctrl.reset_wake_stats()
assert ctrl.wake_stats()["near_misses"] == []
# ── file delivery ────────────────────────────────────────────────────────────
def test_check_deliveries_downloads_and_saves_queued_files(monkeypatch, ctrl, tmp_path):
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
lambda: [{"id": "abc", "name": "report.pdf", "size": 5}])
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file",
lambda file_id: b"hello" if file_id == "abc" else b"")
ctrl._check_deliveries()
assert (tmp_path / "report.pdf").read_bytes() == b"hello"
def test_check_deliveries_is_a_noop_when_nothing_queued(monkeypatch, ctrl, tmp_path):
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: [])
downloaded = []
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file",
lambda file_id: downloaded.append(file_id))
ctrl._check_deliveries()
assert downloaded == []
def test_check_deliveries_can_be_disabled(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.config, "RECEIVE_FILES", False)
called = {"n": 0}
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
lambda: called.__setitem__("n", called["n"] + 1))
ctrl._check_deliveries()
assert called["n"] == 0
def test_check_deliveries_logs_and_continues_on_download_failure(monkeypatch, ctrl, tmp_path):
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files", lambda: [
{"id": "bad", "name": "a.txt", "size": 1},
{"id": "good", "name": "b.txt", "size": 1},
])
def fake_download(file_id):
if file_id == "bad":
raise controller_mod.server_client.ServerError("gone")
return b"ok"
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file", fake_download)
logs = _capture(ctrl.log)
ctrl._check_deliveries()
assert (tmp_path / "b.txt").read_bytes() == b"ok"
assert not (tmp_path / "a.txt").exists()
assert any("bad" in msg or "a.txt" in msg for msg in logs)
def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_path):
monkeypatch.setattr(controller_mod.mic, "record_utterance",
lambda *a, **k: np.zeros(10, dtype=np.int16))
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "send me that file")
monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: "it's on the way")
monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: True)
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
lambda: [{"id": "abc", "name": "notes.txt", "size": 2}])
monkeypatch.setattr(controller_mod.server_client, "download_outbox_file",
lambda file_id: b"hi")
ctrl._handle_conversation_turn()
assert (tmp_path / "notes.txt").read_bytes() == b"hi"
+54
View File
@@ -0,0 +1,54 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_pet import file_delivery
def test_sanitize_filename_strips_directory_components():
assert file_delivery.sanitize_filename("../../etc/passwd") == "passwd"
assert file_delivery.sanitize_filename("/absolute/path/report.pdf") == "report.pdf"
assert file_delivery.sanitize_filename("plain.txt") == "plain.txt"
def test_sanitize_filename_falls_back_on_empty_or_dots():
assert file_delivery.sanitize_filename("") == "delivered_file"
assert file_delivery.sanitize_filename("..") == "delivered_file"
assert file_delivery.sanitize_filename(".") == "delivered_file"
assert file_delivery.sanitize_filename(None) == "delivered_file"
def test_unique_path_returns_the_plain_name_when_free(tmp_path):
path = file_delivery.unique_path(tmp_path, "report.pdf")
assert path == tmp_path / "report.pdf"
def test_unique_path_suffixes_on_collision(tmp_path):
(tmp_path / "report.pdf").write_bytes(b"existing")
path = file_delivery.unique_path(tmp_path, "report.pdf")
assert path == tmp_path / "report (1).pdf"
(tmp_path / "report (1).pdf").write_bytes(b"also existing")
path = file_delivery.unique_path(tmp_path, "report.pdf")
assert path == tmp_path / "report (2).pdf"
def test_unique_path_creates_the_directory(tmp_path):
target = tmp_path / "nested" / "dir"
file_delivery.unique_path(target, "a.txt")
assert target.is_dir()
def test_save_writes_bytes_and_sanitizes_the_name(tmp_path):
path = file_delivery.save(tmp_path, "../sneaky/report.pdf", b"hello")
assert path == tmp_path / "report.pdf"
assert path.read_bytes() == b"hello"
def test_save_never_overwrites_an_existing_download(tmp_path):
first = file_delivery.save(tmp_path, "notes.txt", b"first")
second = file_delivery.save(tmp_path, "notes.txt", b"second")
assert first != second
assert first.read_bytes() == b"first"
assert second.read_bytes() == b"second"
+266
View File
@@ -0,0 +1,266 @@
"""filectl parsing + execution — the pseudo-commands the server can relay to
read/write/edit local files instead of a raw shell heredoc.
filectl {"op": ...} is a single-line JSON envelope (not a multi-line
marker block) because it rides the "command" tool marker, which the main
repo's tool-call extractor only captures up to the next newline — see the
module docstring in bolt_pet/file_ops.py."""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_pet import file_ops
def _cmd(payload: dict) -> str:
return "filectl " + json.dumps(payload)
# ── parsing ──────────────────────────────────────────────────────────────────
def test_non_file_commands_are_left_alone():
assert file_ops.parse("ls -la") is None
assert file_ops.parse("systemctl restart nginx") is None
assert file_ops.parse("") is None
# "filed" must not be mistaken for the "file" prefix
assert file_ops.parse("filed --list") is None
def test_list_parses_defaults():
assert file_ops.parse(_cmd({"op": "list", "path": "/tmp"})) == {
"action": "list", "path": "/tmp", "pattern": "*", "recursive": False,
}
def test_list_parses_pattern_and_recursive():
assert file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": "*.py", "recursive": True})) == {
"action": "list", "path": "/tmp", "pattern": "*.py", "recursive": True,
}
def test_list_requires_a_path():
with pytest.raises(file_ops.FileOpError):
file_ops.parse(_cmd({"op": "list"}))
def test_list_rejects_empty_pattern():
with pytest.raises(file_ops.FileOpError):
file_ops.parse(_cmd({"op": "list", "path": "/tmp", "pattern": ""}))
def test_read_parses_path_and_optional_line_range():
assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt"})) == {
"action": "read", "path": "/tmp/a.txt", "start": None, "end": None,
}
assert file_ops.parse(_cmd({"op": "read", "path": "/tmp/a.txt", "start": 10, "end": 40})) == {
"action": "read", "path": "/tmp/a.txt", "start": 10, "end": 40,
}
def test_read_requires_a_path():
with pytest.raises(file_ops.FileOpError):
file_ops.parse(_cmd({"op": "read"}))
def test_read_rejects_non_numeric_line_args():
with pytest.raises(file_ops.FileOpError):
file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt", "start": "start"}')
def test_write_parses_path_and_content():
assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": "hello\nworld"})) == {
"action": "write", "path": "/tmp/a.txt", "content": "hello\nworld",
}
def test_write_allows_empty_content():
assert file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt", "content": ""})) == {
"action": "write", "path": "/tmp/a.txt", "content": "",
}
def test_write_without_content_raises():
with pytest.raises(file_ops.FileOpError):
file_ops.parse(_cmd({"op": "write", "path": "/tmp/a.txt"}))
def test_edit_parses_old_and_new():
assert file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz"})) == {
"action": "edit", "path": "/tmp/a.txt", "old": "foo\nbar", "new": "baz",
}
def test_edit_rejects_identical_old_and_new():
with pytest.raises(file_ops.FileOpError):
file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt", "old": "same", "new": "same"}))
def test_edit_missing_fields_raises():
with pytest.raises(file_ops.FileOpError):
file_ops.parse(_cmd({"op": "edit", "path": "/tmp/a.txt"}))
def test_content_with_embedded_quotes_and_shell_metacharacters_survives():
payload = 'echo "hi $USER" `whoami` && rm -rf /'
command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": payload})
assert "\n" not in command # stays on one line, as the command marker requires
assert file_ops.parse(command) == {"action": "write", "path": "/tmp/a.txt", "content": payload}
def test_multiline_content_stays_on_one_physical_line():
content = "line one\nline two\nline three with \"quotes\" and \\backslashes\\"
command = _cmd({"op": "write", "path": "/tmp/a.txt", "content": content})
assert "\n" not in command
assert file_ops.parse(command)["content"] == content
def test_help():
assert file_ops.parse("filectl help") == {"action": "help"}
assert file_ops.parse("filectl") == {"action": "help"}
assert file_ops.parse(_cmd({"op": "help"})) == {"action": "help"}
def test_invalid_json_raises():
with pytest.raises(file_ops.FileOpError):
file_ops.parse("filectl {not valid json")
def test_non_object_json_raises():
with pytest.raises(file_ops.FileOpError):
file_ops.parse("filectl [1, 2, 3]")
def test_unknown_op_raises():
with pytest.raises(file_ops.FileOpError):
file_ops.parse(_cmd({"op": "frobnicate", "path": "/tmp/a.txt"}))
# ── execution ────────────────────────────────────────────────────────────────
def test_list_shows_files_and_subdirectories(tmp_path):
(tmp_path / "a.txt").write_text("hi")
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "b.txt").write_text("nested")
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
output = file_ops.execute(action)
assert "a.txt\t2B" in output
assert "sub/" in output
assert "b.txt" not in output # non-recursive: nested file not shown
def test_list_recursive_finds_nested_files(tmp_path):
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "b.txt").write_text("nested")
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "recursive": True}))
output = file_ops.execute(action)
assert "sub/b.txt" in output or "sub\\b.txt" in output # os-dependent separator
def test_list_pattern_filters_entries(tmp_path):
(tmp_path / "a.py").write_text("x")
(tmp_path / "b.txt").write_text("y")
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path), "pattern": "*.py"}))
output = file_ops.execute(action)
assert "a.py" in output
assert "b.txt" not in output
def test_list_empty_directory_says_so(tmp_path):
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
output = file_ops.execute(action)
assert "no entries" in output
def test_list_missing_directory_raises():
with pytest.raises(file_ops.FileOpError):
file_ops.execute({"action": "list", "path": "/no/such/dir", "pattern": "*", "recursive": False})
def test_list_rejects_a_file_path(tmp_path):
target = tmp_path / "a.txt"
target.write_text("hi")
with pytest.raises(file_ops.FileOpError):
file_ops.execute({"action": "list", "path": str(target), "pattern": "*", "recursive": False})
def test_list_truncates_past_the_entry_cap(tmp_path, monkeypatch):
monkeypatch.setattr(file_ops, "_MAX_LIST_ENTRIES", 3)
for i in range(5):
(tmp_path / f"f{i}.txt").write_text("x")
action = file_ops.parse(_cmd({"op": "list", "path": str(tmp_path)}))
output = file_ops.execute(action)
assert "truncated" in output
assert output.count(".txt") == 3
def test_write_then_read_round_trips(tmp_path):
target = tmp_path / "notes.txt"
write_action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "line one\nline two"}))
result = file_ops.execute(write_action)
assert target.read_text() == "line one\nline two"
assert str(target) in result
read_action = file_ops.parse(_cmd({"op": "read", "path": str(target)}))
output = file_ops.execute(read_action)
assert "line one" in output
assert "line two" in output
def test_read_missing_file_raises():
with pytest.raises(file_ops.FileOpError):
file_ops.execute({"action": "read", "path": "/no/such/file.txt", "start": None, "end": None})
def test_read_respects_line_range(tmp_path):
target = tmp_path / "a.txt"
target.write_text("\n".join(f"line{i}" for i in range(1, 11)))
action = file_ops.parse(_cmd({"op": "read", "path": str(target), "start": 3, "end": 5}))
output = file_ops.execute(action)
assert "line3" in output and "line5" in output
assert "line1" not in output and "line6" not in output
def test_edit_replaces_a_unique_match(tmp_path):
target = tmp_path / "a.py"
target.write_text("def foo():\n return 1\n")
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "return 1", "new": "return 2"}))
file_ops.execute(action)
assert target.read_text() == "def foo():\n return 2\n"
def test_edit_fails_when_text_not_found(tmp_path):
target = tmp_path / "a.py"
target.write_text("def foo():\n return 1\n")
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "nope", "new": "x"}))
with pytest.raises(file_ops.FileOpError):
file_ops.execute(action)
assert target.read_text() == "def foo():\n return 1\n" # untouched
def test_edit_fails_when_text_is_ambiguous(tmp_path):
target = tmp_path / "a.py"
target.write_text("x = 1\nx = 1\n")
action = file_ops.parse(_cmd({"op": "edit", "path": str(target), "old": "x = 1", "new": "x = 2"}))
with pytest.raises(file_ops.FileOpError):
file_ops.execute(action)
assert target.read_text() == "x = 1\nx = 1\n" # untouched
def test_write_creates_parent_directories(tmp_path):
target = tmp_path / "nested" / "dir" / "a.txt"
action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "hi"}))
file_ops.execute(action)
assert target.read_text() == "hi"
+44
View File
@@ -84,3 +84,47 @@ def test_check_health_returns_parsed_json():
get.return_value = _mock_response({"ok": True, "service": "bolt-desk-api"})
result = server_client.check_health()
assert result == {"ok": True, "service": "bolt-desk-api"}
def test_list_outbox_files_returns_the_queue():
with patch.object(server_client.requests, "get") as get:
get.return_value = _mock_response(
{"files": [{"id": "abc", "name": "report.pdf", "size": 9}]}
)
result = server_client.list_outbox_files()
assert result == [{"id": "abc", "name": "report.pdf", "size": 9}]
args, kwargs = get.call_args
assert args[0] == "http://test-server:5002/desk/files"
assert kwargs["params"] == {"session_id": "pet-test"}
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
def test_list_outbox_files_defaults_to_empty_list():
with patch.object(server_client.requests, "get") as get:
get.return_value = _mock_response({})
assert server_client.list_outbox_files() == []
def test_list_outbox_files_raises_server_error_when_unreachable():
with patch.object(server_client.requests, "get", side_effect=ConnectionError("no route")):
with pytest.raises(server_client.ServerError):
server_client.list_outbox_files()
def test_download_outbox_file_returns_raw_bytes():
with patch.object(server_client.requests, "get") as get:
resp = _mock_response({})
resp.content = b"%PDF fake bytes"
get.return_value = resp
result = server_client.download_outbox_file("abc")
assert result == b"%PDF fake bytes"
args, kwargs = get.call_args
assert args[0] == "http://test-server:5002/desk/files/abc"
assert kwargs["params"] == {"session_id": "pet-test"}
def test_download_outbox_file_raises_server_error_on_http_failure():
with patch.object(server_client.requests, "get") as get:
get.return_value = _mock_response({}, ok=False)
with pytest.raises(server_client.ServerError, match="abc"):
server_client.download_outbox_file("abc")