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 <noreply@anthropic.com>
This commit is contained in:
Kartikeya
2026-06-28 01:28:46 -07:00
parent a80a737ea7
commit 5ac66c0a02
+37 -2
View File
@@ -7,7 +7,6 @@ import {
recordObservation, recordObservation,
recordCollision, recordCollision,
recordIntervention, recordIntervention,
hasRecentInterventionForCollision,
updateInterventionStatus, updateInterventionStatus,
} from '../memory/store.js'; } from '../memory/store.js';
import { getGitStates } from '../memory/db.js'; import { getGitStates } from '../memory/db.js';
@@ -17,6 +16,14 @@ import { publishHermesIntervention } from '../action/hermes.js';
export class PodMan { export class PodMan {
private contexts = new Map<string, EngineerContext>(); private contexts = new Map<string, EngineerContext>();
/**
* 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<string>();
constructor( constructor(
private room: Room, private room: Room,
@@ -60,15 +67,43 @@ export class PodMan {
const github = await getGithubState(); // cached const github = await getGithubState(); // cached
const collisions = detectCollisions([...this.contexts.values()], github, gitStates); 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); 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<void> { private async handle(collision: Collision): Promise<void> {
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 const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
if (prior) collision.severity = 'critical'; if (prior) collision.severity = 'critical';
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate 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); await recordCollision(collision);
const action = preferredAction(collision, prior); const action = preferredAction(collision, prior);
const names = collision.engineers.join(' + '); const names = collision.engineers.join(' + ');