Skip to content

Inline meeting ID lookup + 3-tier phone access control - #30

Merged
sonichi merged 20 commits into
mainfrom
feature/inline-call-contact
Mar 30, 2026
Merged

sonichi merged 20 commits into
mainfrom
feature/inline-call-contact

Conversation

@liususan091219

@liususan091219 liususan091219 commented Mar 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Inline meeting ID lookup: verified callers can look up Zoom meeting ID + passcode instantly via lookup_meeting_id tool (no task bridge delay)
  • Inline call_contact: owner can look up contacts and dial directly
  • 3-tier phone access control:
    • Owner (OWNER_NUMBER): full access — work tool + all inline tools
    • Verified (VERIFIED_CALLERS): limited tools (volume, brightness, time, lookup_meeting_id)
    • Unverified: conversation + any-caller tools only (volume, brightness, time)
  • Phone number normalization for fuzzy matching (+1, no prefix, etc.)
  • dotenv({ override: true }) so .env always takes precedence
  • Volume/brightness fix: normalize 0-1 values from Gemini to 0-100
  • Security test template at tests/security/phone-access-control.md.example — each user can make 3 calls (owner/verified/unverified), then ask their agent to read phone transcripts and auto-fill the test results

Test plan

  • Owner: summon, call_contact, work, switch_tab, capture_screen
  • Verified: lookup_meeting_id (returned ID + passcode)
  • Unverified: volume works, scroll/work/summon blocked
  • Full test matrix in tests/security/phone-access-control.md.example

🤖 Generated with Claude Code

@liususan091219 liususan091219 changed the title Add inline tools for phone agent: meeting ID lookup + improved instructions WIP: Add inline tools for phone agent: meeting ID lookup + improved instructions Mar 29, 2026
@liususan091219
liususan091219 force-pushed the feature/inline-call-contact branch from 937553a to 1bdbadc Compare March 29, 2026 15:28
- call_contact: searches macOS Contacts by name, dials directly (bypasses task bridge)
- lookup_meeting_id: returns Zoom meeting ID + passcode from env
- Phone agent: stricter hang_up (wait for caller goodbye), "How to think" section,
  inline tools list for verified callers, GitHub repo URL in instructions
- Context injection for child calls
- Add PERSONAL_CLAUDE.md.example template, gitignore PERSONAL_CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@liususan091219
liususan091219 force-pushed the feature/inline-call-contact branch from 1bdbadc to 3ebfc5e Compare March 29, 2026 15:31

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the updated PR. Looks good overall — clean diff after the rebase.

Approve:

  • lookup_meeting_id — clean, reads env vars, returns ID + passcode
  • call_contact — good Contact search, handles multiple matches, auto-calls single match
  • "How to think" instructions — useful guidance for phone agent decision-making
  • Inline tools list injection for verified callers
  • Stricter hang_up — waits for caller's explicit goodbye, prevents premature disconnects
  • PERSONAL_CLAUDE.md.example — nice per-user rules template

One question: Child call context injection (line 295) re-adds getSafeContext(). We removed this on main per owner feedback ("only context needed for the outbound call should be provided"). The purpose string from core already contains the needed info. Should we keep or drop the broader context for child calls?

Otherwise ready to merge.

@liususan091219
liususan091219 force-pushed the feature/inline-call-contact branch 4 times, most recently from 15fd056 to ac0ac2a Compare March 29, 2026 17:02
@liususan091219 liususan091219 changed the title WIP: Add inline tools for phone agent: meeting ID lookup + improved instructions Add inline call_contact + lookup_meeting_id tools, improve phone agent Mar 29, 2026
@liususan091219
liususan091219 force-pushed the feature/inline-call-contact branch 2 times, most recently from a3194ee to 457a934 Compare March 29, 2026 17:12
liususan091219 and others added 2 commits March 29, 2026 13:13
These belong with the personal overrides PR, not the inline tools PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove getSafeContext() from child calls — the purpose string from
core already carries the needed info. Avoids leaking broader context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@liususan091219
liususan091219 force-pushed the feature/inline-call-contact branch from 457a934 to 654f287 Compare March 29, 2026 17:14
liususan091219 and others added 4 commits March 29, 2026 16:11
Owner (OWNER_NUMBER): work tool + all inline tools (full computer access)
Verified (VERIFIED_CALLERS, not owner): limited tools (volume, brightness,
  time, meeting ID lookup). No task delegation or screen control.
Unverified: conversation + hang_up only.

