From c6752daff062d875d40f591d28cea5414111eb15 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 10 Mar 2026 23:01:51 -0400 Subject: [PATCH 1/2] Polish REPL UX and align hard-positive spec --- docs/M1Design.md | 3 ++ src/context_compiler/repl.py | 61 +++++++++++++++++++++++- tests/test_repl.py | 90 ++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) diff --git a/docs/M1Design.md b/docs/M1Design.md index dd485ce..f9f4031 100644 --- a/docs/M1Design.md +++ b/docs/M1Design.md @@ -120,6 +120,9 @@ Accepted patterns: - "I am using X" - "I'm using X" +Polite prefixes such as `"please"` may be tolerated and ignored by the parser. +For example, `please use X` is treated the same as `use X`. + Produces: FACT_SET(key="focus.primary", value=X) diff --git a/src/context_compiler/repl.py b/src/context_compiler/repl.py index 8239d04..e35bec3 100644 --- a/src/context_compiler/repl.py +++ b/src/context_compiler/repl.py @@ -5,17 +5,76 @@ from . import create_engine from .engine import Decision +_EXIT_TOKENS = {"exit", "quit"} +_HELP_TOKENS = {"help", "?"} + def format_decision(decision: Decision) -> str: return json.dumps(decision, sort_keys=True, separators=(",", ":")) +def _is_interactive(in_stream: TextIO, out_stream: TextIO) -> bool: + return bool(in_stream.isatty() and out_stream.isatty()) + + +def _print_interactive_help(out_stream: TextIO) -> None: + print("Commands: help/? exit/quit", file=out_stream) + print("Examples:", file=out_stream) + print(" use chamber ensemble", file=out_stream) + print(" don't use passive voice", file=out_stream) + print(" allow contractions", file=out_stream) + print(" actually piano reduction", file=out_stream) + print(" reset policies", file=out_stream) + print(" clear state", file=out_stream) + + +def _print_interactive_decision(decision: Decision, out_stream: TextIO) -> None: + kind = decision["kind"] + if kind == "passthrough": + print("passthrough", file=out_stream) + return + if kind == "clarify": + prompt = decision["prompt_to_user"] or "" + print(f"clarify: {prompt}", file=out_stream) + return + + print("updated", file=out_stream) + state = decision["state"] + assert state is not None + focus_primary = state["facts"]["focus.primary"] + prohibit = state["policies"]["prohibit"] + print("state:", file=out_stream) + print(f" focus.primary: {json.dumps(focus_primary)}", file=out_stream) + print(f" prohibit: {json.dumps(prohibit)}", file=out_stream) + + def run_repl(in_stream: TextIO, out_stream: TextIO) -> None: engine = create_engine() + if _is_interactive(in_stream, out_stream): + print("Context Compiler REPL. Type help for commands.", file=out_stream) + + while True: + line = in_stream.readline() + if line == "": + return + user_input = line.rstrip("\n") + token = user_input.strip().lower() + if not token: + continue + if token in _EXIT_TOKENS: + return + if token in _HELP_TOKENS: + _print_interactive_help(out_stream) + continue + + decision = engine.step(user_input) + _print_interactive_decision(decision, out_stream) + return + for line in in_stream: user_input = line.rstrip("\n") - if user_input.strip().lower() in {"exit", "quit"}: + if user_input.strip().lower() in _EXIT_TOKENS: return decision = engine.step(user_input) print(format_decision(decision), file=out_stream) diff --git a/tests/test_repl.py b/tests/test_repl.py index 7966ba1..d0610ac 100644 --- a/tests/test_repl.py +++ b/tests/test_repl.py @@ -4,6 +4,11 @@ from context_compiler.repl import run_repl +class _TTYStringIO(StringIO): + def isatty(self) -> bool: + return True + + def _run_session(text: str) -> list[dict[str, object]]: out = StringIO() run_repl(StringIO(text), out) @@ -55,3 +60,88 @@ def test_repl_exit_and_quit_terminate_session() -> None: assert decisions_exit == [] assert decisions_quit == [] + + +def test_repl_interactive_help_commands() -> None: + out = _TTYStringIO() + run_repl(_TTYStringIO("help\n?\nquit\n"), out) + + lines = out.getvalue().splitlines() + expected_help = [ + "Commands: help/? exit/quit", + "Examples:", + " use chamber ensemble", + " don't use passive voice", + " allow contractions", + " actually piano reduction", + " reset policies", + " clear state", + ] + assert lines[0] == "Context Compiler REPL. Type help for commands." + assert lines[1:9] == expected_help + assert lines[9:17] == expected_help + + +def test_repl_interactive_ignores_blank_lines() -> None: + out = _TTYStringIO() + run_repl(_TTYStringIO("\n \n\t\nquit\n"), out) + + assert out.getvalue().splitlines() == ["Context Compiler REPL. Type help for commands."] + + +def test_repl_interactive_formats_decisions_and_state_summary() -> None: + out = _TTYStringIO() + run_repl(_TTYStringIO("hello\nuse Nord Stage 4\nno use docker\nquit\n"), out) + + lines = out.getvalue().splitlines() + assert lines[0] == "Context Compiler REPL. Type help for commands." + assert lines[1] == "passthrough" + assert lines[2] == "updated" + assert lines[3] == "state:" + assert lines[4] == ' focus.primary: "Nord Stage 4"' + assert lines[5] == " prohibit: []" + assert lines[6].startswith("clarify: ") + + +def test_repl_interactive_eof_exits_cleanly() -> None: + out = _TTYStringIO() + run_repl(_TTYStringIO("use Nord Stage 4\n"), out) + + assert out.getvalue().splitlines() == [ + "Context Compiler REPL. Type help for commands.", + "updated", + "state:", + ' focus.primary: "Nord Stage 4"', + " prohibit: []", + ] + + +def test_repl_interactive_reset_policies_output() -> None: + out = _TTYStringIO() + run_repl(_TTYStringIO("don't use passive voice\nreset policies\nquit\n"), out) + + lines = out.getvalue().splitlines() + assert lines == [ + "Context Compiler REPL. Type help for commands.", + "updated", + "state:", + " focus.primary: null", + ' prohibit: ["passive voice"]', + "updated", + "state:", + " focus.primary: null", + " prohibit: []", + ] + + +def test_repl_non_interactive_keeps_json_output() -> None: + out = StringIO() + run_repl(StringIO("use Nord Stage 4\nquit\n"), out) + + lines = out.getvalue().splitlines() + expected = ( + '{"kind":"update","prompt_to_user":null,' + '"state":{"facts":{"focus.primary":"Nord Stage 4"},' + '"policies":{"prohibit":[]},"version":1}}' + ) + assert lines == [expected] From 23ab9c7d837820a8bd990970d28905023052119a Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 10 Mar 2026 23:15:15 -0400 Subject: [PATCH 2/2] Configure coverage source and exclude REPL entrypoint guard --- pyproject.toml | 3 +++ src/context_compiler/repl.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 462c4bb..40dbb79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,3 +60,6 @@ mypy_path = "src" [tool.pytest.ini_options] addopts = "-ra" testpaths = ["tests"] + +[tool.coverage.run] +source = ["context_compiler"] diff --git a/src/context_compiler/repl.py b/src/context_compiler/repl.py index e35bec3..88eda86 100644 --- a/src/context_compiler/repl.py +++ b/src/context_compiler/repl.py @@ -85,5 +85,5 @@ def main() -> int: return 0 -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover raise SystemExit(main())