diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ed9c3..6b98bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Glance's `salience` has anchors instead of a vibe. A real run scored a man + sitting at a desk 0.7-0.8 on every tick, which means the number carries no + information and Focus has nothing to go on. Someone working is 0.1, 0.9+ is + something wrong, and not being able to tell what's happening is 0.1 with a + summary that says so. +- A quiet glance overwrites the last quiet one instead of scrolling. An hour of + an empty room was 3,600 near-identical lines, which buries the few that meant + something and, when a screen is one of the sensors, feeds back in as input. + Escalations, Focus verdicts and errors still scroll, and piping to a file still + keeps every tick. + - `setup` offers the Ollama models you already have before offering a download, and checks them by name rather than by count. Having some other model pulled read as "ready", and then every tick died on `Ollama has no model 'gemma3:4b'`. diff --git a/saccade/glance.py b/saccade/glance.py index 415f609..756f986 100644 --- a/saccade/glance.py +++ b/saccade/glance.py @@ -48,6 +48,20 @@ someone typing about it. Report what the person is doing, not the text you \ recognise on their screen. +`salience` is a calibrated number, not a mood. Anchor it: + + 0.0-0.2 the room doing what it always does: someone working, reading, sitting, \ +walking through, a pet asleep. This is what most glances should score. + 0.3-0.5 a change worth noting, not worth interrupting anyone over: they got up, \ +someone new sat down, the light changed. + 0.6-0.8 something you would actually mention to a person standing next to you. + 0.9-1.0 something wrong: a fall, smoke, a stranger, water where water shouldn't be. + +Someone at a desk working is 0.1, including the first time you see them. Being \ +unable to tell what is happening is not salience either; that is 0.1 and a summary \ +that says you can't tell. If nearly every glance lands above 0.5 the number has \ +stopped carrying information, and the closer look has nothing to go on. + Judge change, not the static scene: someone who has simply been sitting or standing \ there is ONE ongoing event, not a new one every second. If you already escalated an \ ongoing situation (see the [escalated] lines above), do NOT escalate it again. \ diff --git a/saccade/loop.py b/saccade/loop.py index 7555d18..e291073 100644 --- a/saccade/loop.py +++ b/saccade/loop.py @@ -19,6 +19,7 @@ import contextlib import inspect import shutil +import sys from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING @@ -53,12 +54,34 @@ def _fit(text: str, reserved: int, pad: bool = False) -> str: return f"{out:<{width}}" if pad else out +_live_line = False # a quiet glance is sitting on the current row, unterminated + + +def _out(text: str, live: bool = False) -> None: + """Print, clearing any live line first so a kept line never inherits its tail. + + A quiet tick overwrites the last quiet one instead of scrolling. An hour of an + empty room was 3,600 lines of "a man is sitting at a desk", which buries the + few lines that meant something, and when a screen is one of the sensors it + feeds straight back in as input. Anything worth keeping (an escalation, what + Focus decided, an error) scrolls normally.""" + global _live_line + if _live_line: + print("\r\033[K", end="") # back to column 0, erase to end of line + # Always flushed: at ~1 Hz the cost is nothing, and an agent that runs for + # hours shouldn't lose its log to a block buffer when someone kills it. + print(text, end="\r" if live else "\n", flush=True) + _live_line = live + + def _log(p: Percept) -> None: mark = " ‼ escalate" if p.escalate else "" cadence = f" ⟳{p.next_glance_s:0.0f}s" if p.next_glance_s > 0 else "" # "[glance] sal=0.1 " + the widest mark + the widest cadence. summary = _fit(p.summary, len("[glance] sal=0.1 ") + 13 + 7, pad=True) - print(f"[glance] sal={p.salience:0.1f} {summary}{mark}{cadence}") + line = f"[glance] sal={p.salience:0.1f} {summary}{mark}{cadence}" + # Not a terminal: keep every tick, since that's a log someone will read later. + _out(line, live=sys.stdout.isatty() and not p.escalate) def _next_interval(percept: Percept | None, floor: float, ceiling: float, adaptive: bool) -> float: @@ -100,7 +123,7 @@ async def _focus_act( decision = await focus.reason(percept, Window(frames=clip), memory) # Log every verdict, not just spoken ones; otherwise deliberate silence # (Focus judging it not worth interrupting) looks identical to a dead path. - print(f"[focus] speak={str(decision.speak):5} {_fit(decision.reasoning, 22)}") + _out(f"[focus] speak={str(decision.speak):5} {_fit(decision.reasoning, 22)}") if decision.speak: memory.episodic.record( "action", {"message": decision.message, "trigger": percept.summary} @@ -109,7 +132,7 @@ async def _focus_act( if inspect.isawaitable(result): await result except Exception as e: # noqa: BLE001 (a bad Focus must not kill the agent) - print(f"[focus] skipped: {type(e).__name__}: {e}") + _out(f"[focus] skipped: {type(e).__name__}: {e}") async def _tick( @@ -191,9 +214,9 @@ async def capture() -> None: if msg == last_err: repeats += 1 if repeats % _REPEAT_EVERY == 0: - print(f"[loop] still failing ({repeats + 1}x): {msg}") + _out(f"[loop] still failing ({repeats + 1}x): {msg}") else: - print(f"[loop] skipped a tick: {msg}") + _out(f"[loop] skipped a tick: {msg}") last_err, repeats = msg, 0 if stream_done.is_set() and capture_task.done(): break diff --git a/tests/test_loop.py b/tests/test_loop.py index 60af425..ce37837 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -300,3 +300,37 @@ def test_the_escalate_marker_survives_a_long_summary(monkeypatch, capsys): looplib._log(Percept(ts=0.0, summary="w" * 300, salience=0.9, escalate=True, next_glance_s=2.0)) out = capsys.readouterr().out assert "escalate" in out and "⟳2s" in out + + +def test_a_quiet_glance_overwrites_instead_of_scrolling(monkeypatch, capsys): + """An hour of an empty room was 3,600 lines of "a man is sitting at a desk", + which buries the few lines that meant something and, when a screen is one of + the sensors, feeds straight back in as its own input.""" + monkeypatch.setattr(looplib, "_live_line", False) + monkeypatch.setattr(looplib.sys.stdout, "isatty", lambda: True) + looplib._log(Percept(ts=0.0, summary="a man at a desk", salience=0.1, escalate=False)) + assert capsys.readouterr().out.endswith("\r") + + +def test_an_escalation_is_kept(monkeypatch, capsys): + """The quiet line is a status display; an escalation is the record.""" + monkeypatch.setattr(looplib, "_live_line", False) + monkeypatch.setattr(looplib.sys.stdout, "isatty", lambda: True) + looplib._log(Percept(ts=0.0, summary="someone at the door", salience=0.9, escalate=True)) + assert capsys.readouterr().out.endswith("\n") + + +def test_piping_to_a_file_keeps_every_tick(monkeypatch, capsys): + """Not a terminal means it's a log someone reads later, not a live display.""" + monkeypatch.setattr(looplib, "_live_line", False) + monkeypatch.setattr(looplib.sys.stdout, "isatty", lambda: False) + looplib._log(Percept(ts=0.0, summary="a man at a desk", salience=0.1, escalate=False)) + assert capsys.readouterr().out.endswith("\n") + + +def test_a_kept_line_clears_the_live_one(monkeypatch, capsys): + """Without the erase, a short Focus line inherits the tail of the padded + glance line it lands on top of.""" + monkeypatch.setattr(looplib, "_live_line", True) + looplib._out("[focus] speak=True") + assert capsys.readouterr().out.startswith("\r\033[K")