Access determined by caller/callee phone number, not call type.
Removes child-call-based inheritance of verification status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unverified callers now get anyCallerTools too, not just hang_up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gemini sometimes passes 0.8 instead of 80 for "80%". Detect and
convert fractional values to the expected 0-100 range.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
liususan091219 and others added 10 commits March 29, 2026 16:59
xlsx file for manual testing of 3-tier access control (owner/verified/unverified).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Verified test matrix for 3-tier access: owner, verified, unverified.
Located at tests/manual/ for contributor test assignments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Strip +, -, spaces and prepend country code for 10-digit numbers.
Fixes: child call to 6464706470 not matching +16464706470 in VERIFIED_CALLERS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
import 'dotenv/config' skips vars already in the shell environment.
With override: true, .env always takes precedence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Child call instructions were telling the agent to decline all lookups.
Now dynamically lists available tools based on callee's access tier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Marked items tested during this session's phone calls.
Remaining items unchecked for future testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Template at tests/manual/phone-access-control.md.example with empty tables.
Agents fill in phone-access-control.md locally (gitignored) with test results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
One table with all 3 tiers as columns instead of 3 separate tables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Explains how each user can verify their own security settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Users make calls from each tier, then ask their agent to read
transcripts and auto-fill the test table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
liususan091219 and others added 2 commits March 29, 2026 18:24
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@liususan091219 liususan091219 changed the title Add inline call_contact + lookup_meeting_id tools, improve phone agent Inline meeting ID lookup + 3-tier phone access control Mar 29, 2026
@liususan091219

Copy link
Copy Markdown
Collaborator Author

One question: Child call context injection (line 295) re-adds getSafeContext(). We removed this on main per owner feedback ("only context needed for the outbound call should be provided"). The purpose string from core already contains the needed info. Should we keep or drop the broader context for child calls?

Dropped it. The purpose string from core already carries the needed info, and broader context shouldn't leak to child calls — especially now with 3-tier access control where the callee may not be the owner.

Also added 3-tier phone access control in this PR:

  • Owner (OWNER_NUMBER): full access — work tool + all inline tools
  • Verified (VERIFIED_CALLERS, not owner): limited tools (volume, brightness, time, lookup_meeting_id). No work/summon/call_contact.
  • Unverified: conversation + any-caller tools only (volume, brightness, time)

Key changes: isOwner detection, phone number normalization, dotenv({ override: true }), child call instructions list available tools per tier, security test template at tests/security/phone-access-control.md.example.

Partially tested — owner summon/call_contact/work, verified lookup_meeting_id, unverified volume/scroll-blocked all verified via phone calls.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — PR #30 with 3-tier access control

Changes look clean. Key additions:

  1. OWNER_NUMBER env var ✅ — separates owner from verified callers. Good.
  2. 3-tier tool access ✅ — ownerOnlyTools, anyCallerTools, configurableTools exports from inline-tools.ts. Clean separation.
  3. Dropped child call context injection ✅ — per earlier discussion.
  4. Security test template ✅ — tests/security/phone-access-control.md.example
  5. dotenv override — changed to dotenv.config({ override: true }). Why override? This means env vars set before the script (e.g., from startup.sh) get overridden by .env. Could cause issues if startup.sh sets values intentionally.
  6. .env.example updated with clear 3-tier documentation ✅

One question: the dotenv({ override: true }) change — is this intentional? The rest of the codebase uses import 'dotenv/config' which doesn't override.

Otherwise LGTM — ready to merge.

override: true was a workaround for stale shell env during debugging.
Not needed in production — .env should be the source of truth and
startup.sh may set vars intentionally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@liususan091219

Copy link
Copy Markdown
Collaborator Author

One question: the dotenv({ override: true }) change — is this intentional? The rest of the codebase uses import 'dotenv/config' which doesn't override.

Reverted — back to import 'dotenv/config'. The override was a workaround for stale shell env during debugging, not needed in production.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All feedback addressed — context injection dropped, dotenv reverted. 3-tier phone access control looks solid. LGTM, ready to merge.

@sonichi
sonichi merged commit e628136 into main Mar 30, 2026
@sonichi
sonichi deleted the feature/inline-call-contact branch March 30, 2026 12:36
liususan091219 added a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
* Add inline call_contact + lookup_meeting_id tools, improve phone agent

- call_contact: searches macOS Contacts by name, dials directly (bypasses task bridge)
- lookup_meeting_id: returns Zoom meeting ID + passcode from env
- Phone agent: stricter hang_up (wait for caller goodbye), "How to think" section,
  inline tools list for verified callers, GitHub repo URL in instructions
- Context injection for child calls
- Add PERSONAL_CLAUDE.md.example template, gitignore PERSONAL_CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Move .gitignore and PERSONAL_CLAUDE.md.example to PR sonichi#37

These belong with the personal overrides PR, not the inline tools PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Drop child call context injection per review

Remove getSafeContext() from child calls — the purpose string from
core already carries the needed info. Avoids leaking broader context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add 3-tier phone access control: owner, verified, unverified

Owner (OWNER_NUMBER): work tool + all inline tools (full computer access)
Verified (VERIFIED_CALLERS, not owner): limited tools (volume, brightness,
  time, meeting ID lookup). No task delegation or screen control.
Unverified: conversation + hang_up only.

Access determined by caller/callee phone number, not call type.
Removes child-call-based inheritance of verification status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Give any-caller tools (volume, brightness, time) to all callers

Unverified callers now get anyCallerTools too, not just hang_up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix volume/brightness: normalize 0-1 values from Gemini to 0-100

Gemini sometimes passes 0.8 instead of 80 for "80%". Detect and
convert fractional values to the expected 0-100 range.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Move toggle_tasks to owner-only tools

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add phone access control test spreadsheet

xlsx file for manual testing of 3-tier access control (owner/verified/unverified).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add manual test checklist for phone access control

Verified test matrix for 3-tier access: owner, verified, unverified.
Located at tests/manual/ for contributor test assignments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Normalize phone numbers for access control matching

Strip +, -, spaces and prepend country code for 10-digit numbers.
Fixes: child call to 6464706470 not matching +16464706470 in VERIFIED_CALLERS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Use dotenv override to always read fresh .env values

import 'dotenv/config' skips vars already in the shell environment.
With override: true, .env always takes precedence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Tell child calls about their available tools

Child call instructions were telling the agent to decline all lookups.
Now dynamically lists available tools based on callee's access tier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update test checklist with manually verified items

Marked items tested during this session's phone calls.
Remaining items unchecked for future testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add phone access control test template, gitignore results

Template at tests/manual/phone-access-control.md.example with empty tables.
Agents fill in phone-access-control.md locally (gitignored) with test results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Consolidate test template into single table

One table with all 3 tiers as columns instead of 3 separate tables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add usage instructions to access control test template

Explains how each user can verify their own security settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update test instructions: make 3 calls, agent fills in results

Users make calls from each tier, then ask their agent to read
transcripts and auto-fill the test table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Rename tests/manual to tests/security

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Remove tracked phone-access-control.md (now gitignored)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Revert dotenv override — use standard import 'dotenv/config'

