From 5ac66c0a029b222f35d8f57b1ec078ec2aefb10d Mon Sep 17 00:00:00 2001 From: Kartikeya <176560021+karti-ai@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:28:46 -0700 Subject: [PATCH] fix(voice): edge-trigger conflict alerts so Hermes speaks once, not on a loop Both dedup gates (shouldIntervene per-pod cooldown + hasRecentInterventionForCollision per-file window) were purely time-based, so an unresolved conflict re-alerted every NUDGE_COOLDOWN_MS (30s on prod). Track active conflicts in-memory in PodMan and alert only on the transition into conflict; re-arm when a detection cycle no longer sees it. Co-Authored-By: Claude Opus 4.8 --- backend/src/agent/podman.ts | 39 +++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/backend/src/agent/podman.ts b/backend/src/agent/podman.ts index b2def92..9c448ef 100644 --- a/backend/src/agent/podman.ts +++ b/backend/src/agent/podman.ts @@ -7,7 +7,6 @@ import { recordObservation, recordCollision, recordIntervention, - hasRecentInterventionForCollision, updateInterventionStatus, } from '../memory/store.js'; import { getGitStates } from '../memory/db.js'; @@ -17,6 +16,14 @@ import { publishHermesIntervention } from '../action/hermes.js'; export class PodMan { private contexts = new Map(); + /** + * Conflicts we have already voiced and that are still unresolved, keyed by + * file (see conflictKey). Edge-triggered alerting: speak once when a conflict + * appears, stay quiet while it persists. A conflict is re-armed (deleted + * here) by onScreenFrame as soon as a detection cycle no longer sees it, so a + * resolved-then-recurring conflict alerts again. + */ + private activeConflicts = new Set(); constructor( private room: Room, @@ -60,15 +67,43 @@ export class PodMan { const github = await getGithubState(); // cached const collisions = detectCollisions([...this.contexts.values()], github, gitStates); + + // Re-arm: any conflict we previously voiced that is no longer present has + // resolved, so allow it to alert again if it recurs. + const current = new Set(collisions.map((c) => this.conflictKey(c))); + for (const key of this.activeConflicts) { + if (!current.has(key)) this.activeConflicts.delete(key); + } + for (const collision of collisions) await this.handle(collision); } + /** + * Stable identity for a conflict, independent of the Date.now() baked into + * collision.id. Mirrors comparableFile() in memory/store.ts so keys line up: + * strip any git-status prefix ("M ", "?? ") and reduce to a lowercased + * basename. + */ + private conflictKey(collision: Collision): string { + return ( + (collision.file ?? '') + .trim() + .replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '') + .split(/[\\/]/) + .pop() + ?.toLowerCase() ?? '' + ); + } + private async handle(collision: Collision): Promise { + const key = this.conflictKey(collision); + if (this.activeConflicts.has(key)) return; // single-shot: already voiced, still unresolved + const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence if (prior) collision.severity = 'critical'; if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate - if (await hasRecentInterventionForCollision(collision)) return; + this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution await recordCollision(collision); const action = preferredAction(collision, prior); const names = collision.engineers.join(' + ');