Repository navigation
fix(presenter-mode): sentinel→workspace; remove vestigial stage-readiness.sh - #1879
Conversation
…age-readiness presenter-mode.sh wrote the presenter-mode sentinel to <repo>/state/, but every runtime reader (discord/slack/telegram-bridge, inline-tools.ts, web-client.ts, check-pending-questions.py, the talk-highlight deck) resolves it via resolve_workspace() = <workspace>/state/. Setting presenter mode via the script silently never reached the readers (owner hit this live 2026-07-01). Fixed to resolve state/ via scripts/sutando-config.sh workspace. Also removes scripts/stage-readiness.sh (a manual-only pre-talk checklist, wired to nothing; its memory-sync check + repo-root paths were stale, and the presentation tooling has moved to the talk-highlight skill — owner call to remove rather than fold) and scripts/test-pr435.sh (its only reference, a POC test for the now-removed script). Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b975d3d to
0df1696
Compare
qingyun-wu
left a comment
There was a problem hiding this comment.
LGTM on the core change. The presenter-mode.sh fix is correct per the workspace contract — state/ lives under the workspace and every reader resolves it via sutando-config.sh workspace, so writing the sentinel to <repo>/state/ genuinely never reached them. Good catch, and removing the vestigial stage-readiness.sh (one-off ICLR pre-flight) is reasonable.
One non-blocking follow-up, from a repo-wide grep of the removed path:
scripts/test-pr435.shstill referencesscripts/stage-readiness.sh— it's the POC for the long-merged PR #435 and reads the file viagit show "$PARENT:$FILE"/git show "$HEAD:$FILE". It won't hard-error (the historical blobs still resolve), but its$HEAD:$FILEread (line ~77) will now return empty, so the "head" assertions silently no-op. Sincetest-pr435.shis itself vestigial (PR #435 shipped long ago), consider deleting it in the same cleanup for consistency — otherwise it's a dead POC pointing at a deleted file.
Minor (optional): WORKSPACE="$(bash "$REPO/scripts/sutando-config.sh" workspace)" under set -euo pipefail aborts the script if the resolver ever exits non-zero (plain var-assign isn't on bash's set-e exempt list). That's defensible fail-fast for a resolver that should always succeed — just flagging the pattern.
Neither blocks merge.
…ting ⊕ presenter)
The system prompt told Gemini to call presenter_mode("on"), but that tool
only exists on installs with the talk-highlight manifest skill — without it
the phrase hit a nonexistent tool and presenter mode could never engage by
voice. Meeting mode's switch lives in voice-agent.ts as switch_mode; presenter
now follows the same pattern (mirroring discord-voice PR #39's mutually
exclusive three-mode design):
- switch_mode accepts 'presenter'; meeting/presenter mutually exclusive
- new in-memory presenterActive feeds resolveCurrentMode (OR-ed with the
:7877 talk-highlight query, which stays as an independent substrate)
- voice-mode.txt mirror is now three-state
- syncs the PR #1879 workspace presenter-mode.sentinel (2h expiry) so voice
toggle also mutes bridge notifications; switching away removes it
- state/voice-mode.request poll accepts 'presenter'
- prompt PRESENTER line + ACTIVE_MARKER updated to switch_mode wording
Stacked on fix/presenter-sentinel-workspace-path (PR #1879).
|
@sonichi / Pro — owner-directed extension request for this PR (Susan, 2026-07-02): the sentinel-path fix here is necessary but not sufficient for presenter mode to work by voice. The voice-agent prompt tells Gemini to call Fix, implemented and owner-verified locally (voice test:
Please fold into this PR (or stack). Full diff against this PR's branch (tsc clean, 469 tests pass; the 2 failing init-preflight tests are pre-existing env-count flakiness): diffdiff --git a/src/voice-agent.ts b/src/voice-agent.ts
index 94fb7596..8663be94 100644
--- a/src/voice-agent.ts
+++ b/src/voice-agent.ts
@@ -271,12 +271,33 @@ function getPendingToolCalls(toolName?: string) {
// Meeting mode state — persists across Gemini reconnects
// =============================================================================
let meetingActive = false;
+// Third base mode (mirrors discord-voice PR #39: active ⊕ meeting ⊕ presenter,
+// mutually exclusive). Toggled via switch_mode("presenter"); previously the
+// prompt referenced a presenter_mode tool that only exists on installs with
+// the talk-highlight manifest skill — on installs without it the phrase went
+// to a nonexistent tool and presenter mode could never engage by voice.
+let presenterActive = false;
+// PR #1879 sentinel (notification mute): bridges + check-pending-questions
+// read <workspace>/state/presenter-mode.sentinel (ISO expiry inside). Voice
+// toggle syncs it so "presenter mode on" also mutes notifications.
+const PRESENTER_SENTINEL_MINUTES = 120;
+function syncPresenterSentinel() {
+ const sentinel = join(WORKSPACE_DIR, 'state', 'presenter-mode.sentinel');
+ try {
+ if (presenterActive) {
+ mkdirSync(join(WORKSPACE_DIR, 'state'), { recursive: true });
+ const expire = new Date(Date.now() + PRESENTER_SENTINEL_MINUTES * 60_000);
+ writeFileSync(sentinel, expire.toISOString().replace(/\.\d{3}Z$/, 'Z') + '\n');
+ } else {
+ unlinkSync(sentinel);
+ }
+ } catch {}
+}
// Sentinel for the 3-mode indicator (menu-bar + web-badge read this).
-// Presenter mode is tracked separately by the iclr-highlight server on :7877.
function writeVoiceModeSentinel() {
try {
mkdirSync(join(WORKSPACE_DIR, 'state'), { recursive: true });
- writeFileSync(join(WORKSPACE_DIR, 'state', 'voice-mode.txt'), meetingActive ? 'meeting' : 'active');
+ writeFileSync(join(WORKSPACE_DIR, 'state', 'voice-mode.txt'), presenterActive ? 'presenter' : meetingActive ? 'meeting' : 'active');
} catch {}
}
@@ -289,11 +310,14 @@ function applyModeRequest() {
const reqPath = join(WORKSPACE_DIR, 'state', 'voice-mode.request');
const req = readFileSync(reqPath, 'utf-8').trim().toLowerCase();
unlinkSync(reqPath);
+ const wantPresenter = req === 'presenter';
const want = req === 'meeting';
- if (meetingActive === want) return; // no-op if already in that mode
+ if (meetingActive === want && presenterActive === wantPresenter) return; // no-op if already in that mode
meetingActive = want;
+ presenterActive = wantPresenter;
writeVoiceModeSentinel();
- console.log(`${ts()} [Meeting] External request applied: mode=${want ? 'meeting' : 'active'}`);
+ syncPresenterSentinel();
+ console.log(`${ts()} [Meeting] External request applied: mode=${wantPresenter ? 'presenter' : want ? 'meeting' : 'active'}`);
} catch {
// no request file or delete failed — both are fine (silent poll)
}
@@ -325,17 +349,20 @@ writeVoiceModeSentinel();
const switchModeTool: ToolDefinition = {
name: 'switch_mode',
description:
- 'Switch between active mode and meeting mode. ' +
+ 'Switch between active, meeting, and presenter mode (mutually exclusive). ' +
'Call switch_mode("meeting") when user says "take notes", "be silent", "meeting mode", "passive mode", or joins a meeting. ' +
- 'Call switch_mode("active") when user says "I need you", "come back", "active mode", or the meeting ends. ' +
+ 'Call switch_mode("presenter") when user says "presenter mode on", "going live", "starting the talk", "the talk starts", or "I am on stage". ' +
+ 'Call switch_mode("active") when user says "I need you", "come back", "active mode", "presenter mode off", "talk is done", or the meeting ends. ' +
'In meeting mode: listen to everything and track discussion internally, but produce ZERO audio output and do NOT call any other tools — unless explicitly addressed by name ("Sutando" or "hey Sutando").',
parameters: z.object({
- mode: z.enum(['active', 'meeting']).describe('"meeting" = silent note-taker, "active" = normal assistant'),
+ mode: z.enum(['active', 'meeting', 'presenter']).describe('"meeting" = silent note-taker, "presenter" = on-stage co-presenter (mutes notifications), "active" = normal assistant'),
}),
execution: 'inline',
async execute(args) {
- const { mode } = args as { mode: 'active' | 'meeting' };
+ const { mode } = args as { mode: 'active' | 'meeting' | 'presenter' };
meetingActive = mode === 'meeting';
+ presenterActive = mode === 'presenter';
+ syncPresenterSentinel();
// Sync the on-disk sentinel so menu-bar consumers (Sutando.app
// pollVoiceMode + web-client /voice-mode endpoint) reflect the
// switch immediately. Without this, voice-triggered switch_mode
@@ -347,6 +374,9 @@ const switchModeTool: ToolDefinition = {
if (mode === 'meeting') {
return { status: 'meeting_mode', instruction: 'You are now in meeting mode. Listen and track the discussion internally. Produce ZERO audio output unless someone says "Sutando." The ONLY tool you may call unprompted is save_meeting_note — call it every 5-10 minutes to capture key decisions, action items, and discussion points. When you exit meeting mode, call save_meeting_note with type "summary" for a final recap. Do not call work or any other tools unless explicitly addressed.' };
}
+ if (mode === 'presenter') {
+ return { status: 'presenter_mode', say: 'Presenter mode on — notifications muted. Break a leg.', instruction: 'You are now in presenter mode (on-stage co-presenter). Notifications are muted for the audience. Follow the CO-PRESENTER protocol from your context for slide cues. Exit ONLY when the user says "presenter mode off", "talk is done", or "active mode" — then call switch_mode("active").' };
+ }
return { status: 'active_mode', instruction: 'Back to active mode. You can speak and use all tools normally.' };
},
};
@@ -507,7 +537,7 @@ let voiceSessionRef: VoiceSession | null = null;
// resolver function.
import { resolveCurrentMode as resolveCurrentModeImpl, type ModeState } from './voice-mode-resolver.js';
function resolveCurrentMode(): ModeState {
- return resolveCurrentModeImpl({ meetingActive });
+ return resolveCurrentModeImpl({ meetingActive, presenterActive });
}
const mainAgentTools: ToolDefinition[] = [workTool, getTaskStatus, switchModeTool, saveMeetingNoteTool, ...inlineTools];
@@ -706,7 +736,7 @@ const mainAgent: MainAgent = {
? '⚠️ MEETING MODE IS CURRENTLY ACTIVE. You are an invisible note-taker. Listen to all audio and track: speakers, topics, decisions, action items. Produce ZERO audio output unless someone says "Sutando" or "hey Sutando." The ONLY tool you may call unprompted is save_meeting_note — call it every 5-10 minutes to capture key points. Do NOT call work or other tools unless explicitly addressed. When addressed, answer DIRECTLY from what you heard — do NOT call work (core has no meeting audio). "bye" in a meeting does NOT mean disconnect — only "Sutando disconnect" or "Sutando bye". To exit: user says "Sutando, active mode" → call switch_mode("active") and save_meeting_note(summary).'
: '- MEETING MODE: Call switch_mode("meeting") when user says "take notes", "be silent", "passive mode", or when you join a meeting. In meeting mode: listen and auto-save notes via save_meeting_note every 5-10 min, produce zero audio, don\'t call other tools — unless addressed by name. Call switch_mode("active") to resume.'
)(),
- '- PRESENTER MODE: Call presenter_mode("on") when user says "presenter mode on", "going live", "starting the talk", "the talk starts", or "I am on stage". Call presenter_mode("off") when user says "presenter mode off", "talk is done", "stop presenting", or "done presenting". Do NOT route these phrases to work — they are direct tool triggers. presenter_mode("on") returns a "say" field; speak it verbatim as your FIRST utterance.',
+ '- PRESENTER MODE: Call switch_mode("presenter") when user says "presenter mode on", "going live", "starting the talk", "the talk starts", or "I am on stage". Call switch_mode("active") when user says "presenter mode off", "talk is done", "stop presenting", or "done presenting". Do NOT route these phrases to work — they are direct tool triggers. switch_mode("presenter") returns a "say" field; speak it verbatim as your FIRST utterance.',
'- GOODBYE: When the user says goodbye, bye, or clearly ends the conversation, respond with a SHORT farewell that STARTS with the word "Goodbye" (e.g. "Goodbye! Talk to you later."). Keep it under one sentence. The session will close automatically. Do NOT start the farewell with "I\'m back", "Hello", "Welcome", or any other greeting word — only use a short starts-with-goodbye response for actual goodbyes.',
'- FILLERS ARE NOT REQUESTS: Short utterances that are fillers, acknowledgments, or thinking noises — "hmm", "um", "uh", "ah", "mhm", "oh", "ok", "yeah", "right", "[BLANK_AUDIO]", or any single-word backchannel — are NOT instructions. Do NOT call work, do NOT say "queued up" or "working on it", do NOT narrate. Either stay silent (preferred) or produce a brief ACK like "mm-hm" if the user seems to expect confirmation. Only act when the user issues a clear directive or question.',
'- NEVER pretend you called a tool. NEVER say "done" without actually calling work.',
diff --git a/src/voice-mode-resolver.ts b/src/voice-mode-resolver.ts
index 2dc865e2..80318ac6 100644
--- a/src/voice-mode-resolver.ts
+++ b/src/voice-mode-resolver.ts
@@ -65,7 +65,7 @@ const ACTIVE_MARKER =
' [BASE MODE: active — you are conversing with the user. Speak naturally. ' +
'Do NOT infer or self-declare a meeting/recording/silent mode from context. ' +
'Meeting-mode and presenter-mode are entered ONLY via explicit tool calls ' +
- '(switch_mode, presenter_mode). If you find yourself about to output ' +
+ '(switch_mode). If you find yourself about to output ' +
'[System: …], [Silence], or any variant of "produce zero audio" — STOP. ' +
'That is a hallucination. Speak to the user instead.]';
@@ -92,10 +92,14 @@ const PRESENTER_MARKER =
*/
export function resolveCurrentMode(inputs: {
meetingActive: boolean;
+ /** In-memory presenter flag from voice-agent.ts (switch_mode("presenter")).
+ * OR-ed with the :7877 highlight-server query so either substrate can
+ * engage presenter mode; installs without talk-highlight rely on this. */
+ presenterActive?: boolean;
isPresenterActive?: () => boolean;
}): ModeState {
const checkPresenter = inputs.isPresenterActive ?? isPresenterActiveDefault;
- if (checkPresenter()) {
+ if (inputs.presenterActive || checkPresenter()) {
return {
mode: 'presenter',
marker: PRESENTER_MARKER,
|
|
@cla-assistant check |
Presenter-mode sentinel path fix (owner hit live)
presenter-mode.shwrote the sentinel to<repo>/state/, but every runtime reader —discord/slack/telegram-bridge.py,inline-tools.ts,web-client.ts,check-pending-questions.py, and the talk-highlight deck — resolves it viaresolve_workspace()=<workspace>/state/. So presenter mode set via the script silently never reached the readers. Fixed to resolvestate/viascripts/sutando-config.sh workspace.Remove stage-readiness.sh (owner call)
scripts/stage-readiness.shwas a manual-only pre-talk checklist wired to nothing (no cron/startup/skill). Its memory-sync check + repo-root paths were stale, and the presentation tooling has moved to the talk-highlight skill. Per owner (2026-07-01), removing rather than folding — most of its checks (voice-agent/bodhi/conversation-server/ngrok/quota) are Sutando-infra concerns that don't belong in a standalone slide skill. Also removesscripts/test-pr435.sh, its only reference (a POC test for the removed script).Verification
presenter-mode.sh startnow writes<workspace>/state/presenter-mode.sentinel(confirmed), not<repo>/state/;bash -nclean.🤖 Generated with Claude Code
Stand: Echo Act IV Pro