override: true was a workaround for stale shell env during debugging.
Not needed in production — .env should be the source of truth and
startup.sh may set vars intentionally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
yixuan-ag2 added a commit that referenced this pull request Aug 18, 2026
… surfaces) (#3131)

Picks up the five merged bodhi PRs the voice context-budget design depends on
(sonichi/bodhi_realtime_agent #33, #34, #31, #30, #32):

- upstream send counters + getDiagnostics() (attempted/queued/skipped/threw,
  split raw/wire bytes, per-outcome timestamps; reset per transport generation)
- typed connection-lifecycle events (attempt / setup-ok / setup-failed /
  attempt-close / generation-close, correlated by connectAttemptId)
- onUsageMetadata (promptTokenCount et al), reachable from VoiceSession
- mediaResolution passthrough (session-wide media token cost)
- optional compressionConfig thresholds ({} = server defaults), sent as the
  int64-over-JSON strings the API expects

Plus review hardening from the merge train: observer isolation on both new
callback surfaces, reconnect replay counted AND moved off the deprecated
`media` field onto the supported video/audio slots, and superseded-dial
setup-failed fenced like every other stale-dial signal.

Pin only; no sutando code change. Consumers land separately (Phase 1: ledger
sampling, logicalSessionId, up=/ctx= health segments, shadow matrix).

Evidence:

Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped.

Live round trip through the PINNED artifact (not the SDK directly) against
gemini-2.5-flash-native-audio-preview-12-2025 — connect with
mediaResolution=LOW + compressionConfig:{} accepted; text in -> model audio out;
every new surface read while doing it:

  connected in 304ms; lifecycle so far: attempt -> setup-ok
  text slot     : {"attempted":1,"queued":1,"queuedRawBytes":26}
  generation    : 1
  audio chunks  : 1 (first after 1442ms)
  usage samples : 1 promptTokenCount 375 -> 375
  lifecycle full: attempt -> setup-ok -> generation-close
  LIVE ROUND TRIP: PASS

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2 added a commit that referenced this pull request Aug 18, 2026
…ow matrix (#3132)

* chore(deps): bump bodhi pin ca0f61de -> 0d506ead (B1-B5 observability surfaces)

Picks up the five merged bodhi PRs the voice context-budget design depends on
(sonichi/bodhi_realtime_agent #33, #34, #31, #30, #32):

- upstream send counters + getDiagnostics() (attempted/queued/skipped/threw,
  split raw/wire bytes, per-outcome timestamps; reset per transport generation)
- typed connection-lifecycle events (attempt / setup-ok / setup-failed /
  attempt-close / generation-close, correlated by connectAttemptId)
- onUsageMetadata (promptTokenCount et al), reachable from VoiceSession
- mediaResolution passthrough (session-wide media token cost)
- optional compressionConfig thresholds ({} = server defaults), sent as the
  int64-over-JSON strings the API expects

Plus review hardening from the merge train: observer isolation on both new
callback surfaces, reconnect replay counted AND moved off the deprecated
`media` field onto the supported video/audio slots, and superseded-dial
setup-failed fenced like every other stale-dial signal.

Pin only; no sutando code change. Consumers land separately (Phase 1: ledger
sampling, logicalSessionId, up=/ctx= health segments, shadow matrix).

Evidence:

Full TS suite at this pin: 1096 pass / 0 fail / 2 skipped.

Live round trip through the PINNED artifact (not the SDK directly) against
gemini-2.5-flash-native-audio-preview-12-2025 — connect with
mediaResolution=LOW + compressionConfig:{} accepted; text in -> model audio out;
every new surface read while doing it:

  connected in 304ms; lifecycle so far: attempt -> setup-ok
  text slot     : {"attempted":1,"queued":1,"queuedRawBytes":26}
  generation    : 1
  audio chunks  : 1 (first after 1442ms)
  usage samples : 1 promptTokenCount 375 -> 375
  lifecycle full: attempt -> setup-ok -> generation-close
  LIVE ROUND TRIP: PASS

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(voice): P7 Tranche B — upstream ledger, lineage, ctx gauge, shadow matrix

The health matrix's most common verdict on 2026-08-17 was
insufficient-evidence (speech-without-model-event; upstream-hop-unobserved) —
28 times in one day. The right verdict, and a useless one: up=n/a on every
line because the agent→Gemini hop had no counters. The bumped bodhi pin
exposes them; this wires the sutando half, dark.

Ledger (voice-audio-health.ts):
- coverage becomes an ordered LedgerCoverage level with observesUpstreamSend();
  the live snapshot stays 'session-only'.
- samples bodhi's VoiceSession.getDiagnostics() on ledger ticks only (never
  the frame path; §D7.0b untouched). Absent or throwing ⇒ up=n/a — null means
  unobserved, never zero.
- [Health] renders up={aQ/s,aKB/s,vQ/s,vKB/s,drop,buf:n/a} from per-tick
  deltas; a generation change renders that tick from zero rather than a
  negative rate. ctx={tok,age} appears once usage metadata is seen.
- logicalSessionId/lineageState derived sutando-side from lifecycle events
  (design §1.1): minted on a handle-less setup-ok; resumed keeps it;
  suspected-sever (setup-failed with a handle, or handle-accepted 1008 close)
  is TERMINAL — only a fresh lineage exits it, by minting. Context occupancy
  belongs to the lineage and resets with it.
- persisted rows (full and reduced) carry every evaluator input: counters,
  per-outcome timestamps, generation, lineage, contextTokens, echoSuppressed.

Matrix (voice-health-matrix.ts):
- structural guard BEFORE every delta row: a transport-generation change
  inside the window is insufficient-evidence — upstream counters reset per
  generation, so row 5's timestamps are as unusable across it as row 4's
  deltas.
- row 4 upstream-send-dead: eligible ingress, zero attempts — a dead call
  path. Row 4b upstream-send-failing: attempts, zero queued, named by guard
  outcome. Both exclude echo-suppressed windows (suppression returns before
  sendAudio and would masquerade as a dead path).
- row 5 renamed model-silent → post-sdk-silent, reachable only through the
  lossless-window proof: dAttempted >= dDelivered ∧ dQueued == dAttempted ∧
  lastQueuedAt >= speech.lastAboveFloorAt. Aggregate counters cannot say WHICH
  frame queued; losslessness proves the speech-bearing ones necessarily did.
  A lossy window stays insufficient-evidence — at aggregate resolution it
  genuinely cannot distinguish "model went quiet" from "we dropped the speech".

voice-agent.ts:
- onUsageMetadata/onConnectionLifecycle wired into the VoiceSession config;
  getSessionDiagnostics injected into the ledger.
- a SHADOW evaluator runs the 'session+egress' ruleset every health tick and
  logs [Matrix-shadow] on verdict change, telemetry-only. Canary 3a later
  compares live vs shadow on identical input; without the shadow, 3a has no
  baseline. The live verdict stream is byte-for-byte unchanged.
- the [Health] line now carries vision={sent,defB,defG,disp} while streaming —
  whether the vision throttle engaged was previously unknowable from the log
  (deferredBudget existed but was never surfaced; investigation F2).

Tests: 7 new ledger cases (lineage state machine incl. terminal
suspected-sever, ctx latch+reset, up= rendering, generation-reset rendering,
n/a on unobserved/throwing, replayable persisted row) and 8 new/updated
matrix cases (rows 4/4b, structural guard, lossless/lossy/order-unproven,
echo exclusion, live-at-session-only unchanged, row-5 rename). Full suite:
1109 pass / 0 fail / 2 skipped. tsc clean. prose-cap PASS.

Stacked on voice/bodhi-pin-b1-b5 (#3131); merge order pin → this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(voice): P7 Tranche B round 2 — seven codex findings on #3132, all confirmed

- Matrix: an unobserved→observed diagnostics transition could FABRICATE
  post-sdk-silent — the generation guard fired only when both generations
  were non-null, while unobserved counters baselined as placeholder zeros,
  so generation-lifetime totals diffed as one window (the review repro'd
  it). Upstream deltas now require a valid window: observed baseline, same
  generation. Rows 4/4b gate on it; row 5 downgrades to
  insufficient-evidence 'upstream-baseline-unobserved'.
- Matrix: MatrixResult gains the §1.5 facts — attemptedAudioAdvanced,
  queuedAudioAdvanced, losslessWindowWithSpeechQueued,
  transportGenerationChanged, echoSuppressedAdvanced — computed before any
  row can return, so every result carries an honest set. The watchdog
  integration reads facts, never verdict strings; verdict-independence
  needs them to exist.
- Matrix: row 4b named the guard outcome from generation-lifetime totals,
  letting earlier skips masquerade as the current failure mode. The
  baseline now carries skippedNoSession/threw; reasons are window deltas.
- Ledger: up= rendered cumulative totals over one dt after a missed
  diagnostics sample (900 queues → "30.0/s" out of thin air). A tick with
  no adjacent observed baseline renders up=n/a(baselining) and establishes
  the window; rates resume next tick. A generation change WITH an adjacent
  baseline still renders from zero — the new generation's counters span at
  most the window.
- Ledger: §1.1's generation→lineage reconciliation records now exist —
  every lineage commit/re-label persists a reason:'lineage' row carrying
  connectAttemptId, transportGeneration, resolved logicalSessionId, final
  state, so offline analysis re-attributes earlier rows instead of
  trusting a stale in-row id. A failed handle-less dial commits nothing
  and emits nothing.
- Ledger: the reduced persistence row was NOT replayable — flattened
  upstream fields, missing speech/heartbeat/clientTotals/lastModelEventAt.
  It now carries every matrix-evaluator input SNAPSHOT-SHAPED (§1.6
  option 1) plus the recorded decision: verdict, facts, reasons (§1.6
  option 2). A row still over budget strips only the episode list and says
  so. Replay is pinned: the evaluator produces an identical baseline,
  verdict, and facts from the full and reduced forms of the same tick.
- voice-agent: promptTokensDetails now reaches the ledger and both
  persistence payloads — §1.4's modality split is what separates "video
  filled the context" from "conversation did". Held and reset per lineage
  like the scalar it details.

Tests: 9 new/extended cases pin the two review repros (null→observed
fabrication, missed-sample rate), 4b window-delta naming, facts honesty at
every coverage, reconciliation records, the modality breakdown, and
full/reduced replay equivalence. Full suite green; tsc clean; eslint 0
errors (14 pre-existing warnings untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): matrix facts are data-level — coverage- and verdict-independent

Codex round 3, two findings on the round-2 commit, both confirmed:

1. Facts depended on coverage and on the verdict path: identical
   diagnostics produced all-false upstream facts under 'session-only' but
   true facts under 'session+egress', and a CONNECTING tick returned before
   facts were derived. §1.5's obligation is the opposite — the watchdog
   reads facts and must behave identically under every coverage mode and
   verdict, so a future coverage flip must not change watchdog behaviour.
   Facts are now computed FIRST, from data validity alone (adjacent
   same-epoch baseline, both generations observed and equal): facts say
   what the data shows; coverage gates only which VERDICTS may be claimed.
   The live verdict stream is unchanged — facts never drive verdicts.

2. upstreamWindowValid required only the BASELINE generation to be non-null.
   A typed sample with counters present but a null CURRENT generation would
   diff counters across generations and could emit post-sdk-silent. The
   window now requires both ends observed; the row-5 downgrade reason is
   renamed 'upstream-baseline-unobserved' → 'upstream-window-unobserved'
   since it covers both directions.

Tests: the session-only facts test inverted into the §1.5 pin (same input,
same facts, different claim across coverage modes); observed→null direction
pinned; a new verdict-independence case proves an early reconnect-window
return carries the same facts as the ACTIVE evaluation of identical input.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(voice): name why the clamp cannot hide a silent reset

sonichi asked whether a counter reset without a generation bump would render
aQ/s:0.0 indistinguishably from 'the agent stopped sending'. It cannot: at the
pinned bodhi commit the reset and the generation bump are adjacent lines in one
synchronous block, and each is the only such site in the file. Recording the
commit so the next reader re-checks the coupling instead of re-deriving it.

* docs(voice): the try/catch guards the TDZ, not the typeof

qingyun-wu's review nit, verified: typeof on a let/const inside its temporal
dead zone throws ReferenceError rather than yielding 'undefined', so the
typeof check cannot be what makes a tick racing construction safe. Behaviour
was already correct — only the comment credited the wrong mechanism.

* fix(voice): an unobserved upstream baseline must not read as a real zero

john-the-dev's review finding. makeBaseline collapsed the four upstream
counters to 0 when `upstream` was absent, while upstreamWindowObserved
inferred the previous tick's observedness from transportGeneration being
non-null. Those are independent fields off the same diag, so a baseline with a
real generation and absent counters passed the guard and the next tick diffed
against placeholder zeros — turning lifetime totals into a "window" and letting
losslessWindowWithSpeechQueued go true on a window that was never measured.

The counters are now `number | null` with no `?? 0`, and the guard checks the
counter itself rather than using generation-nullness as a proxy. Unobserved is
now unrepresentable as zero instead of relying on two fields moving together.

Defence-in-depth at the current pin, not a live bug: bodhi's getDiagnostics
(0d506ead:575) returns upstream and transportGeneration unconditionally, and
`upstream` is always an object — so the two cannot diverge today. That is an
invariant held in another repo, which is exactly why the guard should not
depend on it.

Also amends the structural-guard comment: it is gated on observesUpstreamSend,
so below session+egress row 5 is protected by its own honesty branch, not by
that guard.

Control: the added test fails without the fix (39/40) and passes with it (40/40).

* fix(voice): echoSuppressed carries the same unobserved-vs-zero defect

john-the-dev's follow-up to 2af342d: that fix made the four upstream
counters nullable but left echoSuppressed on `?? 0`, which fires when the
whole `diag` is null — reachable by construction, since sampleDiagnostics()
returns null whenever getSessionDiagnostics is absent or throws, and the
option's own doc says "Absent or throwing => upstream stays unobserved".

So an unobserved tick recorded 0, and the next observed tick turned a
generation-lifetime total into a one-window delta on echoSuppressedAdvanced,
which the watchdog reads. Same violation of this module's stated contract
("Null fields mean UNOBSERVED ... never zero"), one field over; it escaped
because the nullability lived in the container rather than the field.

echoSuppressed is now `number | null` on both SessionDiagnosticsSample and
AudioHealthSnapshot, the `?? 0` is gone, and dEchoSuppressed is gated on both
ends being observed — the same shape as its siblings.

Also drops a dead assertion they caught in the previous regression test:
`r.facts.upstreamWindowObserved` is a local const, not a MatrixFacts member,
so `?? false` made it unfailable. Replaced with two real members.

Control: the added test fails without the fix (40/41) and passes with it
(41/41).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants