Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`.
Expand Down
14 changes: 14 additions & 0 deletions saccade/glance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. \
Expand Down
33 changes: 28 additions & 5 deletions saccade/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import contextlib
import inspect
import shutil
import sys
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/test_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading