The 60-Second Dwell Gate: Debouncing YouTube Live Viewer Alerts with Sliding-Window Filters

β€’

πŸ€– Read Raw Markdown β€’ πŸ“„ Google Doc (Try: Tools/Audio/Listen to document summary)

Setting the Stage: Context for the Curious Book Reader

A monitoring loop that reacts to every raw boundary crossing eventually trains its operator to ignore it. In the ongoing Honeybot broadcast studio setup, a background ytwatch daemon polls the YouTube live stream every 30 seconds and triggers a local audio chime, a spoken Piper TTS announcement, and an ntfy mobile push whenever concurrent viewers reach three or more. In practice, a single viewer drifting in and out causes the count to bounce between two and three on consecutive polls, turning transient drive-bys into repeated false summons.

This entry captures a focused surgical refinement to scripts/articles/common.py: introducing a 60-second sliding-window minimum filter (YT_VIEWER_ALERT_DWELL = 60). Instead of firing on the first poll that crosses the threshold, _sustained_viewer_count inspects the timestamped polling history and requires the viewer floor to hold at or above the target across three consecutive 30-second samples before ringing the bell. Walk through the probe, patch, git diff receipt, and tmux daemon restart that quieted the see-saw.

TL;DR: A live-stream monitoring daemon that polls YouTube concurrent viewer counts every 30 seconds will generate noisy, repetitive alerts if it fires immediately whenever viewers cross a numeric threshold. Adding a 60-second sliding-window minimum filter (YT_VIEWER_ALERT_DWELL = 60, configurable via PIPULATE_YT_VIEWER_DWELL) requires the viewer count to remain at or above the threshold across consecutive polls for a full minute before triggering local audio chimes, text-to-speech announcements, or mobile push notifications via ntfy.


Technical Journal Entry Begins

πŸ”— Verified Pipulate Commits:

MikeLev.in: Reaching the summon threshold should have a minimum time-delay of about a minute. If they don’t stick around for a minute, I am not interested. It should be like a dampening effect, an 80/20-rule to cut down see-sawing notifications both if that makes sense and if that is the best way.

Catching the Premature Summon in the Logs

From typing ytlog:

πŸ“‘ YouTube Watch: LIVE=yes | video=JYa5jrSi4FU | viewers=2 (alert>=3) | chat_items=0 | daemon=RUNNING | title='When t he Sheet Music Jams the Player Piano: Disarming Backtick Cues, Right-Split Unpacking, and...'
πŸ”­ Watching https://www.youtube.com/@MikeLevin/live every 30s...
πŸ’‘ [HOW TO EXIT TMUX WITHOUT STOPPING]: Press 'q' OR press Ctrl+b, release, then press d
πŸ‘€ [VIEWER] 3 watching now (min=3) β€” When the Sheet Music Jams the Player Piano: Disarming Backtick Cues, Right-Split Unpacking, and...
πŸ”” Sent ntfy push (HTTP 200 -> pipulate-hb-e12...): [πŸ‘€ Honeybot Live Viewer] 3 watching now (min=3) β€” When the Sheet Music Jams the Player Piano: Disarming Backtick Cues, Right-Split Unpacking, and...

Gemini 4 Argon High: Context verified.

TL;DR: Add a 60-second sliding-window minimum filter (YT_VIEWER_ALERT_DWELL = 60, overridable via PIPULATE_YT_VIEWER_DWELL) to run_youtube_watch in scripts/articles/common.py. A viewer count at or above YT_VIEWER_ALERT_MIN (or a new peak) must hold continuously across polls for at least 60 seconds before ringing the local chime, speaking via Piper TTS, or pushing to ntfy.

Designing the 60-Second Sliding-Window Gate

Raw Viewers:   2 ──► 3 ──► 2          2 ──► 3 ──► 3 ──► 3
                     β”‚     β”‚                β”‚     β”‚     β”‚
Dwell Clock:        0s   reset             0s   30s   60s >= DWELL
                     β”‚     β”‚                            β”‚
Output:           [⏳ hold] [πŸ’¨ suppressed]          [πŸ‘€ ALERT + πŸ”” ntfy]

Why This Stops See-Sawing

  1. 1-Minute Sustain Gate (YT_VIEWER_ALERT_DWELL = 60): On a 30-second poll interval, a jump from 2 -> 3 starts a dwell window (⏳ [VIEWER DWELL]) without firing audio or ntfy. Only when _sustained_viewer_count confirms the minimum viewer count over the full 60-second window is >= 3 (3 consecutive polls: 0s, 30s, 60s) does the summon fire.
  2. Bounce Suppression: If viewers drop back below 3 before 60 seconds elapse, the window clears (πŸ’¨ [VIEWER DWELL] ... notification suppressed) and nothing is sent.
  3. Peak Dampening: Once alerted in a contiguous run (alerted_in_run = True), a 1-poll flicker from 3 -> 4 -> 3 also has a 60-second floor of 3, so it will not trigger a false new-peak alert unless 4+ viewers also stay for a full minute (and the 300-second YT_VIEWER_ALERT_COOLDOWN has elapsed).

Verification Probes and Patch Blueprint


(1) PROBES

Probes verify --broadcast watch --dry-run output and test _sustained_viewer_count against a 30-second transient spike versus a 60-second sustained crowd.

(nix) pipulate $

.venv/bin/python scripts/articles/common.py --broadcast watch --dry-run
.venv/bin/python -c "import sys; sys.path.insert(0, 'scripts/articles'); import common; fn = getattr(common, '_sustained_viewer_count', None); print('dwell=', getattr(common, 'YT_VIEWER_ALERT_DWELL', None), 'spike_30s=', fn([(0.0, 3), (30.0, 3)], 30.0, 60) if fn else None, 'sustained_60s=', fn([(0.0, 3), (30.0, 3), (61.0, 3)], 61.0, 60) if fn else None)"

(2) NEXT CONTEXT

Put in context.txt using context or x:

context.txt
scripts/articles/common.py
! .venv/bin/python scripts/articles/common.py --broadcast watch --dry-run
! .venv/bin/python -c "import sys; sys.path.insert(0, 'scripts/articles'); import common; fn = getattr(common, '_sustained_viewer_count', None); print('dwell=', getattr(common, 'YT_VIEWER_ALERT_DWELL', None), 'spike_30s=', fn([(0.0, 3), (30.0, 3)], 30.0, 60) if fn else None, 'sustained_60s=', fn([(0.0, 3), (30.0, 3), (61.0, 3)], 61.0, 60) if fn else None)"

(3) PATCHES

patch, app, d, m

Target: scripts/articles/common.py
[[[SEARCH]]]
YT_VIEWER_ALERT_MIN = int(os.environ.get("PIPULATE_YT_VIEWER_MIN", "3"))
YT_VIEWER_ALERT_COOLDOWN = int(os.environ.get("PIPULATE_YT_VIEWER_COOLDOWN", "300"))  # Seconds between any two viewer alerts
[[[DIVIDER]]]
YT_VIEWER_ALERT_MIN = int(os.environ.get("PIPULATE_YT_VIEWER_MIN", "3"))
YT_VIEWER_ALERT_DWELL = int(os.environ.get("PIPULATE_YT_VIEWER_DWELL", "60"))  # Seconds viewers must stay >= min before alerting
YT_VIEWER_ALERT_COOLDOWN = int(os.environ.get("PIPULATE_YT_VIEWER_COOLDOWN", "300"))  # Seconds between any two viewer alerts
[[[REPLACE]]]

Target: scripts/articles/common.py
[[[SEARCH]]]
def _save_ytwatch_state(state_obj):
    """Persist ytwatch state to ~/.config/pipulate/ytwatch_state.json."""
    try:
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        YTWATCH_STATE_FILE.write_text(json.dumps(state_obj, indent=2) + "\n", encoding="utf-8")
    except OSError:
        pass
[[[DIVIDER]]]
def _save_ytwatch_state(state_obj):
    """Persist ytwatch state to ~/.config/pipulate/ytwatch_state.json."""
    try:
        CONFIG_DIR.mkdir(parents=True, exist_ok=True)
        YTWATCH_STATE_FILE.write_text(json.dumps(state_obj, indent=2) + "\n", encoding="utf-8")
    except OSError:
        pass

def _sustained_viewer_count(dwell_window, now_ts, dwell_secs):
    """Return the viewer count continuously sustained for at least dwell_secs, or 0."""
    if not dwell_window:
        return 0
    if dwell_secs <= 0:
        return int(dwell_window[-1][1])
    run_min = int(dwell_window[-1][1])
    for ts, v in reversed(dwell_window):
        run_min = min(run_min, int(v))
        if (now_ts - float(ts)) >= dwell_secs:
            return run_min
    return 0
[[[REPLACE]]]

Target: scripts/articles/common.py
[[[SEARCH]]]
    if dry_run:
        sample = 'var ytInitialPlayerResponse = {"videoDetails":{"videoId":"test1234567","isLive":true}};'
        parsed = _extract_yt_json(sample, "ytInitialPlayerResponse =")
        ok = (parsed.get("videoDetails") or {}).get("videoId") == "test1234567"
        t_label = "configured" if resolved_topic else "unconfigured"
        print(f"πŸ“‘ [DRY RUN] YouTube Watch: channel={resolved_channel} | min_viewers={YT_VIEWER_ALERT_MIN} | ntfy={t_label} | parser_ok={ok}")
        return 0 if ok else 1
    try:
        state = fetch_youtube_live_state(resolved_channel)
    except Exception as exc:
        print(f"❌ YouTube Watch fetch failed for {resolved_channel}: {exc}")
        return 1
    persisted = _load_ytwatch_state()
    same_video = (persisted.get("video_id") == state["video_id"] and bool(state["video_id"]))
    seen_ids = set(persisted.get("seen_ids") or []) if same_video else set()
    had_prior_snapshot = bool(same_video and persisted.get("seen_ids") is not None)
    prev_viewers = int(persisted.get("concurrent_viewers") or 0) if same_video else state["concurrent_viewers"]
    peak_viewers = int(persisted.get("peak_viewers") or 0) if same_video else state["concurrent_viewers"]
    prev_comments = persisted.get("comment_count") if same_video else state["comment_count"]
    last_viewer_alert = float(persisted.get("last_viewer_alert") or 0.0) if same_video else 0.0
    daemon_running = (
        subprocess.run(
            ["tmux", "has-session", "-t", "ytwatch"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        ).returncode == 0
    )
    c_str = f" | comments={state['comment_count']}" if state["comment_count"] is not None else ""
    d_str = " | daemon=RUNNING" if daemon_running else ""
    print(
        f"πŸ“‘ YouTube Watch: LIVE={'yes' if state['is_live'] else 'no'} | "
        f"video={state['video_id'] or 'NONE'} | viewers={state['concurrent_viewers']} (alert>={YT_VIEWER_ALERT_MIN}) | "
        f"chat_items={len(state['chat_messages'])}{c_str}{d_str} | title={state['title']!r}"
    )
[[[DIVIDER]]]
    if dry_run:
        sample = 'var ytInitialPlayerResponse = {"videoDetails":{"videoId":"test1234567","isLive":true}};'
        parsed = _extract_yt_json(sample, "ytInitialPlayerResponse =")
        ok = (parsed.get("videoDetails") or {}).get("videoId") == "test1234567"
        t_label = "configured" if resolved_topic else "unconfigured"
        print(f"πŸ“‘ [DRY RUN] YouTube Watch: channel={resolved_channel} | min_viewers={YT_VIEWER_ALERT_MIN} | dwell={YT_VIEWER_ALERT_DWELL}s | ntfy={t_label} | parser_ok={ok}")
        return 0 if ok else 1
    try:
        state = fetch_youtube_live_state(resolved_channel)
    except Exception as exc:
        print(f"❌ YouTube Watch fetch failed for {resolved_channel}: {exc}")
        return 1
    persisted = _load_ytwatch_state()
    same_video = (persisted.get("video_id") == state["video_id"] and bool(state["video_id"]))
    seen_ids = set(persisted.get("seen_ids") or []) if same_video else set()
    had_prior_snapshot = bool(same_video and persisted.get("seen_ids") is not None)
    peak_viewers = int(persisted.get("peak_viewers") or 0) if same_video else 0
    prev_comments = persisted.get("comment_count") if same_video else state["comment_count"]
    last_viewer_alert = float(persisted.get("last_viewer_alert") or 0.0) if same_video else 0.0
    alerted_in_run = bool(persisted.get("alerted_in_run")) if (same_video and state["concurrent_viewers"] >= YT_VIEWER_ALERT_MIN) else False
    daemon_running = (
        subprocess.run(
            ["tmux", "has-session", "-t", "ytwatch"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        ).returncode == 0
    )
    c_str = f" | comments={state['comment_count']}" if state["comment_count"] is not None else ""
    d_str = " | daemon=RUNNING" if daemon_running else ""
    print(
        f"πŸ“‘ YouTube Watch: LIVE={'yes' if state['is_live'] else 'no'} | "
        f"video={state['video_id'] or 'NONE'} | viewers={state['concurrent_viewers']} (alert>={YT_VIEWER_ALERT_MIN} for {YT_VIEWER_ALERT_DWELL}s) | "
        f"chat_items={len(state['chat_messages'])}{c_str}{d_str} | title={state['title']!r}"
    )
[[[REPLACE]]]

Target: scripts/articles/common.py
[[[SEARCH]]]
        _save_ytwatch_state({
            "video_id": state["video_id"],
            "concurrent_viewers": state["concurrent_viewers"],
            "peak_viewers": max(peak_viewers, state["concurrent_viewers"]),
            "comment_count": state["comment_count"],
            "last_viewer_alert": last_viewer_alert,
            "seen_ids": all_ids,
            "updated_at": int(time.time()),
        })
        return 0
    for m in state["chat_messages"]:
        seen_ids.add(m["id"])
    _save_ytwatch_state({
        "video_id": state["video_id"],
        "concurrent_viewers": state["concurrent_viewers"],
        "peak_viewers": max(peak_viewers, state["concurrent_viewers"]),
        "comment_count": state["comment_count"],
        "last_viewer_alert": last_viewer_alert,
        "seen_ids": list(seen_ids)[-500:],
        "updated_at": int(time.time()),
    })
    print(f"πŸ”­ Watching {resolved_channel} every {interval}s...")
    print("πŸ’‘ [HOW TO EXIT TMUX WITHOUT STOPPING]: Press 'q' OR press Ctrl+b, release, then press d")
    try:
        while True:
            time.sleep(max(5, int(interval)))
            try:
                state = fetch_youtube_live_state(resolved_channel)
            except Exception as exc:
                print(f"⚠️ Poll error: {exc}")
                continue
            viewers = state["concurrent_viewers"]
            now = time.time()
            cooldown_ok = (now - last_viewer_alert) > YT_VIEWER_ALERT_COOLDOWN
            crossed_min = prev_viewers < YT_VIEWER_ALERT_MIN and viewers >= YT_VIEWER_ALERT_MIN
            new_peak = viewers >= YT_VIEWER_ALERT_MIN and viewers > peak_viewers
            if (crossed_min or new_peak) and cooldown_ok:
                peak_viewers = max(peak_viewers, viewers)
                last_viewer_alert = now
                alert_msg = f"{viewers} watching now (min={YT_VIEWER_ALERT_MIN}) β€” {state['title']}"
                print(f"πŸ‘€ [VIEWER] {alert_msg}", flush=True)
                spoken_v = SPOKEN_ALERT_TABLE["viewers_threshold"].format(
                    count=viewers, min_viewers=YT_VIEWER_ALERT_MIN
                )
                _play_local_ding(ding, spoken_text=spoken_v)
                if resolved_topic:
                    send_ntfy_notification(
                        alert_msg,
                        title="πŸ‘€ Honeybot Live Viewer",
                        priority="default",
                        tags="eyes",
                        topic=resolved_topic,
                    )
            prev_viewers = viewers
[[[DIVIDER]]]
        _save_ytwatch_state({
            "video_id": state["video_id"],
            "concurrent_viewers": state["concurrent_viewers"],
            "peak_viewers": peak_viewers,
            "alerted_in_run": alerted_in_run,
            "comment_count": state["comment_count"],
            "last_viewer_alert": last_viewer_alert,
            "seen_ids": all_ids,
            "updated_at": int(time.time()),
        })
        return 0
    for m in state["chat_messages"]:
        seen_ids.add(m["id"])
    init_ts = time.time()
    dwell_window = [(init_ts, state["concurrent_viewers"])] if state["concurrent_viewers"] >= YT_VIEWER_ALERT_MIN else []
    _save_ytwatch_state({
        "video_id": state["video_id"],
        "concurrent_viewers": state["concurrent_viewers"],
        "peak_viewers": peak_viewers,
        "alerted_in_run": alerted_in_run,
        "comment_count": state["comment_count"],
        "last_viewer_alert": last_viewer_alert,
        "seen_ids": list(seen_ids)[-500:],
        "updated_at": int(init_ts),
    })
    print(f"πŸ”­ Watching {resolved_channel} every {interval}s (dwell={YT_VIEWER_ALERT_DWELL}s)...")
    print("πŸ’‘ [HOW TO EXIT TMUX WITHOUT STOPPING]: Press 'q' OR press Ctrl+b, release, then press d")
    try:
        while True:
            time.sleep(max(5, int(interval)))
            poll_ts = time.time()
            try:
                state = fetch_youtube_live_state(resolved_channel)
            except Exception as exc:
                print(f"⚠️ Poll error: {exc}")
                continue
            viewers = state["concurrent_viewers"]
            now = time.time()
            if viewers >= YT_VIEWER_ALERT_MIN:
                dwell_window.append((poll_ts, viewers))
                cutoff_ts = poll_ts - max(YT_VIEWER_ALERT_DWELL * 3, int(interval) * 4)
                dwell_window = [(ts, v) for ts, v in dwell_window if ts >= cutoff_ts]
                if len(dwell_window) == 1 and YT_VIEWER_ALERT_DWELL > 0:
                    print(
                        f"⏳ [VIEWER DWELL] {viewers} watching (min={YT_VIEWER_ALERT_MIN}) β€” "
                        f"holding {YT_VIEWER_ALERT_DWELL}s to confirm they stick around...",
                        flush=True,
                    )
                sustained = _sustained_viewer_count(dwell_window, poll_ts, YT_VIEWER_ALERT_DWELL)
                cooldown_ok = (now - last_viewer_alert) > YT_VIEWER_ALERT_COOLDOWN
                if sustained >= YT_VIEWER_ALERT_MIN and (not alerted_in_run or sustained > peak_viewers) and cooldown_ok:
                    alerted_in_run = True
                    peak_viewers = max(peak_viewers, sustained)
                    last_viewer_alert = now
                    dwell_elapsed = int(round(poll_ts - dwell_window[0][0]))
                    alert_msg = (
                        f"{viewers} watching now (sustained >={dwell_elapsed}s, min={YT_VIEWER_ALERT_MIN}) β€” "
                        f"{state['title']}"
                    )
                    print(f"πŸ‘€ [VIEWER] {alert_msg}", flush=True)
                    spoken_v = SPOKEN_ALERT_TABLE["viewers_threshold"].format(
                        count=viewers, min_viewers=YT_VIEWER_ALERT_MIN
                    )
                    _play_local_ding(ding, spoken_text=spoken_v)
                    if resolved_topic:
                        send_ntfy_notification(
                            alert_msg,
                            title="πŸ‘€ Honeybot Live Viewer",
                            priority="default",
                            tags="eyes",
                            topic=resolved_topic,
                        )
            else:
                if dwell_window and not alerted_in_run and YT_VIEWER_ALERT_DWELL > 0:
                    dwell_elapsed = int(round(poll_ts - dwell_window[0][0]))
                    print(
                        f"πŸ’¨ [VIEWER DWELL] Dropped to {viewers} after {dwell_elapsed}s "
                        f"(<{YT_VIEWER_ALERT_DWELL}s dwell) β€” notification suppressed.",
                        flush=True,
                    )
                dwell_window.clear()
                alerted_in_run = False
[[[REPLACE]]]

Target: scripts/articles/common.py
[[[SEARCH]]]
            _save_ytwatch_state({
                "video_id": state["video_id"],
                "concurrent_viewers": viewers,
                "peak_viewers": peak_viewers,
                "comment_count": cur_comments,
                "last_viewer_alert": last_viewer_alert,
                "seen_ids": list(seen_ids)[-500:],
                "updated_at": int(now),
            })
[[[DIVIDER]]]
            _save_ytwatch_state({
                "video_id": state["video_id"],
                "concurrent_viewers": viewers,
                "peak_viewers": peak_viewers,
                "alerted_in_run": alerted_in_run,
                "comment_count": cur_comments,
                "last_viewer_alert": last_viewer_alert,
                "seen_ids": list(seen_ids)[-500:],
                "updated_at": int(now),
            })
[[[REPLACE]]]

Ignition β€” restart the running ytwatch tmux daemon so it loads the patched common.py:

(nix) pipulate $

ytwatch restart

(4) PROMPT

Use prompt or p to create prompt.md from copy:

Verify the AFTER receipts for the 60-second `YT_VIEWER_ALERT_DWELL` dampening filter in `scripts/articles/common.py` and confirm `ytwatch` status.

(5) EXTERNAL DELIVERABLES

No external deliverables required. YT_VIEWER_ALERT_DWELL defaults to 60 seconds and can be tuned anytime via PIPULATE_YT_VIEWER_DWELL in ~/.config/pipulate/.env.

Applying the Patch and Inspecting the Diff Receipt

MikeLev.in:

THE AI-EDIT METHOD

Same commands, run twice, one change between them. Where the readings differ is what the change did; the diff in the middle is the receipt.

1: BEFORE (PROBE):

On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

GIT repo clean. Take BEFORE reading, make CHANGE, record AFTER diff.
(nix) pipulate $ .venv/bin/python scripts/articles/common.py --broadcast watch --dry-run
.venv/bin/python -c "import sys; sys.path.insert(0, 'scripts/articles'); import common; fn = getattr(common, '_sustained_viewer_count', None); print('dwell=', getattr(common, 'YT_VIEWER_ALERT_DWELL', None), 'spike_30s=', fn([(0.0, 3), (30.0, 3)], 30.0, 60) if fn else None, 'sustained_60s=', fn([(0.0, 3), (30.0, 3), (61.0, 3)], 61.0, 60) if fn else None)"
πŸ“‘ [DRY RUN] YouTube Watch: channel=https://www.youtube.com/@MikeLevin/live | min_viewers=3 | ntfy=configured | parser_ok=True
dwell= None spike_30s= None sustained_60s= None
(nix) pipulate $ 

2: AFTER (NEXT CONTEXT):

# Content 1
# context.txt
# flake.nix
# 
# # HOME-HOSTED (NOT BEHIND CDN) NGINX WEBSERVER HONEYBOT AI OBSERVATORY
# nixops.sh
# remotes/honeybot/hooks/post-receive
# remotes/honeybot/nixos/configuration.nix
# remotes/honeybot/scripts/score.py
# remotes/honeybot/scripts/card.py
# remotes/honeybot/scripts/forest.py
# remotes/honeybot/scripts/test_forest.py
# remotes/honeybot/scripts/logs.py
# remotes/honeybot/scripts/content_loader.py
# remotes/honeybot/scripts/db.py
# 
# scripts/articles/common.py

# Context 2
context.txt
scripts/articles/common.py
! .venv/bin/python scripts/articles/common.py --broadcast watch --dry-run
! .venv/bin/python -c "import sys; sys.path.insert(0, 'scripts/articles'); import common; fn = getattr(common, '_sustained_viewer_count', None); print('dwell=', getattr(common, 'YT_VIEWER_ALERT_DWELL', None), 'spike_30s=', fn([(0.0, 3), (30.0, 3)], 30.0, 60) if fn else None, 'sustained_60s=', fn([(0.0, 3), (30.0, 3), (61.0, 3)], 61.0, 60) if fn else None)"

3: CHANGE (PATCHES):

On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

GIT repo clean. Take BEFORE reading, make CHANGE, record AFTER diff.
(nix) pipulate $ patch
(nix) pipulate $ app
βœ… DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
βœ… DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
βœ… DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
βœ… DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
βœ… DETERMINISTIC PATCH APPLIED: Successfully mutated 'scripts/articles/common.py'.
(nix) pipulate $ d
diff --git a/scripts/articles/common.py b/scripts/articles/common.py
index 4b29712..923ca17 100644
--- a/scripts/articles/common.py
+++ b/scripts/articles/common.py
@@ -17,6 +17,7 @@ LAST_PUBLISHED_FILE = CONFIG_DIR / "last_published.json"
 # Minimum simultaneous viewers required to ring the bell + send an ntfy push
 # (2 = viewer + their trusted advisor; lone 1-viewer drive-bys stay silent unless they comment)
 YT_VIEWER_ALERT_MIN = int(os.environ.get("PIPULATE_YT_VIEWER_MIN", "3"))
+YT_VIEWER_ALERT_DWELL = int(os.environ.get("PIPULATE_YT_VIEWER_DWELL", "60"))  # Seconds viewers must stay >= min before alerting
 YT_VIEWER_ALERT_COOLDOWN = int(os.environ.get("PIPULATE_YT_VIEWER_COOLDOWN", "300"))  # Seconds between any two viewer alerts
 
 DEFAULT_POSTS_DIR = Path(
@@ -1758,6 +1759,20 @@ def _save_ytwatch_state(state_obj):
         pass
 
 
+def _sustained_viewer_count(dwell_window, now_ts, dwell_secs):
+    """Return the viewer count continuously sustained for at least dwell_secs, or 0."""
+    if not dwell_window:
+        return 0
+    if dwell_secs <= 0:
+        return int(dwell_window[-1][1])
+    run_min = int(dwell_window[-1][1])
+    for ts, v in reversed(dwell_window):
+        run_min = min(run_min, int(v))
+        if (now_ts - float(ts)) >= dwell_secs:
+            return run_min
+    return 0
+
+
 SPOKEN_ALERT_TABLE = {
     "viewers_threshold": "Web Bot Observatory alert. YouTube concurrent viewers reached {count}, meeting the summon threshold of {min_viewers}.",
     "viewers_above_2": "Web Bot Observatory alert. YouTube concurrent viewers climbed above two, now at {count}.",
@@ -2006,7 +2021,7 @@ def run_youtube_watch(
         parsed = _extract_yt_json(sample, "ytInitialPlayerResponse =")
         ok = (parsed.get("videoDetails") or {}).get("videoId") == "test1234567"
         t_label = "configured" if resolved_topic else "unconfigured"
-        print(f"πŸ“‘ [DRY RUN] YouTube Watch: channel={resolved_channel} | min_viewers={YT_VIEWER_ALERT_MIN} | ntfy={t_label} | parser_ok={ok}")
+        print(f"πŸ“‘ [DRY RUN] YouTube Watch: channel={resolved_channel} | min_viewers={YT_VIEWER_ALERT_MIN} | dwell={YT_VIEWER_ALERT_DWELL}s | ntfy={t_label} | parser_ok={ok}")
         return 0 if ok else 1
     try:
         state = fetch_youtube_live_state(resolved_channel)
@@ -2017,10 +2032,10 @@ def run_youtube_watch(
     same_video = (persisted.get("video_id") == state["video_id"] and bool(state["video_id"]))
     seen_ids = set(persisted.get("seen_ids") or []) if same_video else set()
     had_prior_snapshot = bool(same_video and persisted.get("seen_ids") is not None)
-    prev_viewers = int(persisted.get("concurrent_viewers") or 0) if same_video else state["concurrent_viewers"]
-    peak_viewers = int(persisted.get("peak_viewers") or 0) if same_video else state["concurrent_viewers"]
+    peak_viewers = int(persisted.get("peak_viewers") or 0) if same_video else 0
     prev_comments = persisted.get("comment_count") if same_video else state["comment_count"]
     last_viewer_alert = float(persisted.get("last_viewer_alert") or 0.0) if same_video else 0.0
+    alerted_in_run = bool(persisted.get("alerted_in_run")) if (same_video and state["concurrent_viewers"] >= YT_VIEWER_ALERT_MIN) else False
     daemon_running = (
         subprocess.run(
             ["tmux", "has-session", "-t", "ytwatch"],
@@ -2032,7 +2047,7 @@ def run_youtube_watch(
     d_str = " | daemon=RUNNING" if daemon_running else ""
     print(
         f"πŸ“‘ YouTube Watch: LIVE={'yes' if state['is_live'] else 'no'} | "
-        f"video={state['video_id'] or 'NONE'} | viewers={state['concurrent_viewers']} (alert>={YT_VIEWER_ALERT_MIN}) | "
+        f"video={state['video_id'] or 'NONE'} | viewers={state['concurrent_viewers']} (alert>={YT_VIEWER_ALERT_MIN} for {YT_VIEWER_ALERT_DWELL}s) | "
         f"chat_items={len(state['chat_messages'])}{c_str}{d_str} | title={state['title']!r}"
     )
     if not loop:
@@ -2059,7 +2074,8 @@ def run_youtube_watch(
         _save_ytwatch_state({
             "video_id": state["video_id"],
             "concurrent_viewers": state["concurrent_viewers"],
-            "peak_viewers": max(peak_viewers, state["concurrent_viewers"]),
+            "peak_viewers": peak_viewers,
+            "alerted_in_run": alerted_in_run,
             "comment_count": state["comment_count"],
             "last_viewer_alert": last_viewer_alert,
             "seen_ids": all_ids,
@@ -2068,20 +2084,24 @@ def run_youtube_watch(
         return 0
     for m in state["chat_messages"]:
         seen_ids.add(m["id"])
+    init_ts = time.time()
+    dwell_window = [(init_ts, state["concurrent_viewers"])] if state["concurrent_viewers"] >= YT_VIEWER_ALERT_MIN else []
     _save_ytwatch_state({
         "video_id": state["video_id"],
         "concurrent_viewers": state["concurrent_viewers"],
-        "peak_viewers": max(peak_viewers, state["concurrent_viewers"]),
+        "peak_viewers": peak_viewers,
+        "alerted_in_run": alerted_in_run,
         "comment_count": state["comment_count"],
         "last_viewer_alert": last_viewer_alert,
         "seen_ids": list(seen_ids)[-500:],
-        "updated_at": int(time.time()),
+        "updated_at": int(init_ts),
     })
-    print(f"πŸ”­ Watching {resolved_channel} every {interval}s...")
+    print(f"πŸ”­ Watching {resolved_channel} every {interval}s (dwell={YT_VIEWER_ALERT_DWELL}s)...")
     print("πŸ’‘ [HOW TO EXIT TMUX WITHOUT STOPPING]: Press 'q' OR press Ctrl+b, release, then press d")
     try:
         while True:
             time.sleep(max(5, int(interval)))
+            poll_ts = time.time()
             try:
                 state = fetch_youtube_live_state(resolved_channel)
             except Exception as exc:
@@ -2089,27 +2109,50 @@ def run_youtube_watch(
                 continue
             viewers = state["concurrent_viewers"]
             now = time.time()
-            cooldown_ok = (now - last_viewer_alert) > YT_VIEWER_ALERT_COOLDOWN
-            crossed_min = prev_viewers < YT_VIEWER_ALERT_MIN and viewers >= YT_VIEWER_ALERT_MIN
-            new_peak = viewers >= YT_VIEWER_ALERT_MIN and viewers > peak_viewers
-            if (crossed_min or new_peak) and cooldown_ok:
-                peak_viewers = max(peak_viewers, viewers)
-                last_viewer_alert = now
-                alert_msg = f"{viewers} watching now (min={YT_VIEWER_ALERT_MIN}) β€” {state['title']}"
-                print(f"πŸ‘€ [VIEWER] {alert_msg}", flush=True)
-                spoken_v = SPOKEN_ALERT_TABLE["viewers_threshold"].format(
-                    count=viewers, min_viewers=YT_VIEWER_ALERT_MIN
-                )
-                _play_local_ding(ding, spoken_text=spoken_v)
-                if resolved_topic:
-                    send_ntfy_notification(
-                        alert_msg,
-                        title="πŸ‘€ Honeybot Live Viewer",
-                        priority="default",
-                        tags="eyes",
-                        topic=resolved_topic,
+            if viewers >= YT_VIEWER_ALERT_MIN:
+                dwell_window.append((poll_ts, viewers))
+                cutoff_ts = poll_ts - max(YT_VIEWER_ALERT_DWELL * 3, int(interval) * 4)
+                dwell_window = [(ts, v) for ts, v in dwell_window if ts >= cutoff_ts]
+                if len(dwell_window) == 1 and YT_VIEWER_ALERT_DWELL > 0:
+                    print(
+                        f"⏳ [VIEWER DWELL] {viewers} watching (min={YT_VIEWER_ALERT_MIN}) β€” "
+                        f"holding {YT_VIEWER_ALERT_DWELL}s to confirm they stick around...",
+                        flush=True,
+                    )
+                sustained = _sustained_viewer_count(dwell_window, poll_ts, YT_VIEWER_ALERT_DWELL)
+                cooldown_ok = (now - last_viewer_alert) > YT_VIEWER_ALERT_COOLDOWN
+                if sustained >= YT_VIEWER_ALERT_MIN and (not alerted_in_run or sustained > peak_viewers) and cooldown_ok:
+                    alerted_in_run = True
+                    peak_viewers = max(peak_viewers, sustained)
+                    last_viewer_alert = now
+                    dwell_elapsed = int(round(poll_ts - dwell_window[0][0]))
+                    alert_msg = (
+                        f"{viewers} watching now (sustained >={dwell_elapsed}s, min={YT_VIEWER_ALERT_MIN}) β€” "
+                        f"{state['title']}"
+                    )
+                    print(f"πŸ‘€ [VIEWER] {alert_msg}", flush=True)
+                    spoken_v = SPOKEN_ALERT_TABLE["viewers_threshold"].format(
+                        count=viewers, min_viewers=YT_VIEWER_ALERT_MIN
+                    )
+                    _play_local_ding(ding, spoken_text=spoken_v)
+                    if resolved_topic:
+                        send_ntfy_notification(
+                            alert_msg,
+                            title="πŸ‘€ Honeybot Live Viewer",
+                            priority="default",
+                            tags="eyes",
+                            topic=resolved_topic,
+                        )
+            else:
+                if dwell_window and not alerted_in_run and YT_VIEWER_ALERT_DWELL > 0:
+                    dwell_elapsed = int(round(poll_ts - dwell_window[0][0]))
+                    print(
+                        f"πŸ’¨ [VIEWER DWELL] Dropped to {viewers} after {dwell_elapsed}s "
+                        f"(<{YT_VIEWER_ALERT_DWELL}s dwell) β€” notification suppressed.",
+                        flush=True,
                     )
-            prev_viewers = viewers
+                dwell_window.clear()
+                alerted_in_run = False
             for msg in state["chat_messages"]:
                 if msg["id"] in seen_ids:
                     continue
@@ -2186,6 +2229,7 @@ def run_youtube_watch(
                 "video_id": state["video_id"],
                 "concurrent_viewers": viewers,
                 "peak_viewers": peak_viewers,
+                "alerted_in_run": alerted_in_run,
                 "comment_count": cur_comments,
                 "last_viewer_alert": last_viewer_alert,
                 "seen_ids": list(seen_ids)[-500:],
(nix) pipulate $ m
πŸ“ Committing: chore: Update YouTube viewer alert dwell duration
[main 43617c3] chore: Update YouTube viewer alert dwell duration
 1 file changed, 72 insertions(+), 28 deletions(-)
(nix) pipulate $ git push
Enumerating objects: 9, done.
Counting objects: 100% (9/9), done.
Delta compression using up to 48 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 1.61 KiB | 825.00 KiB/s, done.
Total 5 (delta 4), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (4/4), completed with 4 local objects.
To github.com:pipulate/pipulate.git
   0f50624..43617c3  main -> main
(nix) pipulate $ 

Ignition: often β€œexit”, β€œnix develop”. Instructions may vary.

Ignition is restarting with ytwatch restart. Okay.

Restarting the Tmux Daemon in Live Mode

(sys) ~ $ p
(sys) pipulate $ ndq
(nix) pipulate $ ytlog
πŸ”­ 'ytwatch' daemon was not running β€” starting it now...
πŸ”­ Started background 'ytwatch' daemon in tmux (polling every 30s).
   β€’ Watch live stream: ytwatch log   (or `ytlog` β€” press `q` or `Ctrl+b d` to exit)
   β€’ Check status:      ytwatch status
   β€’ Stop daemon:       ytwatch stop
╔══════════════════════════════════════════════════════════════════════╗
β•‘ πŸ”­ ENTERING YTWATCH LIVE TMUX MONITOR (Read-Only Safe Mode)          β•‘
β•‘ β€’ Muscle-Memory Detach : Press Ctrl+b, release both, then press d    β•‘
β•‘ β€’ Quick Escape Hatch   : Press q (detaches & keeps daemon running!)  β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
[detached (from session ytwatch)]
βœ… Detached from 'ytwatch' monitor (daemon is STILL RUNNING in background).
(nix) pipulate $ ytlog
╔══════════════════════════════════════════════════════════════════════╗
β•‘ πŸ”­ ENTERING YTWATCH LIVE TMUX MONITOR (Read-Only Safe Mode)          β•‘
β•‘ β€’ Muscle-Memory Detach : Press Ctrl+b, release both, then press d    β•‘
β•‘ β€’ Quick Escape Hatch   : Press q (detaches & keeps daemon running!)  β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
[detached (from session ytwatch)]
βœ… Detached from 'ytwatch' monitor (daemon is STILL RUNNING in background).
(nix) pipulate $ ytwatch restart
πŸ”­ Started background 'ytwatch' daemon in tmux (polling every 30s).
   β€’ Watch live stream: ytwatch log   (or `ytlog` β€” press `q` or `Ctrl+b d` to exit)
   β€’ Check status:      ytwatch status
   β€’ Stop daemon:       ytwatch stop
(nix) pipulate $ ytlog
╔══════════════════════════════════════════════════════════════════════╗
β•‘ πŸ”­ ENTERING YTWATCH LIVE TMUX MONITOR (Read-Only Safe Mode)          β•‘
β•‘ β€’ Muscle-Memory Detach : Press Ctrl+b, release both, then press d    β•‘
β•‘ β€’ Quick Escape Hatch   : Press q (detaches & keeps daemon running!)  β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
[detached (from session ytwatch)]
βœ… Detached from 'ytwatch' monitor (daemon is STILL RUNNING in background).
(nix) pipulate $ 

That looks like it worked. I guess I’ll just wait and hear.

4: PROMPT:

Verify the AFTER receipts for the 60-second YT_VIEWER_ALERT_DWELL dampening filter in scripts/articles/common.py and confirm ytwatch status.

5: DELIVERABLE:

This seems done. Okay, this was a quick one-off but even these deserve their own mini-streams. I just have to make sure the previous article had at least one full read-through storytime stream. Reducing notification noise on this very closed feedback loop seems on-brand and something that shouldn’t be a gap in the continuous record of this project.

Sealing the Session at the Notary Beat

Hop off the ride. This ride’s stated goal is reached – dismount. This is the NOTARY BEAT: the ride ends here, is witnessed here, and is sealed here. Answer all seven beats, briefly:

  1. TL;DR: a short, dry, neutral abstract for the TOP of the published article – written for an unfamiliar reader or AI summarizer who has never seen this system. No hype, no insider handles unexplained.
  2. VERIFY: restate the goal from the top of this article and confirm (or deny) it was met, citing THIS compile’s receipts, not memory. Name any ignition this ride required that never fired – an AFTER tap taken without one is a stale BEFORE wearing the AFTER’s label.
  3. BANK: name everything that graduates – rule, earmark, todo, pin – as SEARCH/REPLACE patch cars against raw source present in THIS context (Target line inside the fence, one car per commit story), deletions included. A β€˜paste-ready’ line with no Target and no SEARCH anchor is not banked; it is a hand edit the operator will not make. These BANK cars are the ONLY patches a dismount emits.
  4. DANGLING: what carries forward unbanked? One line each, no essays.
  5. SEED: the context.txt lines (and TODO_SLUGS if narrative context is needed) for the next ride’s first compile.
  6. CLOSING: a closing summary for the BOTTOM of the article – the final take-away, tied to the book’s larger arc where it fits naturally, never forced. Storytelling over inventory.
  7. NOTARIZE: read the exact Deed line from THIS compile’s final QA ARCHIVE SEAL footer, outside the payload, not a quoted older footer or a newest-file guess. It names the input archive, not this later response or the generated Jekyll post. State verification separately: only a verifier receipt naming that archive supports a verified claim; otherwise say not independently verified here. Count this Manifest’s live-command receipts, including failures. Quote only recorded digests, sizes and dates, naming their source; filesystem mtime and fixed ZIP dates are not command timestamps. Do not demand absent wc -c or dated-receipt fields. If the footer is missing, say so; otherwise close with its exact Deed line.

FINALITY: after beat 6, this discussion is CLOSED. Emit NO five-car train, NO probes, NO patches beyond the BANK cars of beat 2, and NO next-turn prompt beyond the SEED lines in beat 4. Any reader or model encountering this article later should treat it as a finished, notarized document – an archive entry, not an open thread.

Gemini 4 Argon High:

1. VERIFY

  • Stated Goal: Add a ~60-second minimum time-delay (dwell dampening filter) to the YouTube viewer summon threshold (YT_VIEWER_ALERT_MIN = 3) in scripts/articles/common.py so brief viewer spikes do not trigger see-sawing notifications unless the viewers stay for at least a minute.
  • Status: Met in code and ignited; live AFTER ! compile receipt skipped.
  • BEFORE Receipt: Hand-run probe at (nix) pipulate $ confirmed dwell= None spike_30s= None sustained_60s= None and dry-run output without dwell.
  • CHANGE Receipt: All five apply.py blocks applied cleanly to scripts/articles/common.py (4b29712..923ca17), adding YT_VIEWER_ALERT_DWELL = 60, _sustained_viewer_count(), and the dwell_window sustain/suppression gates in run_youtube_watch(), committed as [main 43617c3] chore: Update YouTube viewer alert dwell duration (1 file changed, 72 insertions(+), 28 deletions(-)) and pushed (0f50624..43617c3).
  • Ignition Receipt: ytwatch restart was executed at (nix) pipulate $, restarting the background tmux session (πŸ”­ Started background 'ytwatch' daemon in tmux (polling every 30s).).
  • AFTER Probe Note: Because this dismount turn was pasted directly into the conversation without running a second prompt_foo.py compile, the two ! probe lines staged in context.txt were not executed as LIVE COMMAND RECEIPTS in a new Manifest.

2. BANK

No BANK patch cars emitted. scripts/articles/common.py is already committed and pushed in 43617c3, and foo_files.py is not present in raw source in this context.


3. DANGLING

  • Observe ⏳ [VIEWER DWELL] and πŸ’¨ [VIEWER DWELL] in ytlog under live stream traffic during the next brief viewer bounce vs. 60-second sustained crowd.

4. SEED

context.txt
flake.nix
nixops.sh
remotes/honeybot/hooks/post-receive
remotes/honeybot/nixos/configuration.nix
remotes/honeybot/scripts/score.py
remotes/honeybot/scripts/card.py
remotes/honeybot/scripts/forest.py
remotes/honeybot/scripts/test_forest.py
remotes/honeybot/scripts/logs.py
remotes/honeybot/scripts/content_loader.py
remotes/honeybot/scripts/db.py
scripts/articles/common.py

5. CLOSING

An automated observatory only works as a human instrument if its alarms respect human attention. When a sensor polls a noisy boundaryβ€”like concurrent viewers drifting between two and three on a 24/7 broadcastβ€”an instantaneous trigger turns every one-poll flicker into a false summon. Requiring the signal to hold across a 60-second sliding window before ringing the bell separates drive-by noise from genuine company, keeping the operator’s feedback loop calm enough to stay switched on all day.


6. NOTARIZE

  • This Dismount Turn: The === QA ARCHIVE SEAL === footer and Manifest are missing on this final turn because the transcript was sent directly in chat rather than through a fresh prompt_foo.py compile (0 live-command receipts in this turn).
  • Session Opening Compile (Turn 1):
  • Manifest Live-Command Receipts: 0 ! receipts (16 static codebase files totaling 124,418 codebase tokens; Prompt Summary: 133,955 summed tokens, 135,952 verified tokens, 57,784 words, 539,261 chars, 543,679 UTF-8 bytes).
  • Pasted Git Commit Receipt (Turn 2): 43617c3 (0f50624..43617c3 on main, scripts/articles/common.py index 4b29712..923ca17).
  • Verification Status: Not independently verified here (no scripts/foo_cartridge.py receipt was captured in this session).
  • Turn 1 QA Archive Seal Footer: Deed: qamy.ai_274-9c8dfdda.zip (no footer present on Turn 2).

Book Analysis

Ai Editorial Take

What stands out in this small patch is that it implements software contact debouncing for human attention. In digital electronics, a mechanical switch doesn’t close cleanly; its metal contacts bounce for a few milliseconds, and without a Schmitt trigger or a timing capacitor, a microcontroller counts a single button press as a dozen rapid interrupts. A live-stream audience counter behaves the exact same way at the human timescaleβ€”bouncing across the integer boundary of 2 and 3 as browser tabs open, buffer, or close. Crucially, a cooldown timer alone (YT_VIEWER_ALERT_COOLDOWN = 300) only rate-limits the noise after the first false positive has already interrupted the operator. Pairing a trailing cooldown with a leading 60-second sustain gate creates a true low-pass filter on the summon channel, proving that the difference between an annoying script and a trusted ambient instrument is often twenty lines of sliding-window arithmetic.

🐦 X.com Promo Tweet

Brief viewer spikes shouldn't ring the bell. A 60-second sliding-window dwell filter in Python confirms viewers stay before firing TTS or ntfy.
https://mikelev.in/futureproof/dwell-gate-debouncing-ytwatch-viewer-alerts/
#Python #Linux #Automation

🎨 Hero Banner Image Prompt

A wide 16:9 widescreen graphic-novel ink-and-wash illustration set in a deep obsidian-slate (#111111) environment, featuring a precision brass mechanical sliding-window gate and glowing amber hourglass vertically centered in the composition. From the left, jagged cyan telemetry waves representing erratic signal spikes enter a series of three brass chronometer rings; transient spikes dissipate into wisps of silver vapor at the first ring, while a steady, continuous golden beam passes cleanly through all three aligned rings to strike a resonant copper bell on the right, sending warm amber acoustic ripples outward into the dark slate workshop. High-contrast chiaroscuro lighting, crisp architectural linework, and intricate analog-digital instrument textures extend continuously to every edge of the scene. Full-bleed edge-to-edge composition filling 100% of the canvas with no borders, no frames, no matting, no margins, and no text overlays or lettering.

πŸ–ΌοΈ YAML Frontmatter Image Override

image:
  path: /assets/images/dwell-gate-debouncing-ytwatch-viewer-alerts.jpeg
  alt: "A brass mechanical hourglass and sliding-window gate filtering erratic signal spikes before striking a resonant chime."

Title Brainstorm

  • Title Option: The 60-Second Dwell Gate: Debouncing YouTube Live Viewer Alerts with Sliding-Window Filters
    • Filename: dwell-gate-debouncing-ytwatch-viewer-alerts.md
    • Rationale: Directly names the core engineering mechanism (the 60-second dwell gate and sliding-window filter) and connects it to the familiar hardware/software concept of debouncing noisy signals.
  • Title Option: Suppressing the See-Saw: Sliding-Window Dwell Filters for Live Stream Telemetry
    • Filename: sliding-window-dwell-filters-ytwatch-ntfy.md
    • Rationale: Echoes the operator’s exact β€˜see-sawing notifications’ framing while highlighting the concrete Python telemetry and ntfy integration.
  • Title Option: Holding the Bell: Engineering a 60-Second Sustain Filter for YouTube Watch Notifications
    • Filename: holding-the-bell-ytwatch-sustain-filter.md
    • Rationale: Focuses on the human-attention ergonomics of holding an acoustic and mobile summon until attendance is verified across multiple polls.
  • Title Option: Three Polls to Ring: Taming Notification Bounce in Python Polling Daemons
    • Filename: three-polls-to-ring-ytwatch-bounce-suppression.md
    • Rationale: Highlights the discrete sampling math (0s, 30s, 60s polls) that makes the sliding window work in a lightweight polling loop.

Content Potential And Polish

  • Core Strengths:
    • Clear, visual ASCII timing diagram that immediately explains how a 30-second poll interval interacts with a 60-second sustain window.
    • Pure-function design in _sustained_viewer_count(dwell_window, now_ts, dwell_secs), making the time-series minimum check trivial to test via a one-line CLI probe without mocking network calls.
    • Explicit console observability for suppressed events (⏳ [VIEWER DWELL] and πŸ’¨ [VIEWER DWELL]), ensuring that silence on the audio bus is still auditable in the ytlog scrollback.
  • Suggestions For Polish:
    • Run the staged context.txt ! probe commands after applying the patch so the post-change output (dwell= 60 spike_30s= 0 sustained_60s= 3) is captured directly in the transcript alongside the git diff.
    • Note how dwell_window is held in-memory inside the while True loop whereas alerted_in_run is persisted to ytwatch_state.json, explaining what happens across a manual daemon restart mid-stream.

Next Step Prompts

  • Run the one-liner Python probe from context.txt to capture the live AFTER verification receipt for _sustained_viewer_count against the 30-second spike and 60-second sustained test tuples.
  • Review ytlog output after the next live broadcast session to verify how ⏳ [VIEWER DWELL] and πŸ’¨ [VIEWER DWELL] log lines render during real viewer fluctuations.
πŸ”΄ Live 24/7 Web Bot AI Observatory β€” Send this stream to an engineer who can explain the 3 shapes & 2 panels: Like & Follow on YouTube β†—
πŸ’Έ Spending $10,000/mo and up on HTML pre-render? Fully implemented new web standards (RFC 9110 Accept: text/markdown, RFC 9111 Vary: Accept, and RFC 9421 Web Bot Auth) can save you that money. Contact Mike for a consultation (miklevin@gmail.com) β€’ RFC 9110/9111 Edge Advisory β€’ LinkedIn

A brass mechanical hourglass and sliding-window gate filtering erratic signal spikes before striking a resonant chime.