Skip to content

feat(tools): add timer tool for countdowns#304

Open
isair wants to merge 11 commits intodevelopfrom
feat/timer-tool
Open

feat(tools): add timer tool for countdowns#304
isair wants to merge 11 commits intodevelopfrom
feat/timer-tool

Conversation

@isair
Copy link
Copy Markdown
Owner

@isair isair commented Apr 26, 2026

Summary

Adds a built-in timer tool for countdowns: set / list / cancel actions, optional labels, concurrent timers, and a best-effort audible + visible announcement when each timer elapses. In-process only (no persistence) per the privacy-first stance. Closes #303.

Behaviour

  • set: integer hours / minutes / seconds (any combination, summed), optional label, optional announcement (LLM-localised spoken text). Returns id, label, duration, ETA.
  • list: active timers sorted by ETA.
  • cancel: by timer_id, by label (case-insensitive, matches all), or all=true. Cancelling a non-existent timer is a successful no-op.
  • Hard limits: 24h max per timer, 32 max active timers. Both surface as tool errors.
  • Announcement: the reply LLM passes a pre-localised announcement string. The default announcer fires three best-effort side-effects (stdout banner, desktop face flipped to SPEAKING then restored to IDLE via TTS completion_callback, and TTS speech). English fallback only kicks in when no announcement is passed.

Review fixes (v2 + v3)

  • Face state never sticks on SPEAKING: restore happens via completion_callback and inline if TTS is unavailable.
  • No hardcoded English: spoken text comes from the LLM-supplied announcement.
  • timer added to _DIGEST_SKIP_TOOLS so ids/etas survive verbatim for follow-up cancel calls.
  • label and announcement are whitespace-collapsed; labels also strip , and = to prevent render-payload spoofing.
  • Durations summed as floats and rounded once: 0.5 minutes becomes 30s.
  • threading.Timer.start() now runs outside the manager lock, matching the spec's "expensive work runs unlocked" rule.
  • New timer.spec.md documenting the contract; registered in CLAUDE.md spec table.
  • README gains a hands-free timer example dialogue.

Review fixes (v4: full adversarial-review pass)

  • listener.py: dropped the hardcoded English "Set a 10-minute pasta timer" example from the startup banner.
  • llm_contexts.md: documented _DIGEST_SKIP_TOOLS in section 5 and the timer's place in it.
  • ipc_constants.py: new shared module owning TIMER_ALARM_IPC_PREFIX; the daemon side and the desktop side now import the same constant rather than redefining it twice.
  • app.py _handle_timer_alarm_line: tightened the IPC fast-path from prefix in line to line.lstrip().startswith(prefix) so a quoted log line containing the marker can never reach the parser.
  • timer.py TimerManager.start: floor sub-second durations to 1 so the alarm dialogue / TTS no longer announce "0 seconds elapsed" on fast test timers (the real countdown still uses the float).
  • timer.py AlarmRegistry.start: register the alarm in _alarms BEFORE starting the auto-stop threading.Timer, closing the narrow race where the cap could fire and find nothing to stop.
  • tests/test_timer_alarm_dialog.py: 23 new tests covering parse_alarm_event, the full TimerAlarmDialog handler surface, the _handle_timer_alarm_line dispatch logic, and an AST regression guard that prevents the prefix check from regressing back to substring match. Tests construct the dialogue without a real QApplication to avoid Windows access violations from unrelated TTS-test pollution.

Test plan

  • Unit tests for sanitisers, set/list/cancel paths, expiry, default announcer (TTS + face restore + English fallback), localised announcement passthrough, fractional minutes, hard-limit tool errors (24h + max active), registry, singleton.
  • New tests for parse_alarm_event, TimerAlarmDialog (handle_start / handle_stop / dismiss / render-labels / cap fallbacks), and the _handle_timer_alarm_line dispatch (start / stop / malformed / unknown / substring-spoof rejection).
  • AST regression guard ensuring the IPC prefix check stays anchored to startswith.
  • Full test suite stays green; pluggable TTS provider avoids the heavy daemon import in tests.

@isair isair force-pushed the feat/timer-tool branch from 9a3b271 to 0bef925 Compare May 3, 2026 12:23
isair added a commit that referenced this pull request May 3, 2026
Adversarial review of #304 surfaced two medium issues and several low-
severity follow-ups. All addressed:

- listener.py: drop the hardcoded English "10-minute pasta timer" example
  from the startup banner (CLAUDE.md forbids hardcoded language patterns).
- llm_contexts.md: document _DIGEST_SKIP_TOOLS in section 5 and call out
  that timer rides it alongside getWeather, so the digest skip is no
  longer invisible to the LLM-context map.
- ipc_constants.py: new shared module owning TIMER_ALARM_IPC_PREFIX so
  the daemon side (timer.py) and the desktop side (timer_alarm_dialog.py)
  cannot drift; previous duplicate definitions removed.
- app.py: tighten the IPC fast-path from `prefix in line` to
  `line.lstrip().startswith(prefix)` so a quoted log line containing the
  marker can never reach the parser.
- timer.py (TimerManager.start): floor sub-second durations to 1 so the
  alarm dialogue and TTS no longer announce "0 seconds elapsed" on fast
  test timers (the real countdown still uses the original float).
- timer.py (AlarmRegistry.start): register the alarm in `_alarms` BEFORE
  starting the auto-stop threading.Timer, closing the narrow race where
  the cap could fire and find nothing to stop.
- tests/test_timer_alarm_dialog.py: 23 new tests covering parse_alarm_event,
  the full TimerAlarmDialog handler surface (no QApplication construction,
  to avoid Windows access violations from upstream TTS-test pollution),
  the _handle_timer_alarm_line dispatch logic, and an AST regression guard
  that prevents the prefix check from regressing back to substring match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
isair and others added 11 commits May 3, 2026 14:27
Adds a built-in `timer` tool so Jarvis can set, list, and cancel
countdown timers ("set a timer for 10 minutes", "cancel the pasta
timer", "what timers are running?"). Multiple labelled timers can
run concurrently. When a timer elapses, an in-process TimerManager
announces it audibly via the global TTS engine and visibly by
flipping the desktop face into SPEAKING and printing a stdout
banner; each side-effect is best-effort so headless / eval / test
runs degrade gracefully.

Closes #303

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Restore desktop face to IDLE via TTS completion_callback (with inline
  safety net) so it never sticks on SPEAKING.
- Accept localised `announcement` from the reply LLM; fall back to
  English only when none provided. Keeps language handling on the LLM
  per the project's no-hardcoded-patterns rule.
- Skip tool digesting for `timer` so ids/etas survive verbatim for
  follow-up cancel-by-id calls.
- Sanitise `label` and `announcement` (collapse whitespace) to prevent
  render-payload spoofing.
- Sum durations as floats and round once so 0.5 minutes -> 30s instead
  of truncating to 0.
- Add timer.spec.md and register it in CLAUDE.md.
- Pluggable TTS provider in timer module avoids importing the heavy
  daemon stack from tests.
- Spawn the threading.Timer OS thread outside the manager lock so
  start() honours the spec's "expensive work runs unlocked" rule.
- Round duration once at the manager boundary so a fractional value
  no longer sets duration_sec=0 while the underlying timer still
  fires; sub-second test durations remain valid via the raw float.
- Strip `,` and `=` from labels so they cannot smuggle an extra
  `label=` token into the comma/equals-delimited render payload that
  the reply LLM parses.
- Add tool-error tests for the 24h and max-active hard limits.
- Add a hands-free timer example to the README's example dialogues.
Gemma-class small models routinely append 'the N minutes are up' or
'is complete' right after setting a timer, because their language
prior links 'timer for N minutes' with 'N minutes have elapsed'. The
existing 'Timer status: set' line wasn't load-bearing enough.

Add an explicit, unmistakable instruction to the SET payload that
the timer is still counting down and the assistant must not claim
elapsed state. Covered by a new test that asserts the anchoring
phrases are present in the rendered payload.
When a timer fires, the daemon now also drives a looping alarm tone via
a desktop dialogue (TimerAlarmDialog with QSoundEffect) alongside the
existing stdout banner, face SPEAKING flip, and TTS announcement.

Dismiss paths (any one stops the noise and closes the dialogue):
- Dismiss button or window close on the dialogue
- "stop" voice command (stop tool now silences alarms before exit)
- Setting another timer or cancelling timers via the timer tool
- 30-second hard cap, enforced by both daemon and dialogue independently

Daemon-side AlarmRegistry tracks active alarms and emits __TIMER_ALARM__
IPC lines over stdout (mirroring the diary IPC pattern) so the desktop
app can intercept them on the existing log channel. A short BEL fallback
covers headless callers when no UI is listening.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Use the daemon-supplied auto_stop_sec from the IPC payload instead
  of hardcoding 30s in the dialogue, so the daemon stays the single
  source of truth for the cap.
- Drop the unused TimerAlarmSignals class.
- Multi-alarm dialogue now shows "Multiple timers done" with a
  comma-separated label list rather than reusing the latest label,
  which was misleading.
- Inject the BEL fallback writer instead of monkeypatching real
  sys.stdout in tests, and gate the loop on sys.stdout.isatty() so
  it doesn't run uselessly when the desktop app captures stdout.
- Add tests for the no-TTY skip path and the broken-writer path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…larms

Stop events for individual alarms now re-render the title and body so
the user no longer sees "3 alarms ringing: 'A', 'B', 'C'" after one
of them was dismissed. Per-alarm metadata (label + duration) is now
tracked in _active so the rerender path has everything it needs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adversarial review of #304 surfaced two medium issues and several low-
severity follow-ups. All addressed:

- listener.py: drop the hardcoded English "10-minute pasta timer" example
  from the startup banner (CLAUDE.md forbids hardcoded language patterns).
- llm_contexts.md: document _DIGEST_SKIP_TOOLS in section 5 and call out
  that timer rides it alongside getWeather, so the digest skip is no
  longer invisible to the LLM-context map.
- ipc_constants.py: new shared module owning TIMER_ALARM_IPC_PREFIX so
  the daemon side (timer.py) and the desktop side (timer_alarm_dialog.py)
  cannot drift; previous duplicate definitions removed.
- app.py: tighten the IPC fast-path from `prefix in line` to
  `line.lstrip().startswith(prefix)` so a quoted log line containing the
  marker can never reach the parser.
- timer.py (TimerManager.start): floor sub-second durations to 1 so the
  alarm dialogue and TTS no longer announce "0 seconds elapsed" on fast
  test timers (the real countdown still uses the original float).
- timer.py (AlarmRegistry.start): register the alarm in `_alarms` BEFORE
  starting the auto-stop threading.Timer, closing the narrow race where
  the cap could fire and find nothing to stop.
- tests/test_timer_alarm_dialog.py: 23 new tests covering parse_alarm_event,
  the full TimerAlarmDialog handler surface (no QApplication construction,
  to avoid Windows access violations from upstream TTS-test pollution),
  the _handle_timer_alarm_line dispatch logic, and an AST regression guard
  that prevents the prefix check from regressing back to substring match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@isair isair force-pushed the feat/timer-tool branch from f87ea7c to a9d5c0d Compare May 3, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant