feat(presence): server-side last_seen stamping for non-CC agents - #280
Conversation
Non-CC agents (Codex, GLM) never call the send_heartbeat MCP tool, so their presence showed stale despite active streaming sessions. Stamp agents.last_seen_at server-side on every evidence of a working pipe: broker delivery (inbound + inter-agent), and turn completion/error in both StreamingSession and CodexSession. - Add last_seen_at column + Agent.last_seen_at field + stamp_last_seen() - _agent_presence takes max(server_ts, heartbeat_ts) — heartbeat path preserved so CC agents keep working unchanged - Plumb AgentRegistry into StreamingSession/CodexSession via new `registry` kwarg (mirrors analytics_store pattern) Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Addresses murzik's PR #280 review findings: 1. _agent_presence() status branch previously returned "unknown" for any agent without a heartbeat — even when last_seen_at was recently stamped by server-side activity. Status now falls through to server_ts when no fresher heartbeat exists, using the same online/idle/offline age bands. Heartbeat logic still wins when hb_ts >= server_ts so dead/non-alive heartbeats aren't masked by an older server stamp. 2. codex_session "error" event path now stamps last_seen. Previously only turn.completed and turn.failed stamped, so transport/runtime errors surfaced via the generic error event missed a real liveness signal. Tests: - tests/test_api.py — 5 new presence tests (server-stamped online/idle/ offline, no-stamp-no-hb unknown, heartbeat-wins-when-fresher) - tests/test_codex_session.py — 3 new stamp tests (error event stamps, plus regression guards for turn.completed and turn.failed) 1637 passed, ruff clean. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Review: LGTM ✅ (Pushok)Clean, well-scoped change. What it does: Server-side Key observations:
Tests: 10 new tests across 4 files covering online/idle/offline/unknown/heartbeat-wins, broker delivery positive+negative, and all three codex event types. Ship it. 🚢 — Pushok |
…me, voice, outreach) (#313) * chore(deps): bump actions/checkout from 4 to 6 (#275) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](actions/checkout@v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump actions/setup-node from 4 to 6 (#276) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](actions/setup-node@v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump actions/setup-python from 5 to 6 (#277) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](actions/setup-python@v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the npm-minor-patch group (#278) Bumps the npm-minor-patch group in /frontend-svelte with 3 updates: [svelte-spa-router](https://github.com/ItalyPaleAle/svelte-spa-router), [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `svelte-spa-router` from 5.0.0 to 5.0.1 - [Release notes](https://github.com/ItalyPaleAle/svelte-spa-router/releases) - [Changelog](https://github.com/ItalyPaleAle/svelte-spa-router/blob/main/CHANGELOG.md) - [Commits](ItalyPaleAle/svelte-spa-router@v5.0.0...v5.0.1) Updates `svelte` from 5.55.0 to 5.55.4 - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.4/packages/svelte) Updates `vite` from 8.0.3 to 8.0.8 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite) --- updated-dependencies: - dependency-name: svelte-spa-router dependency-version: 5.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: svelte dependency-version: 5.55.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: vite dependency-version: 8.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(presence): server-side last_seen stamping for non-CC agents (#280) * feat(presence): server-side last_seen stamping for non-CC agents (#279) Non-CC agents (Codex, GLM) never call the send_heartbeat MCP tool, so their presence showed stale despite active streaming sessions. Stamp agents.last_seen_at server-side on every evidence of a working pipe: broker delivery (inbound + inter-agent), and turn completion/error in both StreamingSession and CodexSession. - Add last_seen_at column + Agent.last_seen_at field + stamp_last_seen() - _agent_presence takes max(server_ts, heartbeat_ts) — heartbeat path preserved so CC agents keep working unchanged - Plumb AgentRegistry into StreamingSession/CodexSession via new `registry` kwarg (mirrors analytics_store pattern) Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * fix(presence): honor server stamp in status + stamp on codex error event Addresses murzik's PR #280 review findings: 1. _agent_presence() status branch previously returned "unknown" for any agent without a heartbeat — even when last_seen_at was recently stamped by server-side activity. Status now falls through to server_ts when no fresher heartbeat exists, using the same online/idle/offline age bands. Heartbeat logic still wins when hb_ts >= server_ts so dead/non-alive heartbeats aren't masked by an older server stamp. 2. codex_session "error" event path now stamps last_seen. Previously only turn.completed and turn.failed stamped, so transport/runtime errors surfaced via the generic error event missed a real liveness signal. Tests: - tests/test_api.py — 5 new presence tests (server-stamped online/idle/ offline, no-stamp-no-hb unknown, heartbeat-wins-when-fresher) - tests/test_codex_session.py — 3 new stamp tests (error event stamps, plus regression guards for turn.completed and turn.failed) 1637 passed, ruff clean. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(memory): distinguish zero-norm query embedding from empty results (#285) (#297) search_by_embedding silently returned [] when the query embedding had zero norm, making it impossible for callers to distinguish a broken embedding from a legitimate 'no matches found' result. - Add InvalidQueryEmbeddingError(ValueError) in pinky_memory.store. - search_by_embedding_scored raises on empty or zero-norm queries. - Inner _search_by_numpy keeps a defense-in-depth raise on zero-norm. - MCP recall() in pinky_memory.server catches and logs the error before falling back to keyword search, so broken embeddings surface in logs instead of vanishing. - Add regression tests covering zero-norm and empty query embeddings. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(outreach): let httpx set Discord multipart content type (#283) (#298) * fix(outreach): let httpx set Discord multipart content type (#283) Discord file uploads crashed because the adapter set `Content-Type: application/json` as a Client-level default, then tried to override it per-request with `headers={"Content-Type": None}`. httpx rejects `None` as a header value and raises before sending, so DiscordAdapter.send_file() never worked. - Drop the Client-level Content-Type default; httpx now sets the correct content type per request (application/json for json= bodies, multipart/form-data with boundary for files= bodies). - Remove the invalid `Content-Type: None` override in send_file(). - Add a regression test that exercises the real send_file() path via httpx.MockTransport and asserts a proper multipart request is emitted. Co-Authored-By: Murzik <noreply@pinkybot.ai> Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * style: ruff isort fix for test_discord.py Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@pinkybot.ai> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(memory): validate and log embed() failures on reflect/recall (#291) (#299) * fix(memory): validate and log embed() failures on reflect/recall (#291) embed() failures (empty returns or raised exceptions) previously stored empty embeddings silently. Downstream semantic search quietly missed those memories with no signal that anything was wrong. - Add _safe_embed() helper in pinky_memory.server that: - Skips the call entirely for NoOpEmbeddingClient (expected downgrade) - Catches exceptions from real embedders and logs with type + message - Logs a "degraded" warning when a non-NoOp embedder returns [] - reflect() now surfaces `embedded: bool` in its JSON response so callers can see when a memory was stored without a usable vector. - recall() routes through the same helper — failures no longer masquerade as "no matches"; keyword fallback proceeds with a visible log entry. - Add regression tests for: NoOp embedder, real embedder returning value, real embedder returning [] (degraded log), and embedder raising. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * fix(memory): treat zero-norm and non-finite embeddings as degraded (#291) Murzik review: a real embedder returning a non-empty but zero-norm (e.g., [0.0, ..., 0.0]) or non-finite (NaN/inf) vector passed the previous `if not embedding` check, so reflect() reported embedded=True and stored an unusable vector. Downstream search then silently skipped those rows via the existing zero-norm row guard in _search_by_numpy — reproducing the original silent-degrade bug with different inputs. - Extend _safe_embed() to validate non-empty vectors: zero-norm or any non-finite component → log "degenerate" and return [] so reflect() reports embedded=False and stores no vector. - Add regression tests for [0.0]*8 and NaN-containing embeddings. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> Co-Authored-By: Murzik <noreply@pinkybot.ai> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> Co-authored-by: Murzik <noreply@pinkybot.ai> * fix(memory): log sqlite-vec query failures before numpy fallback (#290) (#300) sqlite-vec query exceptions were swallowed and _search_by_numpy was invoked silently. Users got slower, possibly differently ordered search with no signal that vec search was broken. - Log a WARNING via pinky_memory.store logger with exception type and message before falling back to numpy. - Track a per-instance `_vec_fallback_count` bumped on every fallback. - Surface `vec_available` and `vec_fallback_count` in introspect() so health checks / dashboards can detect a degraded vec backend. - Regression test simulates a vec failure via a connection wrapper and asserts the warning is logged, counter incremented, introspect reports it, and numpy fallback still returns results. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(outreach): reject failed WhatsApp media downloads (#284) (#302) WhatsAppAdapter.download_file() wrote the raw response body to disk without checking the second-stage media GET status. 401/404 responses (HTML or Graph API error JSON) were persisted as wa_<media_id>.<ext> files, silently corrupting the media cache. - Raise WhatsAppError on any status_code >= 400 from the media download. - Extract Graph API error message/code when the response is JSON; fall back to the raw HTTP status otherwise. - Existing happy-path test now asserts status_code=200 explicitly so the new guard is exercised. - New regression asserts a 401 Expired-token response raises with the correct error_code/message and leaves no wa_<id>.* file behind. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@pinkybot.ai> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(outreach): omit null Telegram media reply ids (#288) (#303) Telegram media senders (send_photo, send_document, send_animation) now filter None-valued fields from multipart form data. Passing reply_to_message_id=None was being serialized as the literal string "None" in the multipart body, which Telegram's API rejected or treated as an invalid message id. Patch authored by Murzik; applied + committed with attribution. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@anthropic.com> * fix(self): encode KB tool URL parameters (#292) (#304) KB self-tools now URL-encode search query params and path variables: - `kb_search` builds its querystring via urllib.parse.urlencode so that queries containing `&`, `#`, `?`, or spaces round-trip correctly instead of silently injecting into the request URI. - `kb_get_wiki` / `kb_save_wiki` / `kb_delete_wiki` quote the slug with `safe="/"` — nested slugs like `topics/llm-knowledge-bases` still work, but spaces / specials inside a segment are escaped. - `kb_delete_raw` / `kb_update_raw` quote the raw source id as a single path segment (`safe=""`), so ids containing `/` or other specials no longer break routing. Added regressions for query injection, wiki slug specials, and raw source IDs containing `/` + space. Patch authored by Murzik; applied + committed with attribution. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@anthropic.com> * fix(calendar): return tool error for invalid event dates (#296) (#305) `get_events()` now parses start_date / end_date inside its error boundary and returns JSON `{error: ...}` for invalid ISO dates instead of letting `ValueError` escape the MCP tool (which surfaces as an unhandled exception to the caller). Added a new `tests/test_calendar_server.py` with a regression for invalid `start_date`. Patch authored by Murzik; applied + committed with attribution. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@anthropic.com> * fix(voice): guard finalize_call against None session under expiry races (#286) (#307) `voice_routes.voice_websocket` was handing `_voice_store.get_session(...)` directly to `finalize_call()` in the WS disconnect path. `get_session()` can legitimately return None under session-expiry races (session was cleaned up, or was never committed after an earlier failure), and `finalize_call()`'s first line accesses `session.call_sid` — boom, `AttributeError: 'NoneType' object has no attribute 'call_sid'`, and the whole finalize path (Opus review + artifact + owner notification) breaks. Two changes, belt + suspenders: 1. Call site (`voice_routes.py`): look up the session before scheduling `finalize_call`, skip + log if it's gone. This prevents the task from being scheduled in the first place. 2. Callee (`voice_engine.py::finalize_call`): accept None defensively and early-return with a log line. Any future caller that forgets the guard gets a clean no-op instead of a crash. Added tests/test_voice_engine.py covering: - `finalize_call(None, ...)` no-ops and does not touch voice_store / agents / broker - `finalize_call(real_session, ...)` still proceeds past the guard Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(datetime): replace deprecated utcnow() + naive now() with tz-aware (#294) (#308) `datetime.utcnow()` is deprecated as of Python 3.12 (will emit DeprecationWarning and eventually be removed). Naive `datetime.now()` silently picks up local tz, which breaks any cross-tz serialization or comparison and — in the case of streaming_session.py:300 — labelled a local-time string as "UTC". Five call sites swept: - `pinky_daemon/api.py:2946` — version string now uses `now(timezone.utc)` so the built version is deterministic regardless of host tz. - `pinky_daemon/api.py:6966` — web/DM timestamp fallback uses `now(utc)`. - `pinky_daemon/api.py:7026` — broker-message timestamp fallback uses `now(utc)`. - `pinky_daemon/streaming_session.py:300` — fallback labelled UTC now actually uses UTC (was implicit local-tz bug). - `pinky_memory/kg_extractor.py:449` — hoisted out of the per-triple loop into a single cached `_now_ymd` value per `_resolve_functional_conflict` call. Already tz-aware; this is just the "cache once per call" cleanup flagged in the issue. Verified: `grep -n 'datetime\\.utcnow\\|datetime\\.now()'` returns only comments. Full test suite for touched areas (test_api, test_kg_extractor, test_streaming_session) — 305 passed. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(memory): handle corrupt embedding JSON in dim detection + backfill (#289) (#301) _detect_embedding_dimensions() and _backfill_vec() both called json.loads() on stored embedding blobs with no guard. A single corrupted row crashed dimension detection (blocking startup) or logged nothing in backfill. - _detect_embedding_dimensions() now scans up to 50 newest-first rows, skips malformed JSON with a logger.warning carrying the reflection id, and returns the first valid embedding's length. - _backfill_vec() catches ValueError/TypeError on json.loads, logs the reflection id, counts corrupt rows, and reports them in the completion message. The bare `except Exception: pass` on the insert step is replaced with a targeted sqlite3.Error catch + debug log. - Regression test corrupts one row's embedding, makes it the newest, asserts _detect_embedding_dimensions still returns 8 from the good row and that the bad row id appears in the warning log. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(memory): replace bare excepts with typed exceptions in store.py (#295) (#309) Closes #295. Replaces 10 `except:` / `except Exception:` sites with specific exception classes (mostly `sqlite3.OperationalError`) plus logger calls at appropriate severity levels. Preserves fallback semantics — callers still get numpy/LIKE fallbacks, migrations stay idempotent. The one site calling user-supplied `llm_classify` keeps a broad catch with a noqa BLE001 + justification since the callable can raise anything. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@anthropic.com> * fix(oauth): validate state nonce on legacy Google Calendar callback (#287) (#306) * fix(oauth): validate state nonce on legacy Google Calendar callback (#287) **Security fix (high).** The legacy `/calendar/google/callback` endpoint accepted `state` but never compared it against any stored value before exchanging the authorization code and saving tokens. A crafted callback hitting the owner's browser with an attacker-controlled code could bind the wrong Google account. Changes: - New `/calendar/google/direct-auth-url` endpoint that calls the existing `pinky_calendar.oauth.get_auth_url()` *and* persists the returned state nonce with an ISO timestamp under `GOOGLE_OAUTH_STATE_{nonce}` (TTL 10m). - `/calendar/google/callback` now requires + validates + single-use-consumes the state nonce before any client-credential lookup or code exchange. Missing / unknown / replayed / expired / corrupt / future-dated states are all hard-rejected with HTTP 400 + a readable HTML error page. - State is deleted immediately on consumption (even on exchange failure or replay attempt), preventing TOCTOU reuse. - `pinky_calendar.oauth.exchange_code`: updated the misleading "validated by caller" marker to a real SECURITY note that spells out the caller's obligation. Tests (`TestGoogleOAuthStateValidation`): missing state, unknown/forged state, expired state (purged afterward), replay rejection, state consumption on exchange failure, direct-auth-url state persistence, direct-auth-url credential gate. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * fix(oauth): fail closed on state consume + reject naive timestamps (#287) Addresses Murzik's review on PR #306: **High — consume must fail closed.** `_consume_google_oauth_state()` was ignoring both the return value of `delete_setting()` and any exception it raised. That meant: - If a concurrent consumer won the race (e.g. legitimate callback + attacker replay fired in parallel), *both* could proceed past the validation check. - If deletion itself failed (DB locked, disk full, any OperationalError), the request still proceeded to token exchange. DELETE is now the authoritative consume step: - Returns False (row not deleted) → "unknown or replayed state" - Raises → "could not consume state" Either way, `exchange_code()` is never invoked. **Medium — naive timestamps.** `datetime.fromisoformat()` accepts ISO strings without a tz suffix and returns a naive datetime. Subtracting a naive dt from an aware `now()` raises TypeError → the endpoint 500s instead of returning the promised 400. We only ever *issue* aware UTC timestamps, so naive records now hard-reject as "corrupt state record". Regressions added: - `test_consume_fails_closed_when_delete_returns_false` — simulates concurrent consumer, asserts callback 400 + `exchange_code` not called. - `test_consume_fails_closed_when_delete_raises` — simulates DB failure, asserts callback 400 + `exchange_code` not called. - `test_callback_rejects_naive_timestamp_state` — seeds a naive ISO string, asserts 400 + row purged. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> Co-authored-by: Murzik <noreply@pinkybot.ai>
* chore(deps): bump actions/checkout from 4 to 6 (#275) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](actions/checkout@v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump actions/setup-node from 4 to 6 (#276) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](actions/setup-node@v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump actions/setup-python from 5 to 6 (#277) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](actions/setup-python@v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the npm-minor-patch group (#278) Bumps the npm-minor-patch group in /frontend-svelte with 3 updates: [svelte-spa-router](https://github.com/ItalyPaleAle/svelte-spa-router), [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `svelte-spa-router` from 5.0.0 to 5.0.1 - [Release notes](https://github.com/ItalyPaleAle/svelte-spa-router/releases) - [Changelog](https://github.com/ItalyPaleAle/svelte-spa-router/blob/main/CHANGELOG.md) - [Commits](ItalyPaleAle/svelte-spa-router@v5.0.0...v5.0.1) Updates `svelte` from 5.55.0 to 5.55.4 - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.4/packages/svelte) Updates `vite` from 8.0.3 to 8.0.8 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite) --- updated-dependencies: - dependency-name: svelte-spa-router dependency-version: 5.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: svelte dependency-version: 5.55.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: vite dependency-version: 8.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(presence): server-side last_seen stamping for non-CC agents (#280) * feat(presence): server-side last_seen stamping for non-CC agents (#279) Non-CC agents (Codex, GLM) never call the send_heartbeat MCP tool, so their presence showed stale despite active streaming sessions. Stamp agents.last_seen_at server-side on every evidence of a working pipe: broker delivery (inbound + inter-agent), and turn completion/error in both StreamingSession and CodexSession. - Add last_seen_at column + Agent.last_seen_at field + stamp_last_seen() - _agent_presence takes max(server_ts, heartbeat_ts) — heartbeat path preserved so CC agents keep working unchanged - Plumb AgentRegistry into StreamingSession/CodexSession via new `registry` kwarg (mirrors analytics_store pattern) Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * fix(presence): honor server stamp in status + stamp on codex error event Addresses murzik's PR #280 review findings: 1. _agent_presence() status branch previously returned "unknown" for any agent without a heartbeat — even when last_seen_at was recently stamped by server-side activity. Status now falls through to server_ts when no fresher heartbeat exists, using the same online/idle/offline age bands. Heartbeat logic still wins when hb_ts >= server_ts so dead/non-alive heartbeats aren't masked by an older server stamp. 2. codex_session "error" event path now stamps last_seen. Previously only turn.completed and turn.failed stamped, so transport/runtime errors surfaced via the generic error event missed a real liveness signal. Tests: - tests/test_api.py — 5 new presence tests (server-stamped online/idle/ offline, no-stamp-no-hb unknown, heartbeat-wins-when-fresher) - tests/test_codex_session.py — 3 new stamp tests (error event stamps, plus regression guards for turn.completed and turn.failed) 1637 passed, ruff clean. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(memory): distinguish zero-norm query embedding from empty results (#285) (#297) search_by_embedding silently returned [] when the query embedding had zero norm, making it impossible for callers to distinguish a broken embedding from a legitimate 'no matches found' result. - Add InvalidQueryEmbeddingError(ValueError) in pinky_memory.store. - search_by_embedding_scored raises on empty or zero-norm queries. - Inner _search_by_numpy keeps a defense-in-depth raise on zero-norm. - MCP recall() in pinky_memory.server catches and logs the error before falling back to keyword search, so broken embeddings surface in logs instead of vanishing. - Add regression tests covering zero-norm and empty query embeddings. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(outreach): let httpx set Discord multipart content type (#283) (#298) * fix(outreach): let httpx set Discord multipart content type (#283) Discord file uploads crashed because the adapter set `Content-Type: application/json` as a Client-level default, then tried to override it per-request with `headers={"Content-Type": None}`. httpx rejects `None` as a header value and raises before sending, so DiscordAdapter.send_file() never worked. - Drop the Client-level Content-Type default; httpx now sets the correct content type per request (application/json for json= bodies, multipart/form-data with boundary for files= bodies). - Remove the invalid `Content-Type: None` override in send_file(). - Add a regression test that exercises the real send_file() path via httpx.MockTransport and asserts a proper multipart request is emitted. Co-Authored-By: Murzik <noreply@pinkybot.ai> Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * style: ruff isort fix for test_discord.py Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@pinkybot.ai> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(memory): validate and log embed() failures on reflect/recall (#291) (#299) * fix(memory): validate and log embed() failures on reflect/recall (#291) embed() failures (empty returns or raised exceptions) previously stored empty embeddings silently. Downstream semantic search quietly missed those memories with no signal that anything was wrong. - Add _safe_embed() helper in pinky_memory.server that: - Skips the call entirely for NoOpEmbeddingClient (expected downgrade) - Catches exceptions from real embedders and logs with type + message - Logs a "degraded" warning when a non-NoOp embedder returns [] - reflect() now surfaces `embedded: bool` in its JSON response so callers can see when a memory was stored without a usable vector. - recall() routes through the same helper — failures no longer masquerade as "no matches"; keyword fallback proceeds with a visible log entry. - Add regression tests for: NoOp embedder, real embedder returning value, real embedder returning [] (degraded log), and embedder raising. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * fix(memory): treat zero-norm and non-finite embeddings as degraded (#291) Murzik review: a real embedder returning a non-empty but zero-norm (e.g., [0.0, ..., 0.0]) or non-finite (NaN/inf) vector passed the previous `if not embedding` check, so reflect() reported embedded=True and stored an unusable vector. Downstream search then silently skipped those rows via the existing zero-norm row guard in _search_by_numpy — reproducing the original silent-degrade bug with different inputs. - Extend _safe_embed() to validate non-empty vectors: zero-norm or any non-finite component → log "degenerate" and return [] so reflect() reports embedded=False and stores no vector. - Add regression tests for [0.0]*8 and NaN-containing embeddings. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> Co-Authored-By: Murzik <noreply@pinkybot.ai> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> Co-authored-by: Murzik <noreply@pinkybot.ai> * fix(memory): log sqlite-vec query failures before numpy fallback (#290) (#300) sqlite-vec query exceptions were swallowed and _search_by_numpy was invoked silently. Users got slower, possibly differently ordered search with no signal that vec search was broken. - Log a WARNING via pinky_memory.store logger with exception type and message before falling back to numpy. - Track a per-instance `_vec_fallback_count` bumped on every fallback. - Surface `vec_available` and `vec_fallback_count` in introspect() so health checks / dashboards can detect a degraded vec backend. - Regression test simulates a vec failure via a connection wrapper and asserts the warning is logged, counter incremented, introspect reports it, and numpy fallback still returns results. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(outreach): reject failed WhatsApp media downloads (#284) (#302) WhatsAppAdapter.download_file() wrote the raw response body to disk without checking the second-stage media GET status. 401/404 responses (HTML or Graph API error JSON) were persisted as wa_<media_id>.<ext> files, silently corrupting the media cache. - Raise WhatsAppError on any status_code >= 400 from the media download. - Extract Graph API error message/code when the response is JSON; fall back to the raw HTTP status otherwise. - Existing happy-path test now asserts status_code=200 explicitly so the new guard is exercised. - New regression asserts a 401 Expired-token response raises with the correct error_code/message and leaves no wa_<id>.* file behind. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@pinkybot.ai> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(outreach): omit null Telegram media reply ids (#288) (#303) Telegram media senders (send_photo, send_document, send_animation) now filter None-valued fields from multipart form data. Passing reply_to_message_id=None was being serialized as the literal string "None" in the multipart body, which Telegram's API rejected or treated as an invalid message id. Patch authored by Murzik; applied + committed with attribution. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@anthropic.com> * fix(self): encode KB tool URL parameters (#292) (#304) KB self-tools now URL-encode search query params and path variables: - `kb_search` builds its querystring via urllib.parse.urlencode so that queries containing `&`, `#`, `?`, or spaces round-trip correctly instead of silently injecting into the request URI. - `kb_get_wiki` / `kb_save_wiki` / `kb_delete_wiki` quote the slug with `safe="/"` — nested slugs like `topics/llm-knowledge-bases` still work, but spaces / specials inside a segment are escaped. - `kb_delete_raw` / `kb_update_raw` quote the raw source id as a single path segment (`safe=""`), so ids containing `/` or other specials no longer break routing. Added regressions for query injection, wiki slug specials, and raw source IDs containing `/` + space. Patch authored by Murzik; applied + committed with attribution. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@anthropic.com> * fix(calendar): return tool error for invalid event dates (#296) (#305) `get_events()` now parses start_date / end_date inside its error boundary and returns JSON `{error: ...}` for invalid ISO dates instead of letting `ValueError` escape the MCP tool (which surfaces as an unhandled exception to the caller). Added a new `tests/test_calendar_server.py` with a regression for invalid `start_date`. Patch authored by Murzik; applied + committed with attribution. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@anthropic.com> * fix(voice): guard finalize_call against None session under expiry races (#286) (#307) `voice_routes.voice_websocket` was handing `_voice_store.get_session(...)` directly to `finalize_call()` in the WS disconnect path. `get_session()` can legitimately return None under session-expiry races (session was cleaned up, or was never committed after an earlier failure), and `finalize_call()`'s first line accesses `session.call_sid` — boom, `AttributeError: 'NoneType' object has no attribute 'call_sid'`, and the whole finalize path (Opus review + artifact + owner notification) breaks. Two changes, belt + suspenders: 1. Call site (`voice_routes.py`): look up the session before scheduling `finalize_call`, skip + log if it's gone. This prevents the task from being scheduled in the first place. 2. Callee (`voice_engine.py::finalize_call`): accept None defensively and early-return with a log line. Any future caller that forgets the guard gets a clean no-op instead of a crash. Added tests/test_voice_engine.py covering: - `finalize_call(None, ...)` no-ops and does not touch voice_store / agents / broker - `finalize_call(real_session, ...)` still proceeds past the guard Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(datetime): replace deprecated utcnow() + naive now() with tz-aware (#294) (#308) `datetime.utcnow()` is deprecated as of Python 3.12 (will emit DeprecationWarning and eventually be removed). Naive `datetime.now()` silently picks up local tz, which breaks any cross-tz serialization or comparison and — in the case of streaming_session.py:300 — labelled a local-time string as "UTC". Five call sites swept: - `pinky_daemon/api.py:2946` — version string now uses `now(timezone.utc)` so the built version is deterministic regardless of host tz. - `pinky_daemon/api.py:6966` — web/DM timestamp fallback uses `now(utc)`. - `pinky_daemon/api.py:7026` — broker-message timestamp fallback uses `now(utc)`. - `pinky_daemon/streaming_session.py:300` — fallback labelled UTC now actually uses UTC (was implicit local-tz bug). - `pinky_memory/kg_extractor.py:449` — hoisted out of the per-triple loop into a single cached `_now_ymd` value per `_resolve_functional_conflict` call. Already tz-aware; this is just the "cache once per call" cleanup flagged in the issue. Verified: `grep -n 'datetime\\.utcnow\\|datetime\\.now()'` returns only comments. Full test suite for touched areas (test_api, test_kg_extractor, test_streaming_session) — 305 passed. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(memory): handle corrupt embedding JSON in dim detection + backfill (#289) (#301) _detect_embedding_dimensions() and _backfill_vec() both called json.loads() on stored embedding blobs with no guard. A single corrupted row crashed dimension detection (blocking startup) or logged nothing in backfill. - _detect_embedding_dimensions() now scans up to 50 newest-first rows, skips malformed JSON with a logger.warning carrying the reflection id, and returns the first valid embedding's length. - _backfill_vec() catches ValueError/TypeError on json.loads, logs the reflection id, counts corrupt rows, and reports them in the completion message. The bare `except Exception: pass` on the insert step is replaced with a targeted sqlite3.Error catch + debug log. - Regression test corrupts one row's embedding, makes it the newest, asserts _detect_embedding_dimensions still returns 8 from the good row and that the bad row id appears in the warning log. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * fix(memory): replace bare excepts with typed exceptions in store.py (#295) (#309) Closes #295. Replaces 10 `except:` / `except Exception:` sites with specific exception classes (mostly `sqlite3.OperationalError`) plus logger calls at appropriate severity levels. Preserves fallback semantics — callers still get numpy/LIKE fallbacks, migrations stay idempotent. The one site calling user-supplied `llm_classify` keeps a broad catch with a noqa BLE001 + justification since the callable can raise anything. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Murzik <noreply@anthropic.com> * fix(oauth): validate state nonce on legacy Google Calendar callback (#287) (#306) * fix(oauth): validate state nonce on legacy Google Calendar callback (#287) **Security fix (high).** The legacy `/calendar/google/callback` endpoint accepted `state` but never compared it against any stored value before exchanging the authorization code and saving tokens. A crafted callback hitting the owner's browser with an attacker-controlled code could bind the wrong Google account. Changes: - New `/calendar/google/direct-auth-url` endpoint that calls the existing `pinky_calendar.oauth.get_auth_url()` *and* persists the returned state nonce with an ISO timestamp under `GOOGLE_OAUTH_STATE_{nonce}` (TTL 10m). - `/calendar/google/callback` now requires + validates + single-use-consumes the state nonce before any client-credential lookup or code exchange. Missing / unknown / replayed / expired / corrupt / future-dated states are all hard-rejected with HTTP 400 + a readable HTML error page. - State is deleted immediately on consumption (even on exchange failure or replay attempt), preventing TOCTOU reuse. - `pinky_calendar.oauth.exchange_code`: updated the misleading "validated by caller" marker to a real SECURITY note that spells out the caller's obligation. Tests (`TestGoogleOAuthStateValidation`): missing state, unknown/forged state, expired state (purged afterward), replay rejection, state consumption on exchange failure, direct-auth-url state persistence, direct-auth-url credential gate. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> * fix(oauth): fail closed on state consume + reject naive timestamps (#287) Addresses Murzik's review on PR #306: **High — consume must fail closed.** `_consume_google_oauth_state()` was ignoring both the return value of `delete_setting()` and any exception it raised. That meant: - If a concurrent consumer won the race (e.g. legitimate callback + attacker replay fired in parallel), *both* could proceed past the validation check. - If deletion itself failed (DB locked, disk full, any OperationalError), the request still proceeded to token exchange. DELETE is now the authoritative consume step: - Returns False (row not deleted) → "unknown or replayed state" - Raises → "could not consume state" Either way, `exchange_code()` is never invoked. **Medium — naive timestamps.** `datetime.fromisoformat()` accepts ISO strings without a tz suffix and returns a naive datetime. Subtracting a naive dt from an aware `now()` raises TypeError → the endpoint 500s instead of returning the promised 400. We only ever *issue* aware UTC timestamps, so naive records now hard-reject as "corrupt state record". Regressions added: - `test_consume_fails_closed_when_delete_returns_false` — simulates concurrent consumer, asserts callback 400 + `exchange_code` not called. - `test_consume_fails_closed_when_delete_raises` — simulates DB failure, asserts callback 400 + `exchange_code` not called. - `test_callback_rejects_naive_timestamp_state` — seeds a naive ISO string, asserts 400 + row purged. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com> --------- Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * docs: update Deploy section in CLAUDE.md (#314) sync.sh/status.sh are no longer present in the repo. Replace with the current deploy path: agents call update_and_restart() from pinky-self, which honors PINKYBOT_CHANNEL to pick main vs beta. Also refresh the Mac Mini LAN IP (.32 → .209) after the DHCP lease change on 2026-04-17. Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> * registry: add gpt-5.5 to OpenAI/Codex seed models OpenAI shipped GPT-5.5 on 2026-04-23, available in Codex CLI via ChatGPT sign-in auth today (API access coming "very soon"). Murzik was already bumped to gpt-5.5 live via the API; this mirrors the change in the seed list so fresh installs inherit it. supports_thinking=0 to match existing OpenAI entries (reasoning effort is driven by the agent-level thinking_effort field, which Codex maps to model_reasoning_effort at spawn). 🤖 Opened by Barsik * chore: bump claude-agent-sdk to >=0.1.68 Picks up: - v0.1.68 (CLI v2.1.119) - v0.1.67: trio compatibility fix (RuntimeError: no running event loop regression from v0.1.51 — uses sniffio-based dispatch). Adds explicit sniffio runtime dep. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Oleg <oleg@Olegs-Mac-mini.local> Co-authored-by: Claude Opus 4 <noreply@anthropic.com> Co-authored-by: Murzik <noreply@pinkybot.ai>
|
Fresh evidence 2026-07-02 ~04:03-04:13 UTC: Murzik received at least 4 replayed/out-of-order deliveries of my earlier inter-agent messages during tonight's review cycle (his acks explicitly flag them as 'seen as replayed context' / 'older stand-down note'). No functional harm — he re-derived current state each time — but it burns his turns and invites state confusion. Reminder: fix PR #831 is still OPEN/HELD (needs review + a daemon bounce to land; tonight's 26.07.001 bounce would have carried it). Worth queueing for the next release. 🤖 Opened by Barsik |
…280) requester's inbox. But agents also reply explicitly via send_to_agent (comms.send -> inbox directly), so a turn that did both delivered the reply TWICE. The dup hadn't surfaced yet only because no agent-injected turn had completed since #279 deployed; the next Murzik review verdict would have doubled. Make auto-route a fallback: suppress it when the responder already sent an explicit (non-auto_routed) message to the requester since the turn began. The turn boundary is captured at inject_agent_message time ((responder, requester) -> start ts), consumed by route_agent_reply. - Backend-agnostic: no reliance on tool_uses (codex turns omit them). - Never content-matches, so it can't drop a genuinely new reply. - Missing marker (e.g. lost across a restart) or a probe error falls through to delivery — a stray dup is acceptable, a dropped verdict is not. - Orphan markers swept by TTL on insert (bounded memory). - Kill-switch PINKY_AGENT_REPLY_DEDUP (default on) restores #279. New AgentComms.has_message_since() backs the probe. Counted via the existing broker `deduped` stat. Tests cover suppress-on-explicit-send, deliver-on-no-send, deliver-on-missing-marker (safety), killswitch, the inject marker, and the comms probe (incl. legacy/empty metadata). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed per-call handoff produces no route-back turn, but the #280 dedup marker stamped before the send survived it. The orphan stayed live for up to the marker TTL and any unrelated explicit responder->requester message would then suppress a DIFFERENT injection's distinct route-back — a dropped verdict, not a stray dup. The unmark is identity-guarded (timestamp token) so an older failed call can never pop a newer concurrent injection's marker, and dedup still fails open to delivery when no marker is present. Regressions: failed handoff leaves no marker and cannot suppress a distinct later route-back; raising sends unmark and propagate; True-handoff behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Fixes #279 — non-CC agents (Codex, GLM) appeared offline because they never call the
send_heartbeatMCP tool. Presence is now stamped server-side on real activity (turn completion, inbound message delivery) so the registry reflects actual liveness regardless of whether the agent manually heartbeats.Changes
agent_registry.py— newagents.last_seen_at REALcolumn via_ensure_columnsmigration;AgentRegistry.stamp_last_seen(name, ts=None)helper.api.py—_agent_presencenow returnsmax(agent.last_seen_at, heartbeat.timestamp); registry plumbed into session init.broker.py— stamp after successfulinject_agent_messagesend and inbound_route_streamingdelivery.streaming_session.py— stamp once after turn lifecycle (both success and error paths).codex_session.py— same pattern onturn.completed/turn.failedfor Codex/GLM.Test plan
pytest tests/test_agent_registry.py tests/test_broker.py— 47 passedNotes
register()INSERT untouched —DEFAULT 0on the new column handles inserts automatically.turn_completedandturn_error) since both prove the pipe is alive.Closes #279
🤖 Opened by Barsik