Let the Persistent Mind call the user when nothing on screen can reach them - #5375
Merged
Conversation
…n reach them The mind could only speak through the Mind tab or a browser voice tab, so a user who had walked away was unreachable — and its capability boundary explicitly forbade external messaging. This adds `voice.call-user` as a separate, default-off grant that places a FaceTime Audio call to the single handle already configured in Settings > Voice. The model's `callRequest` carries a reason and an opening line but no recipient, so a confused turn cannot dial anyone else. The gate (server/services/persistentMindCallCapability.js) runs after inference and refuses the call whenever the grant is off, the FaceTime feature or voice is disabled, no identity is saved, a browser tab could speak the message instead, the user's local time is inside voice quiet hours, a call is already up, or the budget is spent — at most 3 calls per rolling 24 hours, at least 30 minutes apart. Those counters live in durable Persistent Mind state (schema 4 -> 5, migration 313) because an in-memory cap would hand back a fresh allowance on every restart, and the budget is charged only to a call that actually went out. Every decision, placed or suppressed, lands on the trajectory as mind.call.*; the dialed handle never does. A suppressed request is appended to the turn's reply, so the mind cannot claim it called when the phone never rang. A placed call speaks its opening line the moment the far end picks up, runs with the mind's persona and a bounded briefing of its trajectory, and hands the outcome back as a Persistent Mind message on hangup — including when nobody answered, which is exactly when the mind would otherwise redial to say the same thing. Critical-notification escalation (Settings > Voice, off by default) shares the gate and the budget but is authorized by facetime.escalateCritical rather than the mind's grant: a critical notification still unread after escalateAfterMinutes with no voice tab available asks to ring the user. Capabilities schema 3 -> 4, accepting every prior wire version on input so an older client bundle can still toggle the grants it knows about. An install upgrading with the mind already running gains nothing until the user opts in. Part of #5306.
# Conflicts: # docs/features/voice.md # server/lib/socketEventCatalog.generated.json # server/sockets/voice.js # server/sockets/voice.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
voice.call-userlets the Persistent Mind place a FaceTime Audio call to the single handle already configured in Settings > Voice. The model'scallRequestcarries a reason and an opening line but no recipient, so a confused turn cannot dial anyone else. Capabilities schema 3 → 4 (every prior wire version still accepted on input); an install upgrading with the mind already running gains nothing until the user opts in.server/services/persistentMindCallCapability.js) and refuses on: grant off, FaceTime feature off, voice disabled, no saved identity, a browser tab that could speak instead, voice quiet hours, a call already up, or the budget — at most 3 calls per rolling 24 h, at least 30 min apart.callHistory, schema 4 → 5, migration 313) because an in-memory cap would hand back a fresh allowance on every restart. The budget is charged only to a call that actually went out.mind.call.requested+mind.call.placed/mind.call.suppressed; the dialed handle never is. A suppressed request is appended to the turn's reply so the mind cannot claim it called when the phone never rang.systemContextonrunTurn), and hands the outcome back as a Persistent Mind message on hangup — including when nobody answered.facetime.escalateCritical, not the mind's grant: acriticalnotification still unread afterescalateAfterMinutes(default 10) with no voice tab available asks to ring the user.docs/features/cos-enhancement.mdanddocs/features/voice.md.Part of #5306.
Closes #5309
Test plan
server/services/persistentMindCallCapability.test.js(new, 14 cases): each gate in isolation, the placed path, the fourth call in 24 h asrate-capped, the 30-minute gap astoo-soon, a failed dial not spending the budget, escalation calling without the mind grant but under the same caps, no handle on the trajectory, and every returned reason being a declared one.server/lib/persistentMind.test.js: rate verdict across the rolling window, ageing out, a future timestamp failing closed, the ledger staying bounded and handle-free.server/services/voice/proactiveTriggers.test.js: escalation fires only after the delay, only while unread, only forcritical, only while enabled (re-read at fire time), one timer per notification, cleared on unwire.server/sockets/voice.test.js: the opening line is spoken into a connected call exactly once across repeated state broadcasts, and not at all for a user-placed call.server/services/voice/callSession.test.js: consume-once opening line, briefing scoped to a mind call, transcript handed back on hangup (and on an unanswered call), clean end when the handoff fails.server/services/persistentMindAdapter.test.js: the request runs once on the terminal answer (not an intermediate tool round), and a suppression is surfaced in the reply.scripts/migrations/313-persistent-mind-call-history.test.js: seeds an empty ledger, never resets an existing one, idempotent, tolerant of missing/invalid state.server(35 527) andclient(10 260) suites pass, plus client lint and build. API/socket catalogs regenerated.