diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1bf98b5..ed0f8c1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -83,7 +83,13 @@ "Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_emotional_memory.py tests/test_inner_monologue.py tests/test_temporal_core.py tests/test_temporal_episodes.py tests/test_temporal_stream.py tests/test_temporal_facade.py tests/test_proactive.py tests/test_desk_api.py tests/test_main_helpers.py -q -p no:cacheprovider)", "Bash(timeout 1800 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests -q -p no:cacheprovider --ignore=tests/test_tool_markers.py --ignore=tests/test_tts_sanitization.py --ignore=tests/test_speaker_matching.py --ignore=tests/test_recent_speaker_fallback.py --ignore=tests/test_assistant_cli_call_proxy.py --ignore=tests/test_assistant_cli_permissions.py --ignore=tests/test_memory_store.py --ignore=tests/test_default_agent.py --ignore=tests/test_billing_web.py)", "WebFetch(domain:elevenlabs.io)", - "Bash(QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/test_dialogue.py -q)" + "Bash(QT_QPA_PLATFORM=offscreen .venv/bin/pytest tests/test_dialogue.py -q)", + "Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_system_index.py tests/test_initiative.py tests/test_self_experiments.py -q -p no:cacheprovider)", + "Bash($V *)", + "Bash(dig +short themajesticnetwork.com)", + "Bash(dig +short api.themajesticnetwork.com)", + "Bash(timeout 900 /tmp/claude-1000/-home-maji-Documents-Bolt-Pet/58f4a7c3-6a92-47ed-9e43-bb2216d8b135/scratchpad/tmnvenv/bin/python -m pytest tests/test_site.py -q -p no:cacheprovider)", + "Bash(curl -s -o /dev/null -w 'HTTP %{http_code} bytes=%{size_download}\\\\n' -m 15 -H 'X-Forwarded-For: 1.2.3.4' -H 'X-Real-IP: 1.2.3.4' -A 'Mozilla/5.0 \\(X11; Linux x86_64\\) Firefox/152.0' https://themajesticnetwork.com/?claude-probe-__TRACKED_VAR__)" ] } } diff --git a/bolt_pet/dialogue.py b/bolt_pet/dialogue.py index a14d135..7665031 100644 --- a/bolt_pet/dialogue.py +++ b/bolt_pet/dialogue.py @@ -42,6 +42,8 @@ import json import re from typing import Iterable, Optional +from . import relay_json + _PREFIXES = ("dialoguectl", "dialogue", "scene") # ElevenLabs Text to Dialogue limits (docs, 2026-07): at most 10 distinct @@ -82,11 +84,14 @@ def parse(command: str) -> Optional[dict]: 'dialoguectl needs a JSON argument, e.g. dialoguectl {"lines": ' '[{"voice": "self", "text": "[cheerfully] hello"}]}' ) + # Same lenient parse as filectl (see relay_json): machine-written JSON + # fails in a handful of repeatable ways, and a stray quote shouldn't cost + # a turn — but the repair is reported back rather than hidden. try: - data = json.loads(payload) - except json.JSONDecodeError as exc: + data, repairs = relay_json.loads(payload) + except relay_json.RelayJsonError as exc: raise DialogueError( - f"couldn't parse the JSON ({exc}). It must be one line of compact " + f"couldn't parse the JSON — {exc}\nIt must be one line of compact " "JSON — put line breaks inside text as \\n, never as real newlines." ) from exc if not isinstance(data, dict): @@ -109,6 +114,8 @@ def parse(command: str) -> Optional[dict]: lines.append({"voice": voice, "text": text}) action = {"action": "dialogue", "lines": lines} + if repairs: + action["_repairs"] = repairs model = str(data.get("model") or data.get("model_id") or "").strip() if model: action["model"] = model @@ -211,9 +218,10 @@ def describe(action: dict, *, played: bool = True) -> str: """The tool-result string handed back to the server.""" lines = action.get("lines") or [] voices = sorted({str(line.get("voice") or "self") for line in lines}) + note = relay_json.repair_note(action.get("_repairs") or []) if not played: - return f"[dialogue] not played ({len(lines)} lines)" + return f"[dialogue] not played ({len(lines)} lines){note}" return ( f"[dialogue] played {len(lines)} line{'s' if len(lines) != 1 else ''} " - f"in {len(voices)} voice{'s' if len(voices) != 1 else ''}: {', '.join(voices)}" + f"in {len(voices)} voice{'s' if len(voices) != 1 else ''}: {', '.join(voices)}{note}" ) diff --git a/bolt_pet/file_ops.py b/bolt_pet/file_ops.py index ed8683d..9e388ae 100644 --- a/bolt_pet/file_ops.py +++ b/bolt_pet/file_ops.py @@ -52,6 +52,8 @@ import json from pathlib import Path from typing import Optional +from . import relay_json + _PREFIXES = ("filectl", "file") # Keeps a runaway read/write/list from blowing up the tool_result relay (and, @@ -95,17 +97,23 @@ def parse(command: str) -> Optional[dict]: if not rest or rest.lower() in ("help", "-h", "--help"): return {"action": "help"} + # Lenient on purpose — see relay_json. A stray quote in machine-written + # JSON should not cost a turn, but the repair is reported back so the model + # is told it sent something broken while it can still learn from it. try: - payload = json.loads(rest) - except json.JSONDecodeError as exc: - raise FileOpError(f"couldn't parse filectl JSON ({exc}); usage:\n{HELP}") from exc + payload, repairs = relay_json.loads(rest) + except relay_json.RelayJsonError as exc: + raise FileOpError(f"couldn't parse filectl JSON — {exc}\nusage:\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() + # Carried on the action so execute() can tell the model what it got wrong; + # a silent repair would fix today's call and guarantee tomorrow's. + tag = {"_repairs": repairs} if repairs else {} if op == "help": - return {"action": "help"} + return {"action": "help", **tag} if op == "list": path = _required_str(payload, "path") @@ -114,7 +122,7 @@ def parse(command: str) -> Optional[dict]: raise FileOpError('"pattern" must be a non-empty string') return { "action": "list", "path": path, - "pattern": pattern, "recursive": bool(payload.get("recursive")), + "pattern": pattern, "recursive": bool(payload.get("recursive")), **tag, } if op == "read": @@ -122,14 +130,14 @@ def parse(command: str) -> Optional[dict]: return { "action": "read", "path": path, "start": _line_number(payload.get("start"), "start"), - "end": _line_number(payload.get("end"), "end"), + "end": _line_number(payload.get("end"), "end"), **tag, } 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"])} + return {"action": "write", "path": path, "content": str(payload["content"]), **tag} if op == "edit": path = _required_str(payload, "path") @@ -138,7 +146,7 @@ def parse(command: str) -> Optional[dict]: 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} + return {"action": "edit", "path": path, "old": old, "new": new, **tag} raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}") @@ -175,14 +183,16 @@ def execute(action: dict) -> str: 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" + output = _do_list(action) + elif kind == "read": + output = _do_read(action) + elif kind == "write": + output = _do_write(action) + elif kind == "edit": + output = _do_edit(action) + else: + output = "[filectl] ok" + return output + relay_json.repair_note(action.get("_repairs") or []) def _resolve(path_str: str) -> Path: diff --git a/bolt_pet/relay_json.py b/bolt_pet/relay_json.py new file mode 100644 index 0000000..71f4bfb --- /dev/null +++ b/bolt_pet/relay_json.py @@ -0,0 +1,148 @@ +"""Lenient JSON for the relayed command channel — and honest about it. + +`filectl` and `dialoguectl` both take a single line of compact JSON, hand-typed +by a language model into a tool marker. Models get that *nearly* right and then +get it wrong in a small, boringly repeatable set of ways: + + {"op":"list","path":"/home/x","recursive":false"} ← stray quote after a literal + {"op": "read", "path": "/tmp/a.txt",} ← trailing comma + {'op': 'read', 'path': '/tmp/a.txt'} ← single quotes + {“op”: “read”, “path”: “/tmp/a.txt”} ← smart quotes + {"op": "list", "recursive": False} ← Python literals + ```json {"op": "list"} ``` ← fenced + +Observed live (2026-07-30): a stray quote after `false` cost an entire desk +turn — the call was rejected, the model re-sent the *identical* line, was +rejected again, and then gave up and told the user "I'll check now" without +ever calling anything. The user got a promise instead of an answer because of +one character. + +Strict parsing is the wrong trade here. Nothing about a misplaced quote is +ambiguous, the payload is machine-written and machine-read, and the cost of +refusing is a wasted round trip that the model has already demonstrated it +won't recover from. So: try strict first, then apply narrow repairs, and +accept a repair **only if the result parses**. + +Two rules keep this from becoming "guess what they meant": + +1. **Repairs are conservative and named.** Each one fixes a known malformation, + is applied in isolation, and is reported by name. +2. **Repairs are never silent.** The caller appends the repair note to the tool + result, so the model is told it sent broken JSON *while it still has the + turn* — the fix works today and teaches within the conversation. Hiding it + would trade a visible failure for an invisible one. + +When nothing parses, the error points at the exact character with a caret, +because "Expecting ',' delimiter: char 74" is not something a model can act on +and a pointed-at fragment is. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Callable + +# Ordered, cheapest and safest first. Each entry is (name, transform); after +# each one the payload is re-parsed, so the first repair that works wins and +# nothing more aggressive gets applied than the input actually needed. +_REPAIRS: tuple[tuple[str, Callable[[str], str]], ...] = ( + ( + "stripped a markdown code fence", + lambda text: re.sub(r"^\s*```(?:json)?\s*|\s*```\s*$", "", text), + ), + ( + "replaced smart quotes with straight ones", + lambda text: text.translate(str.maketrans({"“": '"', "”": '"', + "‘": "'", "’": "'"})), + ), + ( + "removed a stray quote after a bare value", + # {"recursive":false"} -> {"recursive":false} + lambda text: re.sub( + r"(:\s*(?:true|false|null|-?\d+(?:\.\d+)?))\s*\"(\s*[,}\]])", r"\1\2", text), + ), + ( + "removed a trailing comma", + lambda text: re.sub(r",(\s*[}\]])", r"\1", text), + ), + ( + "converted Python literals (True/False/None) to JSON", + lambda text: re.sub(r"(:\s*)(True|False|None)\b", + lambda m: m.group(1) + {"True": "true", "False": "false", + "None": "null"}[m.group(2)], text), + ), + ( + "converted single-quoted strings to double-quoted", + lambda text: re.sub(r"'([^'\"]*)'", r'"\1"', text), + ), +) + + +class RelayJsonError(ValueError): + """Unparseable even after repairs — carries a pointed-at fragment.""" + + +def loads(payload: str) -> tuple[Any, list[str]]: + """Parse *payload*, repairing common model mistakes. + + Returns (data, repairs) where *repairs* names what had to be fixed — empty + when the input was already valid. Raises RelayJsonError with a caret at the + offending character when nothing works.""" + text = str(payload or "").strip() + if not text: + raise RelayJsonError("empty payload") + try: + return json.loads(text), [] + except json.JSONDecodeError as exc: + # Bound to a plain name: Python deletes the `as` target at the end of + # the except block, so referring to it further down would raise + # UnboundLocalError instead of reporting the parse failure. + first_error = exc + + applied: list[str] = [] + candidate = text + for name, repair in _REPAIRS: + repaired = repair(candidate) + if repaired == candidate: + continue + candidate = repaired + applied.append(name) + try: + return json.loads(candidate), applied + except json.JSONDecodeError: + continue # keep going: a payload can be broken in more than one way + + raise RelayJsonError(point_at(text, first_error)) + + +def point_at(text: str, error: json.JSONDecodeError, width: int = 28) -> str: + """Show the failure where it happened. + + A model can act on "you wrote `false\"}` here"; it cannot act on + "Expecting ',' delimiter: line 1 column 75".""" + position = max(0, min(len(text), getattr(error, "pos", 0))) + start = max(0, position - width) + end = min(len(text), position + width) + fragment = text[start:end] + caret = " " * (position - start) + "^" + lead = "…" if start > 0 else "" + tail = "…" if end < len(text) else "" + return ( + f"{error.msg} at character {position}:\n" + f" {lead}{fragment}{tail}\n" + f" {' ' * len(lead)}{caret}" + ) + + +def repair_note(repairs: list[str]) -> str: + """The line appended to a tool result when repairs were needed. + + Phrased as feedback rather than an apology: the model is the author of the + broken JSON and is the one who can stop sending it.""" + if not repairs: + return "" + return ( + " (note: your JSON was malformed — I " + "; ".join(repairs) + + " and ran it anyway. Send valid single-line JSON next time.)" + ) diff --git a/tests/test_file_ops.py b/tests/test_file_ops.py index b0c0f4b..ea6a5c8 100644 --- a/tests/test_file_ops.py +++ b/tests/test_file_ops.py @@ -264,3 +264,66 @@ def test_write_creates_parent_directories(tmp_path): action = file_ops.parse(_cmd({"op": "write", "path": str(target), "content": "hi"})) file_ops.execute(action) assert target.read_text() == "hi" + + +# ── lenient JSON (see relay_json) ─────────────────────────────────────────── +# Machine-written JSON fails in a small, repeatable set of ways. Observed live +# 2026-07-30: a stray quote after `false` cost a whole desk turn — rejected, +# re-sent identically, rejected again, then abandoned with a promise to the +# user that nothing fulfilled. + +def test_the_stray_quote_that_cost_a_live_turn_now_parses(): + action = file_ops.parse( + 'filectl {"op":"list","path":"/home/maji/Documents","pattern":"*","recursive":false"}' + ) + assert action["action"] == "list" + assert action["path"] == "/home/maji/Documents" + assert action["recursive"] is False + assert action["_repairs"] == ["removed a stray quote after a bare value"] + + +@pytest.mark.parametrize("payload,expected", [ + ('{"op": "read", "path": "/tmp/a.txt",}', "removed a trailing comma"), + ("{'op': 'read', 'path': '/tmp/a.txt'}", "converted single-quoted strings to double-quoted"), + ('{“op”: “read”, “path”: “/tmp/a.txt”}', "replaced smart quotes with straight ones"), + ('```json {"op": "read", "path": "/tmp/a.txt"} ```', "stripped a markdown code fence"), +]) +def test_common_model_json_mistakes_are_repaired(payload, expected): + action = file_ops.parse("filectl " + payload) + assert action["action"] == "read" + assert action["path"] == "/tmp/a.txt" + assert expected in action["_repairs"] + + +def test_python_literals_are_converted(): + action = file_ops.parse('filectl {"op": "list", "path": "/tmp", "recursive": True}') + assert action["recursive"] is True + + +def test_a_repair_is_reported_in_the_output_never_hidden(tmp_path): + """Silently fixing it would work today and guarantee the same broken call + tomorrow — the model has to be told while it still has the turn.""" + (tmp_path / "a.txt").write_text("hello", encoding="utf-8") + action = file_ops.parse( + 'filectl {"op":"list","path":"%s","recursive":false"}' % tmp_path + ) + output = file_ops.execute(action) + assert "a.txt" in output + assert "your JSON was malformed" in output + assert "stray quote" in output + + +def test_valid_json_gets_no_repair_note(tmp_path): + (tmp_path / "a.txt").write_text("hello", encoding="utf-8") + action = file_ops.parse('filectl {"op": "list", "path": "%s"}' % tmp_path) + assert "_repairs" not in action + assert "malformed" not in file_ops.execute(action) + + +def test_genuinely_unparseable_json_points_at_the_character(): + """"Expecting ',' delimiter: char 74" is not something a model can act on.""" + with pytest.raises(file_ops.FileOpError) as excinfo: + file_ops.parse('filectl {"op": "read", "path": "/tmp/a.txt" "extra": 1}') + message = str(excinfo.value) + assert "^" in message # caret under the offending character + assert '"extra"' in message # ...and the fragment around it