From df1a9269ac3bde4813334b942b40f15c5c0260fd Mon Sep 17 00:00:00 2001 From: Devin Phillip Davis <354534380+quantum-osiris@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:14:54 -0500 Subject: [PATCH] fix(repl): bare command words are not sent to the model; code stops a looping reply; charger note only on the phone From a 2026-11-09 session: - Typing "code" alone went to qwen2.5:1.5b, which wrote one invented block about OSIRIS seven times until the 2,048-token limit. A lone word that is exactly a command name now names the command instead (read-only ones are offered as [1]; others are never run for you). Sentences and unknown words still go to OSIRIS. - living.repeating(): the stream stops once the last 300 characters have appeared three times; the reply is marked incomplete and is not learned. - The mentor-round preflight said "Plug in the charger: the phone ..." on the desktop; it now appears only on NODE_ALPHA_MOBILE. Full suite: 1775 passed, 13 skipped, 2 failed (the same two host-specific failures as on main). Co-Authored-By: Claude Opus 5.5 --- osiris_cli/living.py | 21 +++++++++++++- osiris_cli/osiris_repl.py | 55 +++++++++++++++++++++++++++++++++++++ osiris_termux_console.py | 12 +++++++- tests/test_living.py | 30 ++++++++++++++++++++ tests/test_repl_dispatch.py | 28 +++++++++++++++++++ 5 files changed, 144 insertions(+), 2 deletions(-) diff --git a/osiris_cli/living.py b/osiris_cli/living.py index 24ac511a..a6ed9434 100644 --- a/osiris_cli/living.py +++ b/osiris_cli/living.py @@ -83,6 +83,19 @@ # num_ctx tokens: a 100k-character paste timed out twice (601 s, 406 s) on 2026-10-01. # Long messages go to the mentor as a bounded excerpt; the ledger keeps the full text. LONG_MESSAGE = 3000 +# A small voice can fall into a loop and repeat one block until the reply limit (qwen2.5:1.5b +# wrote one ~1,500-character block seven times on 2026-11-09). Code stops the stream once the +# last REPEAT_SPAN characters have appeared REPEAT_LIMIT times; the reply is marked incomplete. +REPEAT_SPAN = 300 +REPEAT_LIMIT = 3 + + +def repeating(text: str, span: int = REPEAT_SPAN, limit: int = REPEAT_LIMIT) -> bool: + """True once the last ``span`` characters of ``text`` occur ``limit`` or more times in it.""" + if len(text) < span * limit: + return False + return text.count(text[-span:]) >= limit + MESSAGE_BUDGET = 6000 # The voice: /mentor NAME, else OSIRIS_VOICE, else the first of these that is installed. # OSIRIS_CHAT_MODEL is the console's Architect (intent -> spec), not the voice: it is @@ -1066,6 +1079,7 @@ def converse(self, text: str, learnable: bool = True, sent_user = None incomplete = "" + looped = False force_core = getattr(self, "force_core_voice", None) use_core_voice = bool(force_core) if force_core is not None else (os.environ.get("OSIRIS_CORE_VOICE", "1") != "0") if gate["open"] and self.core is not None and use_core_voice: @@ -1113,6 +1127,9 @@ def converse(self, text: str, learnable: bool = True, first_word = time.time() - waiting.started reply += piece self.out(piece) + if repeating(reply): + looped = True + break except KeyboardInterrupt: waiting.stop() interrupted = True @@ -1131,7 +1148,9 @@ def converse(self, text: str, learnable: bool = True, if first_word is not None: self._note_first_word(model, first_word) done = getattr(self.mentor, "last_done", None) or {} - if not interrupted and done.get("done_reason") == "length": + if not interrupted and looped: + incomplete = "stopped: the reply began repeating itself" + elif not interrupted and done.get("done_reason") == "length": incomplete = f"cut off at the {MENTOR_MAX_TOKENS}-token reply limit" elif not interrupted and reply.count("```") % 2: incomplete = "ends inside an unclosed code block" diff --git a/osiris_cli/osiris_repl.py b/osiris_cli/osiris_repl.py index 894db557..b2d76f3e 100644 --- a/osiris_cli/osiris_repl.py +++ b/osiris_cli/osiris_repl.py @@ -1902,6 +1902,53 @@ def execute_trio(rest: str) -> None: print(f"\n[!] {e}\n") +# Commands this REPL handles itself (the command branches of dispatch_command); a test +# keeps the set in step with the source. +REPL_COMMANDS = { + "/apply", "/architecture", "/benchmark", "/bridges", "/chat", "/check", "/clean", "/closure", + "/code", "/core", "/digest", "/discard", "/dna", "/experiment", "/fast", "/firewall", "/forge", + "/forget", "/gemini", "/help", "/ignite", "/ingest", "/intent", "/introspect", "/learn", + "/ledger", "/legit", "/mentor", "/mesh", "/mutate", "/nclm", "/ollama", "/osiris", "/physics", + "/power", "/pr", "/publish", "/q", "/remember", "/retrieval", "/run", "/self", "/sim", + "/status", "/swarm", "/sync", "/tetra1d", "/tournament", "/train", "/trio", "/unlearn", + "/update", "/validate", "/voice", "/welcome", +} +# Safe to offer as a one-key [1]: they only read and print. +READ_ONLY_COMMANDS = { + "/help", "/status", "/home", "/check", "/osiris", "/mentors", "/why", "/ledger", + "/architecture", "/lab", "/sources", "/hypotheses", "/gaps", "/runs", "/suggest", "/welcome", + "/power", "/firewall", "/ollama", "/facts", "/nclm", "/intent", +} +# Commands that need words after them to do anything. +NEEDS_ARGUMENT = {"/code": "/code explain osiris_cli/living.py", "/legit": "/legit list", + "/remember": "/remember ", "/forget": "/forget ", + "/self": "/self ", "/chat": "/chat ", "/dna": '/dna ""', + "/gemini": "/gemini ", "/physics": "/physics chsh"} + + +def bare_command(word: str) -> Optional[str]: + """``"/" + word`` when that is a command name, else None. Single letters are menu keys.""" + if len(word) < 2 or not word.isalpha(): + return None + name = "/" + word.lower() + known = REPL_COMMANDS | CONSOLE_COMMANDS | set(getattr(otc, "IMMEDIATE_COMMANDS", ()) or ()) + return name if name in known else None + + +def offer_bare_command(word: str) -> None: + name = bare_command(word) + print(f"\n[!] '{word}' on its own looks like the command {name} -- not sent to the model.") + if name in NEEDS_ARGUMENT: + print(f" It needs more words, for example: {NEEDS_ARGUMENT[name]}") + elif name in READ_ONLY_COMMANDS and otc is not None: + otc._pending_suggestions.clear() + otc._pending_suggestions.update({"1": name}) + print(f" [1] Run {name} (read-only)") + else: + print(f" Type {name} to run it.") + print(" To talk to OSIRIS instead, write a few more words.\n") + + def dispatch_capturing(state: OsirisReplState, line: str) -> None: """safe_dispatch with keys typed during the command's output captured, not echoed into it (2026-10-25: '/architecture' typed while /check printed was scattered into the bench table, @@ -2028,6 +2075,14 @@ def dispatch_command(state: OsirisReplState, line: str) -> None: except Exception as e: print(f"\033[1;31m[!] Console command error ({cmd}): {e}\033[0m\n") return + # 3a. A lone word that is exactly a command name ("code", "status") is that command typed + # without its "/". It is not sent to the model: one word gives a small voice nothing to + # answer, and on 2026-11-09 "code" alone got a looping, invented description of OSIRIS. + # Read-only commands are offered as [1]; the rest are named, never run for you. + if len(parts) == 1 and not cmd.startswith("/") and bare_command(cmd): + offer_bare_command(cmd) + return + # 3. Commands need a leading "/". Anything else is conversation with OSIRIS, # so a sentence that starts with "learn", "status" or "dna" is never hijacked. if not cmd.startswith("/") and cmd not in ("?",): diff --git a/osiris_termux_console.py b/osiris_termux_console.py index f24b4802..88068c49 100755 --- a/osiris_termux_console.py +++ b/osiris_termux_console.py @@ -3161,7 +3161,7 @@ def _bench_preflight(command, argv, label, ui): + ("" if argv[3] in ("gemini", "gateway") else ", or use cloud quota"))] inner = osiris_ui.Canvas(ui.mode, ui.cols - 4) print("\n" + ui.panel("MENTOR ROUND ยท PREFLIGHT", inner.facts(rows).split("\n") + [ - "", ui.dim("Plug in the charger: the phone works hard for the whole round.")])) + *[ui.dim(t) if t else t for t in _charger_note()]])) _pending_suggestions.clear() _pending_suggestions.update({"1": command.strip() + " now", "0": "/home"}) print(ui.actions("START?", [("1", "Start the round"), ("0", "Not now")]) + "\n") @@ -5376,6 +5376,16 @@ def _is_unknown_command(text: str) -> bool: return stripped.split()[0].lower() not in IMMEDIATE_COMMANDS +def _charger_note() -> list: + """The charger reminder, only on the phone (Node Alpha); a desktop has no battery to drain.""" + try: + from osiris.substrate import node_type + on_phone = node_type() == "NODE_ALPHA_MOBILE" + except Exception: # noqa: BLE001 - no substrate module: say nothing rather than guess + on_phone = False + return ["", "Plug in the charger: the phone works hard for the whole round."] if on_phone else [] + + def _unknown_command(text: str): first = text.strip().split()[0].lower() head = "/" + first.lstrip("/").split("/")[0] diff --git a/tests/test_living.py b/tests/test_living.py index 5ffef685..3341e8a7 100644 --- a/tests/test_living.py +++ b/tests/test_living.py @@ -554,3 +554,33 @@ def test_the_voice_is_osiris_voice_not_the_architects_chat_model(monkeypatch): assert m.model() == "qwen2.5:3b" m.set_model("qwen2.5:7b") # /mentor wins assert m.model() == "qwen2.5:7b" + + +class LoopingMentor(ScriptedMentor): + """Repeats one block forever, like qwen2.5:1.5b on 2026-11-09.""" + + def stream(self, messages, model=None): + self.calls.append(messages) + self.sent = 0 + block = ("### Key Components\n1. **Quantum Computing Engine**: a block the voice repeats, " + "word for word, until the reply limit stops it. ") * 3 + while self.sent < 200: + self.sent += 1 + yield block + + +def test_repeating_spots_a_loop_and_not_ordinary_text(): + assert not living.repeating("a short reply") + prose = " ".join(f"sentence {i} says something new." for i in range(200)) + assert not living.repeating(prose) + assert living.repeating(("the same paragraph again and again. " * 12) * 4) + + +def test_a_looping_voice_is_stopped_by_code_and_not_learned(tmp_path): + mentor = LoopingMentor() + o, out = make(tmp_path, mentor=mentor) + o.converse("tell me about the code") + said = "".join(out) + assert mentor.sent < 10 # stopped long before 200 blocks + assert "stopped: the reply began repeating itself" in said + assert "not learned: incomplete reply" in said diff --git a/tests/test_repl_dispatch.py b/tests/test_repl_dispatch.py index 31d06178..2e9e62b0 100644 --- a/tests/test_repl_dispatch.py +++ b/tests/test_repl_dispatch.py @@ -246,6 +246,34 @@ def test_keys_and_typos_never_reach_the_model(calls, capsys): assert calls == [("_unknown_command", ("/stauts",))] assert capsys.readouterr().out.count("No menu item") == 2 + def test_a_bare_command_word_is_not_sent_to_the_model(calls, capsys): + run("code") + run("status") + run("publish") + out = capsys.readouterr().out + assert not [c for c in calls if c[0] == "model"] + assert "looks like the command /code" in out and "/code explain" in out + assert "[1] Run /status (read-only)" in out + assert "Type /publish to run it" in out and "[1] Run /publish" not in out + assert otc._pending_suggestions == {"1": "/status"} # publish is never offered + + def test_sentences_and_unknown_words_still_go_to_osiris(calls): + run("code review my parser please") + run("hello") + assert calls == [("model", "code review my parser please"), ("model", "hello")] + + def test_repl_commands_set_matches_the_dispatch_source(): + found = set(re.findall(r'cmd (?:==|in) \(?"(/[a-z0-9_-]+)"', REPL_SRC)) + assert found <= osiris_repl.REPL_COMMANDS | osiris_repl.CONSOLE_COMMANDS, \ + found - osiris_repl.REPL_COMMANDS - osiris_repl.CONSOLE_COMMANDS + + def test_charger_note_only_on_the_phone(monkeypatch): + import osiris.substrate as sub + monkeypatch.setattr(sub, "node_type", lambda: "NODE_BETA_COMPUTE") + assert otc._charger_note() == [] + monkeypatch.setattr(sub, "node_type", lambda: "NODE_ALPHA_MOBILE") + assert "charger" in " ".join(otc._charger_note()) + def test_plain_text_still_goes_to_osiris(calls): run("hello osiris") run("/home/enki/log.txt line 1\nline 2") # a pasted log is conversation