Add Hermes operations management layer

This commit is contained in:
Yahya Alhinai
2026-06-28 02:17:23 +00:00
parent 89893110f1
commit 4253921532
29 changed files with 1017 additions and 71 deletions
+39
View File
@@ -0,0 +1,39 @@
import type { Room } from '@livekit/rtc-node';
import type { Collision, DataMessage, HermesMessage, Intervention } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared';
const encoder = new TextEncoder();
function teammateText(collision: Collision, intervention: Intervention): string {
return `${collision.engineers.join(', ')}: ${intervention.message}`;
}
export function createHermesMessage(
collision: Collision,
intervention: Intervention,
): HermesMessage {
return {
id: `hermes_${Date.now()}`,
podId: collision.podId,
interventionId: intervention.id,
recipients: collision.engineers,
text: teammateText(collision, intervention),
urgency: collision.severity === 'critical' ? 'urgent' : 'normal',
createdAt: new Date().toISOString(),
};
}
export async function publishHermesMessage(
room: Room,
collision: Collision,
intervention: Intervention,
): Promise<void> {
const data: DataMessage = {
type: 'HERMES_MESSAGE',
message: createHermesMessage(collision, intervention),
};
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
reliable: true,
topic: DATA_TOPIC,
});
}
+17 -4
View File
@@ -9,6 +9,7 @@ import { getGitStates } from '../memory/db.js';
import { recallSimilar } from '../memory/vectors.js';
import { shouldIntervene, preferredAction } from '../memory/policy.js';
import { speak } from '../voice/live.js';
import { publishHermesMessage } from '../action/hermes.js';
export class PodMan {
private contexts = new Map<string, EngineerContext>();
@@ -54,7 +55,7 @@ export class PodMan {
}
private async handle(collision: Collision): Promise<void> {
const prior = await recallSimilar(collision); // Loop A: vector recall raises confidence
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
@@ -64,7 +65,11 @@ export class PodMan {
const message =
`${names} are both editing ${collision.file}` +
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
(prior ? ` I've seen this conflict pattern before.` : '');
(prior?.priorOutcome?.accepted
? ` I've seen this conflict pattern before; last time the team accepted the ${prior.priorIntervention?.suggestedAction.kind.replaceAll('_', ' ') ?? 'suggested'} action.`
: prior
? ` I've seen this conflict pattern before.`
: '');
const intervention: Intervention = {
id: `int_${Date.now()}`,
@@ -72,7 +77,14 @@ export class PodMan {
podId: this.podId,
kind: 'card',
message,
suggestedAction: { kind: action },
suggestedAction: {
kind: action,
params: {
file: collision.file,
summary: message,
engineers: collision.engineers,
},
},
status: 'pending',
createdAt: new Date().toISOString(),
};
@@ -83,6 +95,7 @@ export class PodMan {
reliable: true,
topic: DATA_TOPIC,
});
await speak(this.room, message); // Gemini voice audio into the room
await publishHermesMessage(this.room, collision, intervention);
if (collision.severity === 'critical') await speak(this.room, message);
}
}
+2
View File
@@ -25,6 +25,7 @@ export const env = {
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']),
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-3.1-flash-tts-preview'),
GEMINI_EMBEDDING_MODEL: opt('GEMINI_EMBEDDING_MODEL', 'gemini-embedding-001'),
// GitHub
GITHUB_TOKEN: req('GITHUB_TOKEN'),
GITHUB_REPO: req('GITHUB_REPO'), // owner/name
@@ -34,6 +35,7 @@ export const env = {
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
// Server
PORT: Number(opt('PORT', '8787')),
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
} as const;
export function repoParts(): { owner: string; repo: string } {
+33 -1
View File
@@ -39,13 +39,45 @@ export async function createSyncPr(input: { headBranch: string; file: string; su
ref: `refs/heads/${branch}`,
sha: mainRef.object.sha,
});
const artifactPath = `podman-sync-artifacts/${branch}.md`;
const body = [
'# PodMan Sync Artifact',
'',
`- File: \`${input.file || 'unknown'}\``,
`- Source branch hint: \`${input.headBranch || 'not provided'}\``,
`- Created: ${new Date().toISOString()}`,
'',
'## Coordination Summary',
'',
input.summary || 'PodMan detected a coordination risk before the relevant work was pushed.',
'',
'## Suggested Next Step',
'',
'Coordinate ownership before pushing or merging overlapping local work.',
'',
].join('\n');
await gh.rest.repos.createOrUpdateFileContents({
owner,
repo,
branch,
path: artifactPath,
message: `PodMan sync artifact for ${input.file || 'active work'}`,
content: Buffer.from(body).toString('base64'),
});
const { data: pr } = await gh.rest.pulls.create({
owner,
repo,
title: `PodMan: sync ${input.file} before collision`,
head: branch,
base: 'main',
body: input.summary,
body: [
input.summary,
'',
`PodMan created a visible sync artifact at \`${artifactPath}\` so the team can coordinate before pushing overlapping work.`,
].join('\n'),
});
return pr;
}
+1
View File
@@ -99,6 +99,7 @@ export async function initMemory(): Promise<void> {
'collisions.memorySignature',
() => c.collisions.createIndex({ podId: 1, memorySignature: 1 }),
],
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
];
+31 -11
View File
@@ -1,17 +1,37 @@
import type { Collision, SuggestedActionKind } from '@podman/shared';
import type { RecalledCollision } from './vectors.js';
/**
* Policy gate: decides whether PodMan should intervene.
* Stub: always intervene on warn/critical.
*/
export function shouldIntervene(collision: Collision, _prior: unknown): boolean {
return collision.severity !== 'info';
const lastNudgeByPod = new Map<string, number>();
function cooldownMs(): number {
return Number(process.env.NUDGE_COOLDOWN_MS ?? '180000');
}
/**
* Preferred action selection based on collision + prior history.
* Stub: open sync PR for critical, ping teammate otherwise.
*/
export function preferredAction(collision: Collision, _prior: unknown): SuggestedActionKind {
/** Policy gate: combines severity, exact recall outcomes, and per-pod cooldown. */
export function shouldIntervene(collision: Collision, prior: RecalledCollision | null): boolean {
if (collision.severity === 'info') return false;
const priorOutcome = prior?.priorOutcome;
if (priorOutcome && !priorOutcome.accepted && !priorOutcome.wasRealCollision) return false;
const cooldown = cooldownMs();
const last = lastNudgeByPod.get(collision.podId) ?? 0;
if (cooldown > 0 && Date.now() - last < cooldown && collision.severity !== 'critical') {
return false;
}
lastNudgeByPod.set(collision.podId, Date.now());
return true;
}
/** Preferred action selection based on collision severity and prior accepted actions. */
export function preferredAction(
collision: Collision,
prior: RecalledCollision | null,
): SuggestedActionKind {
const acceptedKind = prior?.priorOutcome?.accepted
? prior.priorIntervention?.suggestedAction.kind
: undefined;
if (acceptedKind && acceptedKind !== 'none') return acceptedKind;
return collision.severity === 'critical' ? 'open_sync_pr' : 'ping_teammate';
}
+8 -1
View File
@@ -34,7 +34,14 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
}
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
await persist('outcome', async () => (await collections()).outcomes.insertOne({ ...outcome }));
await persist('outcome', async () => {
const c = await collections();
await c.outcomes.insertOne({ ...outcome });
await c.interventions.updateOne(
{ id: outcome.interventionId },
{ $set: { status: outcome.accepted ? 'accepted' : 'dismissed' } },
);
});
}
/** Document counts per collection — used by the /api/memory/stats endpoint. */
+116 -19
View File
@@ -1,4 +1,4 @@
import type { Collision } from '@podman/shared';
import type { Collision, Intervention, InterventionOutcome } from '@podman/shared';
import { env } from '../env.js';
import { getDb } from './db.js';
@@ -6,18 +6,35 @@ type StoredCollision = Collision & {
memorySignature?: string;
memoryText?: string;
embedding?: number[];
embeddingProvider?: string;
};
export type RecalledCollision = Collision & {
priorIntervention?: Intervention;
priorOutcome?: InterventionOutcome;
};
interface VoyageEmbeddingResponse {
data?: Array<{ embedding?: number[] }>;
}
interface GeminiEmbeddingResponse {
embedding?: { values?: number[] };
}
function normalize(value: string | undefined): string {
return (value ?? '').trim().toLowerCase();
}
function signature(collision: Collision): string {
return [normalize(collision.file), normalize(collision.symbol)].filter(Boolean).join('#');
return [
normalize(collision.file),
normalize(collision.symbol),
[...collision.engineers].sort().map(normalize).join('+'),
'collision',
]
.filter(Boolean)
.join('#');
}
function memoryText(collision: Collision): string {
@@ -33,6 +50,13 @@ function memoryText(collision: Collision): string {
}
async function embed(text: string, inputType: 'document' | 'query'): Promise<number[] | null> {
return (await embedWithVoyage(text, inputType)) ?? embedWithGemini(text, inputType);
}
async function embedWithVoyage(
text: string,
inputType: 'document' | 'query',
): Promise<number[] | null> {
if (!env.VOYAGE_API_KEY) return null;
try {
const res = await fetch('https://api.voyageai.com/v1/embeddings', {
@@ -59,6 +83,38 @@ async function embed(text: string, inputType: 'document' | 'query'): Promise<num
}
}
async function embedWithGemini(
text: string,
inputType: 'document' | 'query',
): Promise<number[] | null> {
try {
const taskType = inputType === 'document' ? 'RETRIEVAL_DOCUMENT' : 'RETRIEVAL_QUERY';
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
env.GEMINI_EMBEDDING_MODEL,
)}:embedContent?key=${encodeURIComponent(env.GEMINI_API_KEY)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: { parts: [{ text }] },
taskType,
outputDimensionality: 768,
}),
},
);
if (!res.ok) {
console.warn(`[memory] gemini embedding failed: ${res.status} ${await res.text()}`);
return null;
}
const body = (await res.json()) as GeminiEmbeddingResponse;
return body.embedding?.values ?? null;
} catch (err) {
console.warn(`[memory] gemini embedding failed: ${(err as Error).message}`);
return null;
}
}
export async function enrichCollisionMemory(collision: Collision): Promise<StoredCollision> {
const text = memoryText(collision);
const embedding = await embed(text, 'document');
@@ -66,11 +122,40 @@ export async function enrichCollisionMemory(collision: Collision): Promise<Store
...collision,
memorySignature: signature(collision),
memoryText: text,
...(embedding ? { embedding } : {}),
...(embedding
? { embedding, embeddingProvider: env.VOYAGE_API_KEY ? 'voyage' : 'gemini' }
: {}),
};
}
async function recallByVector(collision: Collision): Promise<Collision | null> {
async function attachOutcome(match: StoredCollision): Promise<RecalledCollision> {
const db = await getDb();
const intervention = await db
.collection<Intervention>('interventions')
.findOne({ collisionId: match.id }, { sort: { createdAt: -1 }, projection: { _id: 0 } });
const outcome = intervention
? await db
.collection<InterventionOutcome>('outcomes')
.findOne(
{ interventionId: intervention.id },
{ sort: { recordedAt: -1 }, projection: { _id: 0 } },
)
: null;
const {
memorySignature: _memorySignature,
memoryText: _memoryText,
embedding: _embedding,
embeddingProvider: _embeddingProvider,
...collision
} = match;
return {
...collision,
...(intervention ? { priorIntervention: intervention } : {}),
...(outcome ? { priorOutcome: outcome } : {}),
};
}
async function recallByVector(collision: Collision): Promise<RecalledCollision | null> {
const queryVector = await embed(memoryText(collision), 'query');
if (!queryVector) return null;
@@ -93,31 +178,43 @@ async function recallByVector(collision: Collision): Promise<Collision | null> {
{ $project: { _id: 0, embedding: 0 } },
])
.toArray();
return match ?? null;
return match ? attachOutcome(match) : null;
} catch (err) {
console.warn(`[memory] vector recall unavailable: ${(err as Error).message}`);
return null;
}
}
async function recallBySignature(collision: Collision): Promise<Collision | null> {
async function recallBySignature(collision: Collision): Promise<RecalledCollision | null> {
const db = await getDb();
const sig = signature(collision);
const match = await db.collection<StoredCollision>('collisions').findOne(
{
podId: collision.podId,
id: { $ne: collision.id },
$or: [{ memorySignature: sig }, { file: collision.file }],
},
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 } },
);
return match ?? null;
const matches = await db
.collection<StoredCollision>('collisions')
.find(
{
podId: collision.podId,
id: { $ne: collision.id },
$or: [{ memorySignature: sig }, { file: collision.file }],
},
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 }, limit: 10 },
)
.toArray();
let fallback: RecalledCollision | null = null;
for (const match of matches) {
const recalled = await attachOutcome(match);
if (!fallback) fallback = recalled;
if (recalled.priorOutcome?.accepted && recalled.priorOutcome.wasRealCollision) {
return recalled;
}
}
return fallback;
}
/**
* Recall prior collision patterns. Exact Mongo recall is always available;
* Voyage + Atlas Vector Search is used first when configured.
* Recall prior collision patterns. Exact Mongo recall is the MVP path;
* vector search is an optional broader fallback when Atlas is configured.
*/
export async function recallSimilar(collision: Collision): Promise<Collision | null> {
return (await recallByVector(collision)) ?? recallBySignature(collision);
export async function recallSimilar(collision: Collision): Promise<RecalledCollision | null> {
return (await recallBySignature(collision)) ?? recallByVector(collision);
}