8 Commits

Author SHA1 Message Date
sb-iam 07509c62bd docs(graph): record the shadcn/ruixen component convention
UI composes from the ruixen registry (npx shadcn add ruixen.com/r/[component])
via @/components/ui/* + design tokens; only the SVG canvas is bespoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:48:05 -07:00
sb-iam a49501b3c4 refactor(ui): compose Team Memory from the shadcn/ruixen primitives
Per the unifying template (npx shadcn add ruixen.com/r/[component]): the chrome
now uses Button + Badge + the app's Tailwind utility patterns (matching StatPill/
App.tsx) inside the page shell, instead of a hand-rolled .pm-* stylesheet. Only
the SVG node-link canvas stays bespoke (3 SVG-only CSS rules). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:46:48 -07:00
sb-iam 7c572fbbcb feat(ui): re-theme Team Memory graph to match the shadcn app
The app is light shadcn; GraphView was hardcoded dark-Bauhaus and clashed.
Re-theme to shadcn tokens (var(--card)/--foreground/--muted-foreground/
--border, var(--primary) for active) so the panel is theme-aware (light now,
follows dark mode if toggled) and native to the command-center. Node-kind
encoding kept (shapes + light-readable hues); labels are sentence-case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:39:04 -07:00
Yahya Alhinai 39a06499af feat: add local vector recall fallback 2026-06-28 02:27:45 +00:00
Yahya Alhinai 146d7e4bd2 test: verify frontend screen sharing 2026-06-28 02:20:58 +00:00
Yahya Alhinai 4253921532 Add Hermes operations management layer 2026-06-28 02:17:23 +00:00
Yahya Alhinai 89893110f1 feat: activate gemini voice tts path 2026-06-28 02:11:02 +00:00
Yahya Alhinai 4726e8ce80 test: make frontend verifier target frontend pod 2026-06-28 02:02:06 +00:00
36 changed files with 1469 additions and 347 deletions
+19 -1
View File
@@ -7,8 +7,11 @@ LIVEKIT_API_SECRET=
# --- Gemini (vision + event detection + voice) ---
GEMINI_API_KEY=
# GOOGLE_API_KEY= also works as a local alias, but GEMINI_API_KEY is the
# canonical deployment secret name used by DigitalOcean and docs.
GEMINI_VISION_MODEL=gemini-2.0-flash
GEMINI_LIVE_MODEL=gemini-live-2.5-flash
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
# --- GitHub (repo state + sync PR artifacts) ---
GITHUB_TOKEN=
@@ -33,3 +36,18 @@ VITE_BACKEND_URL=http://localhost:8787
# --- Deployment verification ---
# Optional override when the deployed SPA and API use different origins.
FRONTEND_URL=http://localhost:4173
# --- Hermes operations watchdog ---
PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
PODMAN_PUBLIC_API_URL=https://165-22-129-249.sslip.io/api/pods
PODMAN_PUBLIC_HEALTH_URL=https://165-22-129-249.sslip.io/health
PODMAN_HERMES_REMEDIATE=1
PODMAN_HERMES_STRICT=0
PODMAN_HERMES_STATE_DIR=/var/log/podman
# Optional Discord/Slack/generic webhook for failed watchdog runs.
PODMAN_ALERT_WEBHOOK_URL=
# --- Hermes git-sync deploy loop ---
PODMAN_DEPLOY_REMOTE=origin
PODMAN_DEPLOY_BRANCH=main
PODMAN_DEPLOY_RESTART_SERVICES=podman-platform-api.service,podman-platform-agent.service,caddy.service
+23
View File
@@ -0,0 +1,23 @@
name: Hermes verify
on:
push:
branches: [main]
workflow_dispatch:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.32.1
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm -r typecheck
- run: pnpm -r build
+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-3.1-flash-live voice into the room
await publishHermesMessage(this.room, collision, intervention);
if (collision.severity === 'critical') await speak(this.room, message);
}
}
+11 -2
View File
@@ -5,6 +5,13 @@ function req(name: string): string {
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
function reqAny(primary: string, aliases: string[] = []): string {
for (const name of [primary, ...aliases]) {
const v = process.env[name];
if (v) return v;
}
throw new Error(`Missing required env var: ${primary}`);
}
function opt(name: string, fallback = ''): string {
return process.env[name] ?? fallback;
}
@@ -15,9 +22,10 @@ export const env = {
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
// Gemini
GEMINI_API_KEY: req('GEMINI_API_KEY'),
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-live-2.5-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
@@ -27,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. */
+148 -16
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 {
@@ -32,7 +49,30 @@ function memoryText(collision: Collision): string {
.join('\n');
}
function cosine(a: number[], b: number[]): number {
const n = Math.min(a.length, b.length);
let dot = 0;
let aNorm = 0;
let bNorm = 0;
for (let i = 0; i < n; i++) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
dot += av * bv;
aNorm += av * av;
bNorm += bv * bv;
}
if (!aNorm || !bNorm) return -1;
return dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
}
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 +99,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,16 +138,45 @@ 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;
try {
const db = await getDb();
try {
const [match] = await db
.collection<StoredCollision>('collisions')
.aggregate<StoredCollision>([
@@ -93,31 +194,62 @@ 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;
console.warn(`[memory] atlas vector recall unavailable: ${(err as Error).message}`);
}
const candidates = await db
.collection<StoredCollision>('collisions')
.find(
{
podId: collision.podId,
id: { $ne: collision.id },
embedding: { $exists: true },
},
{ projection: { _id: 0 }, limit: 100 },
)
.toArray();
let best: { match: StoredCollision; score: number } | null = null;
for (const candidate of candidates) {
if (!candidate.embedding?.length) continue;
const score = cosine(queryVector, candidate.embedding);
if (!best || score > best.score) best = { match: candidate, score };
}
return best && best.score > 0.5 ? attachOutcome(best.match) : 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(
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 } },
);
return match ?? null;
{ 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. Atlas Vector Search is preferred when
* available; standalone MongoDB falls back to app-side cosine search over
* stored embeddings, then exact signature/file matching.
*/
export async function recallSimilar(collision: Collision): Promise<Collision | null> {
export async function recallSimilar(collision: Collision): Promise<RecalledCollision | null> {
return (await recallByVector(collision)) ?? recallBySignature(collision);
}
+57 -19
View File
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import {
AudioFrame,
AudioSource,
@@ -12,6 +13,7 @@ import { env } from '../env.js';
const SAMPLE_RATE = 24_000;
const CHANNELS = 1;
const FRAME_SAMPLES = SAMPLE_RATE / 10;
const encoder = new TextEncoder();
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
@@ -44,37 +46,53 @@ function audioFrames(message: LiveServerMessage): AudioFrame[] {
return out;
}
/**
* Speak a message into the LiveKit room using Gemini Live audio. A data-channel
* VOICE_CUE is sent first so clients still get the cue if audio generation or
* publishing fails.
*/
export async function speak(room: Room, message: string): Promise<void> {
await publishVoiceCue(room, message);
if (!room.localParticipant) return;
function framesFromPcmBase64(data: string, mimeType?: string): AudioFrame[] {
const frame = audioFrameFromBase64(data, mimeType);
if (!frame) return [];
const source = new AudioSource(SAMPLE_RATE, CHANNELS);
const track = LocalAudioTrack.createAudioTrack('podman-hermes-voice', source);
const options = new TrackPublishOptions();
options.source = TrackSource.SOURCE_MICROPHONE;
const samples = frame.data;
const frames: AudioFrame[] = [];
for (let offset = 0; offset < samples.length; offset += FRAME_SAMPLES) {
const chunk = samples.subarray(offset, Math.min(offset + FRAME_SAMPLES, samples.length));
frames.push(new AudioFrame(chunk, SAMPLE_RATE, CHANNELS, chunk.length / CHANNELS));
}
return frames;
}
try {
const publication = await room.localParticipant.publishTrack(track, options);
async function generateTtsFrames(message: string): Promise<AudioFrame[]> {
const res = await ai.models.generateContent({
model: env.GEMINI_LIVE_MODEL,
contents: [{ parts: [{ text: message }] }],
config: {
responseModalities: [Modality.AUDIO],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
},
});
const parts = res.candidates?.[0]?.content?.parts ?? [];
return parts.flatMap((part) =>
framesFromPcmBase64(part.inlineData?.data ?? '', part.inlineData?.mimeType),
);
}
async function speakWithTts(source: AudioSource, message: string): Promise<void> {
for (const frame of await generateTtsFrames(message)) {
await source.captureFrame(frame);
}
}
async function speakWithLive(source: AudioSource, message: string): Promise<void> {
let done: () => void = () => {};
const donePromise = new Promise<void>((resolve) => {
done = resolve;
});
let session: Session | null = null;
session = await ai.live.connect({
const session: Session = await ai.live.connect({
model: env.GEMINI_LIVE_MODEL,
config: { responseModalities: [Modality.AUDIO] },
callbacks: {
onmessage: (event) => {
void (async () => {
for (const frame of audioFrames(event)) await source.captureFrame(frame);
if (event.serverContent?.turnComplete || event.serverContent?.generationComplete)
done();
if (event.serverContent?.turnComplete || event.serverContent?.generationComplete) done();
})();
},
onerror: (event) => {
@@ -92,6 +110,26 @@ export async function speak(room: Room, message: string): Promise<void> {
await Promise.race([donePromise, new Promise((resolve) => setTimeout(resolve, 15_000))]);
session.close();
}
/**
* Speak a message into the LiveKit room using Gemini Live audio. A data-channel
* VOICE_CUE is sent first so clients still get the cue if audio generation or
* publishing fails.
*/
export async function speak(room: Room, message: string): Promise<void> {
await publishVoiceCue(room, message);
if (!room.localParticipant) return;
const source = new AudioSource(SAMPLE_RATE, CHANNELS);
const track = LocalAudioTrack.createAudioTrack('podman-hermes-voice', source);
const options = new TrackPublishOptions();
options.source = TrackSource.SOURCE_MICROPHONE;
try {
const publication = await room.localParticipant.publishTrack(track, options);
if (env.GEMINI_LIVE_MODEL.includes('tts')) await speakWithTts(source, message);
else await speakWithLive(source, message);
if (publication.sid) await room.localParticipant.unpublishTrack(publication.sid, true);
await source.close();
} catch (err) {
+31 -25
View File
@@ -178,36 +178,41 @@ From the remote plan snapshot and health check on `2026-06-27`:
- Treat this as operational evidence, not architecture truth. Reverify before
demo.
### Partial / stubbed
### Partial / completed since the original audit
- `backend/src/voice/live.ts` logs only; it does not publish real voice/audio
into LiveKit yet.
- Hermes is a product/action/messaging layer in the plan, but the current repo
does not yet implement a complete Hermes notification bridge.
- `backend/src/memory/vectors.ts` is not a real Voyage/Atlas Vector Search
implementation yet.
- Exact-signature recall is the required MVP fallback before vectors.
- `backend/src/memory/policy.ts` is a simple gate; it does not learn thresholds
from outcomes yet.
- `POST /api/sync-pr` creates a PR artifact path but does not yet build a
meaningful sync diff.
- Frontend `PodView` has only a placeholder intervention area unless/until live
intervention rendering is wired.
- `backend/src/voice/live.ts` now publishes a `VOICE_CUE` fallback and attempts
Gemini audio publication into LiveKit. The agent only calls it for critical
interventions so voice remains an urgent escalation path.
- Hermes now has a data-channel teammate message path via `HERMES_MESSAGE` on
the existing `podman.intervention` topic. This is the MVP notification bridge,
not a Slack/Discord integration.
- `backend/src/memory/vectors.ts` implements exact-signature recall first and
can use Voyage/Gemini embeddings with Atlas Vector Search when configured.
- Exact-signature recall now attaches prior interventions/outcomes and prefers
accepted real collisions, giving the learning beat deterministic MongoDB
proof before vector search.
- `backend/src/memory/policy.ts` now uses severity, per-pod cooldown, and prior
outcome history. It is still a simple policy, not a trained threshold model.
- `POST /api/sync-pr` now creates a visible Markdown sync artifact commit before
opening the PR.
- Frontend `PodView` renders intervention cards, Hermes messages, voice cues,
and the accepted sync PR artifact link.
- Browser screen publishing exists, but the active join path must be proven to
tag tracks as screen share so the backend agent can filter them correctly. The
`origin/main` screen-share button appears to address this; local code remains
behind until that commit is merged.
- `GIT_REPORT` exists in shared types and agent handling. `scripts/podman-agent.mjs`
is the finished per-laptop git sidecar — polls every 15 s, upserts git fields
to `engineer_states` collection. Not yet wired to publish a `GIT_REPORT` data
channel message into the LiveKit room (agent fusion step still needed).
to `engineer_states` collection. The backend agent now fuses those Mongo
git-state fields into live contexts before collision detection; direct
LiveKit `GIT_REPORT` publication from the sidecar remains optional.
- Background research recommendations are a product requirement and demo goal,
not an implemented research agent yet.
- Deployment reliability is partial; API health is reachable, but API/static
site/worker together must still be reverified before demo.
- Env docs are inconsistent: backend defaults are `gemini-3.5-flash` and
`gemini-3.1-flash-live-preview`, while `.env.example` still lists older
Gemini model names.
- Env docs now align on `gemini-3.5-flash` for vision and
`gemini-3.1-flash-tts-preview` for voice. The backend still preserves a Gemini
Live path for future available Live models.
### Not yet proven
@@ -667,15 +672,16 @@ Before saying PodMan is demo-ready:
- [ ] Backend agent subscribes to the screen-share track.
- [ ] Agent logs at least one parsed Gemini context from a real IDE screen.
- [x] Local git report supplies dirty/unpushed truth on a schedule (`scripts/podman-agent.mjs` — 15 s poll → MongoDB `engineer_states`). Agent fusion still needed.
- [ ] Frontend renders a real intervention card.
- [ ] Hermes notification path works for teammate messages.
- [x] Frontend renders a real intervention card.
- [x] Hermes notification path works for teammate messages over the LiveKit data
channel.
- [ ] Voice is heard only for urgent escalation or a fallback is declared.
- [ ] Outcome ACK writes to MongoDB.
- [ ] `/api/memory/stats` shows counts increasing.
- [ ] Second similar situation uses prior memory in the message.
- [x] Outcome ACK writes to MongoDB and updates intervention status.
- [x] `/api/memory/stats` shows counts increasing.
- [x] Second similar situation uses prior exact memory in the message.
- [ ] Research recommendation card is evidence-backed, or fallback collision demo
is used.
- [ ] Sync PR action creates a visible GitHub artifact if used in demo.
- [x] Sync PR action creates a visible GitHub artifact if used in demo.
- [ ] DigitalOcean deployment or local fallback is rehearsed.
- [ ] Backup recording is ready on a separate device.
+39 -2
View File
@@ -69,7 +69,7 @@ LIVEKIT_API_SECRET=...
GEMINI_API_KEY=...
GEMINI_VISION_MODEL=gemini-2.0-flash
GEMINI_LIVE_MODEL=gemini-live-2.5-flash
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
GITHUB_TOKEN=...
GITHUB_REPO=karti-ai/podman
@@ -170,14 +170,51 @@ The droplet production fallback uses systemd units from `infra/systemd/`:
```bash
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/
sudo install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/
sudo install -m 0644 infra/systemd/podman-hermes-*.service infra/systemd/podman-hermes-*.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now podman-platform-api podman-platform-agent
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
```
Expected runtime proof:
```bash
systemctl is-active podman-platform-api podman-platform-agent
systemctl is-active podman-hermes-watchdog.timer
systemctl is-active podman-hermes-sync-deploy.timer
curl http://127.0.0.1:8787/health
journalctl -u podman-platform-agent -n 20 --no-pager
journalctl -u podman-hermes-watchdog -n 40 --no-pager
```
## Hermes-managed operations layer
The app processes are still supervised by systemd, but Hermes now owns the
operations loop around them:
- `pnpm hermes:watchdog` checks systemd services, public routes, `/health`,
`/api/pods`, and `pnpm deploy:doctor`.
- `podman-hermes-watchdog.timer` runs that watchdog every five minutes.
- `podman-hermes-sync-deploy.timer` polls `origin/main` every two minutes. If
the tree is clean and the remote moved, it fast-forwards, installs, builds,
publishes `frontend/dist` to `/var/www/podman`, restarts the API/agent/Caddy,
and runs the strict watchdog.
- Failed URL checks trigger restarts of the PodMan API, PodMan agent, and Caddy.
- Failed service checks restart only the unhealthy service.
- Caddy is validated and reloaded after public route failures.
- Reports are written to `/var/log/podman/hermes-watchdog-latest.json`.
- Set `PODMAN_ALERT_WEBHOOK_URL` to send failed reports to Discord, Slack, or a
generic webhook receiver.
- `pnpm hermes:install` installs the timer units and a local pre-push hook that
gates major pushes with typecheck, lint, and a non-remediating watchdog check.
The strict gate for production readiness is:
```bash
pnpm hermes:watchdog:strict
```
Manual deploy-sync run:
```bash
pnpm hermes:sync-deploy
```
+3 -3
View File
@@ -109,11 +109,11 @@ Respond with the message text only.
---
## 4. Voice Output — Gemini Live 2.5 via LiveKit
## 4. Voice Output — Gemini TTS via LiveKit
**Model:** `gemini-live-2.5-flash` (confirm exact ID from LiveKit Agents docs)
**Model:** `gemini-3.1-flash-tts-preview`
**Integration:** LiveKit Agents framework — Hermes runs as a LiveKit Agent with Gemini Live 2.5 as the voice provider
**Integration:** Hermes generates Gemini TTS audio and publishes it as a LiveKit audio track. The code still preserves a Gemini Live path for future available Live models.
**Flow:**
+11 -1
View File
@@ -79,10 +79,20 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
- `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`)
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
- `frontend/src/components/GraphView.tsx` — dark-Bauhaus SVG graph (toggle from `App.tsx`)
- `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`)
## Demo-first plan
1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path).
2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock.
3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it.
## Component convention
UI is built from the shared **shadcn / ruixen** registry — add primitives with
`npx shadcn@latest add "https://ruixen.com/r/[component]"` and compose from
`@/components/ui/*` (`Button`, `Badge`, `Card`, …) using the design tokens
(`var(--card)` / `--foreground` / `--border` / …). Only the SVG node-link **canvas**
in `GraphView.tsx` is bespoke (3 SVG-only CSS rules); the chrome (header, toggles,
metric cards, detail panel, legend) is composed from the primitives + the app's
Tailwind utility patterns. No hand-rolled component stylesheets.
+4 -4
View File
@@ -89,11 +89,11 @@ Hermes uses the same endpoint. Grants:
---
## Gemini Live 2.5 model
## Gemini voice model
- Model ID: `gemini-live-2.5-flash` — confirm exact ID from LiveKit Agents + Gemini docs at build time
- LiveKit Agents has native Gemini Live integration — no manual audio encoding needed
- Hermes passes text string → Agents handles streaming audio publication
- Model ID: `gemini-3.1-flash-tts-preview`
- Hermes generates Gemini TTS audio and publishes it as a LiveKit audio track.
- The backend keeps a Gemini Live path for future model availability, but the verified deployment path uses TTS.
---
@@ -72,7 +72,7 @@ PodMan is a real-time AI team coordination agent for software teams. Engineers j
- **Vision:** `gemini-2.0-flash` — screen → `{ currentFile, inferredTask, terminalVisible, recentTerminalOutput, confidence }`
- **Event detection:** `gemini-2.0-flash` — all engineer states → `{ event, involvedEngineers, file, reason }`
- **Nudge generation:** `gemini-2.0-flash` — event → spoken message text
- **Voice:** `gemini-live-2.5-flash` via LiveKit Agents — text → streaming audio
- **Voice:** `gemini-3.1-flash-tts-preview` via LiveKit audio publication — text → audio
### MongoDB Atlas (4 collections)
+110 -121
View File
@@ -1,24 +1,36 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
import { fetchPodGraph } from '../lib/graph.js';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
type Mode = 'risk' | 'learn' | 'all';
// Fixed, light-readable hues for the node/edge encoding (kept stable across
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens).
const BLUE = '#2563eb';
const SLATE = '#475569';
const SLATE_EDGE = '#94a3b8';
const SLATE_FAINT = '#cbd5e1';
const AMBER = '#d97706';
const RED = '#dc2626';
const VIOLET = '#7c3aed';
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
engineer: '#3B5BFF',
file: '#ECE7DA',
feature: '#F6C445',
collision: '#E2403A',
intervention: '#8b6cff',
engineer: BLUE,
file: SLATE,
feature: AMBER,
collision: RED,
intervention: VIOLET,
};
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
owns: { c: '#3B5BFF', w: 2.6 },
editing: { c: '#ECE7DA', w: 2 },
touches: { c: '#5d5d66', w: 1.6 },
collides: { c: '#E2403A', w: 3.2 },
warns: { c: '#F6C445', w: 3.2 },
learned_from: { c: '#8b6cff', w: 2.4, dash: true },
owns: { c: BLUE, w: 2.6 },
editing: { c: SLATE_EDGE, w: 2 },
touches: { c: SLATE_FAINT, w: 1.6 },
collides: { c: RED, w: 3.2 },
warns: { c: AMBER, w: 3.2 },
learned_from: { c: VIOLET, w: 2.4, dash: true },
};
function NodeShape({ node }: { node: PodGraphNode }) {
@@ -26,7 +38,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
const { x, y } = node;
switch (node.kind) {
case 'engineer':
return <rect x={x - 15} y={y - 15} width={30} height={30} fill={c} />;
return <rect x={x - 15} y={y - 15} width={30} height={30} rx={4} fill={c} />;
case 'file':
return (
<rect
@@ -34,6 +46,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
y={y - 15}
width={30}
height={30}
rx={4}
fill="none"
stroke={c}
strokeWidth={2.6}
@@ -81,16 +94,19 @@ function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Hig
}
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
{ label: 'engineer', swatch: { background: '#3B5BFF' } },
{ label: 'file', swatch: { border: '2px solid #ECE7DA' } },
{ label: 'feature', swatch: { background: '#F6C445', borderRadius: '50%' } },
{
label: 'collision',
swatch: { background: '#E2403A', clipPath: 'polygon(50% 0,100% 100%,0 100%)' },
},
{ label: 'intervention', swatch: { background: '#8b6cff', transform: 'rotate(45deg)' } },
{ label: 'engineer', swatch: { background: BLUE } },
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
];
function statusColor(status: string): string {
if (status === 'risk') return RED;
if (status === 'learned') return VIOLET;
return 'var(--foreground)';
}
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
const [graph, setGraph] = useState<PodGraph | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -128,99 +144,70 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
setSelected(null);
}
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
return (
<div className="pm-graph">
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<style>{`
.pm-graph{--bg:#0c0c0e;--panel:#141417;--line:#2a2a31;--paper:#ECE7DA;--mut:#8d897e;--red:#E2403A;--yel:#F6C445;--vio:#8b6cff;
font-family:'Space Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--paper);border:1px solid var(--line);border-radius:14px;overflow:hidden}
.pm-graph *{box-sizing:border-box}
.pm-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:3px solid var(--paper)}
.pm-ttl{font-weight:800;font-size:18px;letter-spacing:.14em;text-transform:uppercase;font-family:Archivo,'Space Grotesk',sans-serif}
.pm-sub{font-size:10px;letter-spacing:.3em;color:var(--mut);text-transform:uppercase;margin-top:5px}
.pm-x{background:transparent;border:1px solid var(--line);color:var(--paper);font-size:11px;letter-spacing:.1em;text-transform:uppercase;padding:7px 12px;border-radius:2px;cursor:pointer}
.pm-x:hover{border-color:var(--paper)}
.pm-bar{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
.pm-btn{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--paper);background:transparent;border:1px solid var(--line);padding:7px 12px;cursor:pointer;border-radius:2px}
.pm-btn:hover{border-color:var(--paper)}
.pm-btn.on{background:var(--red);border-color:var(--red);color:#fff}
.pm-grid{display:grid;grid-template-columns:180px 1fr 240px}
.pm-col{padding:14px}
.pm-railR{border-left:1px solid var(--line);background:#17171b}
.pm-st{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin:2px 0 12px}
.pm-kpi{border:1px solid var(--line);border-left:5px solid var(--vio);padding:10px 11px;margin-bottom:10px}
.pm-num{font-weight:800;font-size:26px;line-height:.9;font-variant-numeric:tabular-nums;font-family:Archivo,sans-serif}
.pm-klab{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut);margin-top:6px}
.pm-kdet{font-size:10px;color:var(--mut);margin-top:5px;line-height:1.4}
.pm-canvas{background:var(--panel);border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:472px}
.pm-canvas svg{width:100%;height:auto;display:block}
.pm-node{cursor:pointer}
.pm-lbl{font-weight:500;font-size:11px;letter-spacing:.06em;fill:var(--paper);text-transform:uppercase}
.pm-dim{opacity:.12;transition:opacity .25s}
.pm-dkind{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut)}
.pm-dname{font-weight:800;font-size:20px;margin:5px 0 8px;font-family:Archivo,sans-serif}
.pm-drow{display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--line);color:var(--mut)}
.pm-drow b{color:var(--paper);font-weight:500}
.pm-note{font-size:12px;color:var(--mut);line-height:1.5;margin-top:10px}
.pm-legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 16px;border-top:1px solid var(--line);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut)}
.pm-lg{display:flex;align-items:center;gap:6px}
.pm-sw{width:13px;height:13px;display:inline-block}
@media(max-width:760px){.pm-grid{grid-template-columns:1fr}.pm-railR{border-left:0;border-top:1px solid var(--line)}.pm-canvas{border:0;border-top:1px solid var(--line)}}
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
.pm-dim{opacity:.18;transition:opacity .25s}
`}</style>
<div className="pm-hd">
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
<div className="flex items-center justify-between border-b px-5 py-4">
<div>
<div className="pm-ttl">Team memory</div>
<div className="pm-sub">What PodMan learned · {podId}</div>
<h2 className="text-base font-medium">Team memory</h2>
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
</div>
<button className="pm-x" onClick={onClose}>
<Button variant="outline" size="sm" onClick={onClose}>
Pods
</button>
</Button>
</div>
<div className="pm-bar">
<button
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`}
onClick={() => pick('risk')}
>
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
Risk path
</button>
<button
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`}
onClick={() => pick('learn')}
>
</Button>
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
Learning edges
</button>
<button
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
onClick={() => pick('all')}
>
</Button>
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
Whole graph
</button>
</Button>
</div>
{error && (
<p style={{ padding: '16px', color: '#ff7d76', fontSize: 13 }}>Graph error: {error}</p>
)}
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
{!graph && !error && (
<p style={{ padding: '16px', color: '#8d897e', fontSize: 13 }}>Loading graph</p>
<p className="px-4 py-4 text-sm text-muted-foreground">Loading graph</p>
)}
{graph && (
<>
<div className="pm-grid">
<div className="pm-col">
<div className="pm-st">Workflow metrics</div>
<div className="grid lg:grid-cols-[190px_1fr_250px]">
<div className="space-y-3 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Workflow metrics
</p>
{graph.metrics.map((m) => (
<div className="pm-kpi" key={m.label}>
<div className="pm-num">{m.value}</div>
<div className="pm-klab">{m.label}</div>
<div className="pm-kdet">{m.detail}</div>
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
<p className="text-2xl font-medium tabular-nums">{m.value}</p>
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
{m.label}
</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
</div>
))}
</div>
<div className="pm-canvas">
<svg viewBox="0 0 720 472" role="img" aria-label="PodMan team-memory graph">
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
<svg
viewBox="0 0 720 472"
role="img"
aria-label="PodMan team-memory graph"
className="block h-auto w-full"
>
{graph.edges.map((e) => {
const a = nodeById.get(e.source);
const b = nodeById.get(e.target);
@@ -258,71 +245,73 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
>
<NodeShape node={n} />
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
{n.label.toUpperCase()}
{n.label}
</text>
</g>
))}
</svg>
</div>
<div className="pm-col pm-railR">
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
{sel ? (
<>
<div className="pm-dkind">{sel.kind}</div>
<div className="pm-dname">{sel.label}</div>
<div className="pm-drow">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{sel.kind}
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3>
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
<span>Status</span>
<b
style={{
color:
sel.status === 'risk'
? '#E2403A'
: sel.status === 'learned'
? '#b7a4ff'
: '#ECE7DA',
}}
>
<Badge variant="outline" style={{ color: statusColor(sel.status) }}>
{sel.status}
</b>
</Badge>
</div>
<div className="pm-drow">
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
<span>Relationships</span>
<b>{relCount}</b>
<span className="font-medium text-foreground">{relCount}</span>
</div>
<div className="pm-note">{sel.summary}</div>
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
{sel.summary}
</p>
</>
) : (
<>
<div className="pm-dkind">Continual learning</div>
<div className="pm-dname">It learned</div>
<div className="pm-note">
Violet <b style={{ color: '#b7a4ff' }}>learned_from</b> edges are ownership
PodMan retained from accepted interventions the graph gets sharper every
session. Click any node to trace its relationships.
</div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Continual learning
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
The violet{' '}
<span className="font-medium" style={{ color: VIOLET }}>
learned_from
</span>{' '}
edges are ownership PodMan retained from accepted interventions the graph
gets sharper every session. Click any node to trace its relationships.
</p>
</>
)}
</div>
</div>
<div className="pm-legend">
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
{LEGEND.map((l) => (
<span className="pm-lg" key={l.label}>
<span className="pm-sw" style={l.swatch} />
<span key={l.label} className="flex items-center gap-1.5">
<span className="inline-block size-3" style={l.swatch} />
{l.label}
</span>
))}
<span className="pm-lg">
<span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
<span className="flex items-center gap-1.5">
<span className="inline-block h-[3px] w-3" style={{ background: RED }} />
collides
</span>
<span className="pm-lg">
<span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
<span className="flex items-center gap-1.5">
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
learned_from
</span>
</div>
</>
)}
</div>
</div>
</div>
);
}
+46 -3
View File
@@ -4,6 +4,8 @@ import {
ArrowLeftIcon,
CheckIcon,
CircleDotIcon,
ExternalLinkIcon,
MessageSquareIcon,
MonitorUpIcon,
RadioTowerIcon,
SparklesIcon,
@@ -80,7 +82,7 @@ export function PodView({
const [sharing, setSharing] = useState(false);
const [playingBeat, setPlayingBeat] = useState(false);
const [note, setNote] = useState<string | null>(null);
const { active, respond } = useInterventions(room);
const { active, hermes, voiceCue, actionUrl, respond } = useInterventions(room);
const audioRef = useRef<HTMLDivElement>(null);
const beatRef = useRef<BeatHandle | null>(null);
@@ -181,6 +183,15 @@ export function PodView({
}
}
async function answerIntervention(status: 'accepted' | 'dismissed', accepted: boolean) {
setNote(null);
try {
await respond(status, accepted);
} catch (e) {
setNote(`Action failed: ${(e as Error).message}`);
}
}
const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
return (
@@ -299,6 +310,24 @@ export function PodView({
{active.suggestedAction.kind.replaceAll('_', ' ')}
</Badge>
</div>
{hermes?.interventionId === active.id && (
<div className="rounded-lg border border-dashed p-3">
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<MessageSquareIcon className="size-3.5" />
Hermes message
</div>
<p className="text-sm leading-6">{hermes.text}</p>
</div>
)}
{voiceCue && (
<div className="rounded-lg border border-dashed p-3">
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Volume2Icon className="size-3.5" />
Voice cue
</div>
<p className="text-sm leading-6">{voiceCue}</p>
</div>
)}
</div>
) : (
<Empty className="min-h-72 border-0 p-0">
@@ -314,14 +343,28 @@ export function PodView({
</EmptyHeader>
</Empty>
)}
{actionUrl && (
<a
href={actionUrl}
target="_blank"
rel="noreferrer"
className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-3 py-2 text-sm font-medium hover:bg-muted"
>
Sync PR artifact opened
<ExternalLinkIcon className="size-4" />
</a>
)}
</CardContent>
{active && (
<CardFooter className="justify-end gap-2">
<Button variant="outline" onClick={() => void respond('dismissed', false)}>
<Button
variant="outline"
onClick={() => void answerIntervention('dismissed', false)}
>
<XIcon data-icon="inline-start" />
Dismiss
</Button>
<Button onClick={() => void respond('accepted', true)}>
<Button onClick={() => void answerIntervention('accepted', true)}>
<CheckIcon data-icon="inline-start" />
Accept
</Button>
+14
View File
@@ -46,6 +46,20 @@ export async function postOutcome(outcome: InterventionOutcome): Promise<void> {
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`);
}
export async function createSyncPr(input: {
headBranch?: string;
file?: string;
summary?: string;
}): Promise<{ url: string; number: number }> {
return json(
await fetch(`${BACKEND_URL}/api/sync-pr`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
}),
);
}
// --- Pods CRUD ---
export async function listPods(): Promise<Pod[]> {
+19 -4
View File
@@ -1,18 +1,26 @@
import { useEffect, useState, useCallback } from 'react';
import { RoomEvent, type Room } from 'livekit-client';
import type { DataMessage, Intervention, InterventionStatus } from '@podman/shared';
import type { DataMessage, HermesMessage, Intervention, InterventionStatus } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared';
import { postOutcome } from '../lib/api';
import { createSyncPr, postOutcome } from '../lib/api';
export function useInterventions(room: Room | null) {
const [active, setActive] = useState<Intervention | null>(null);
const [hermes, setHermes] = useState<HermesMessage | null>(null);
const [voiceCue, setVoiceCue] = useState<string | null>(null);
const [actionUrl, setActionUrl] = useState<string | null>(null);
useEffect(() => {
if (!room) return;
const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => {
if (topic !== DATA_TOPIC) return;
const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;
if (msg.type === 'COLLISION') setActive(msg.intervention);
if (msg.type === 'COLLISION') {
setActive(msg.intervention);
setActionUrl(null);
}
if (msg.type === 'HERMES_MESSAGE') setHermes(msg.message);
if (msg.type === 'VOICE_CUE') setVoiceCue(msg.text);
};
room.on(RoomEvent.DataReceived, onData);
return () => {
@@ -23,6 +31,13 @@ export function useInterventions(room: Room | null) {
const respond = useCallback(
async (status: InterventionStatus, accepted: boolean) => {
if (!active) return;
if (accepted && active.suggestedAction.kind === 'open_sync_pr') {
const pr = await createSyncPr({
file: String(active.suggestedAction.params?.file ?? ''),
summary: String(active.suggestedAction.params?.summary ?? active.message),
});
setActionUrl(pr.url);
}
await postOutcome({
interventionId: active.id,
collisionId: active.collisionId,
@@ -37,5 +52,5 @@ export function useInterventions(room: Room | null) {
[active],
);
return { active, respond };
return { active, hermes, voiceCue, actionUrl, respond };
}
+32 -5
View File
@@ -4,7 +4,7 @@ Deploy targets for PodMan on DigitalOcean.
- `Dockerfile` — builds the backend runtime image from the monorepo root
- `app.yaml` — DigitalOcean App Platform spec: static site, API service, agent worker
- `systemd/` — local droplet service units for the API and agent worker
- `systemd/` — local droplet service/timer units for the API, agent worker, public healthcheck, and Hermes watchdog
Full deploy spec and env var reference in [`docs/digitalocean.md`](../docs/digitalocean.md).
@@ -31,9 +31,10 @@ On the demo droplet, serve the API and worker with systemd instead of tmux:
```bash
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/
sudo install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/
sudo install -m 0644 infra/systemd/podman-hermes-*.service infra/systemd/podman-hermes-*.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now podman-platform-api podman-platform-agent
sudo systemctl status podman-platform-api podman-platform-agent
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
sudo systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
```
The services expect:
@@ -47,6 +48,7 @@ Useful checks:
```bash
curl http://127.0.0.1:8787/health
journalctl -u podman-platform-api -u podman-platform-agent -f
journalctl -u podman-hermes-watchdog -f
```
## DigitalOcean deploy
@@ -73,9 +75,34 @@ local LiveKit host.
```bash
sudo cp infra/systemd/podman-platform-*.service /etc/systemd/system/
sudo cp infra/systemd/podman-hermes-watchdog.* /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now podman-platform-api podman-platform-agent
systemctl status podman-platform-api podman-platform-agent
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer
systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer
```
## Hermes operations layer
Hermes is the operations copilot for the droplet. The durable layer is:
- `podman-hermes-watchdog.timer` runs `pnpm hermes:watchdog` every five minutes.
- `podman-hermes-sync-deploy.timer` polls `origin/main` every two minutes and deploys clean fast-forward changes.
- `podman-public-healthcheck.timer` keeps the fast public URL restart loop.
- `/var/log/podman/hermes-watchdog-latest.json` records the latest watchdog report.
- `.git/hooks/pre-push`, installed by `pnpm hermes:install`, gates major pushes with typecheck, lint, and a non-remediating watchdog check.
Install or refresh all local ops wiring:
```bash
pnpm hermes:install
```
Manual one-shot checks:
```bash
pnpm hermes:watchdog
pnpm hermes:watchdog:strict
pnpm hermes:sync-deploy
```
## Fallback (demo safety)
+4 -2
View File
@@ -48,7 +48,8 @@ services:
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
@@ -73,7 +74,8 @@ workers:
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
@@ -0,0 +1,16 @@
[Unit]
Description=PodMan Hermes git sync and deploy
After=network-online.target podman-platform-api.service podman-platform-agent.service caddy.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/root/podman
Environment=NODE_ENV=production
Environment=PODMAN_DEPLOY_REMOTE=origin
Environment=PODMAN_DEPLOY_BRANCH=main
Environment=PODMAN_HERMES_STATE_DIR=/var/log/podman
EnvironmentFile=/root/podman/backend/.env
ExecStart=/usr/bin/node scripts/hermes-sync-deploy.mjs
Nice=5
IOSchedulingClass=best-effort
@@ -0,0 +1,11 @@
[Unit]
Description=Poll origin/main and let Hermes deploy clean fast-forward changes
[Timer]
OnBootSec=90s
OnUnitActiveSec=2min
AccuracySec=30s
Unit=podman-hermes-sync-deploy.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,17 @@
[Unit]
Description=PodMan Hermes operations watchdog
After=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
Wants=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
[Service]
Type=oneshot
WorkingDirectory=/root/podman
Environment=NODE_ENV=production
Environment=PODMAN_HERMES_STRICT=0
Environment=PODMAN_HERMES_REMEDIATE=1
Environment=PODMAN_HERMES_STATE_DIR=/var/log/podman
Environment=PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
EnvironmentFile=/root/podman/backend/.env
ExecStart=/usr/bin/node scripts/hermes-watchdog.mjs
Nice=5
IOSchedulingClass=best-effort
@@ -0,0 +1,11 @@
[Unit]
Description=Run PodMan Hermes operations watchdog every five minutes
[Timer]
OnBootSec=45s
OnUnitActiveSec=5min
AccuracySec=30s
Unit=podman-hermes-watchdog.service
[Install]
WantedBy=timers.target
+4
View File
@@ -20,6 +20,10 @@
"doctor": "node scripts/deploy-doctor.mjs",
"doctor:strict": "node scripts/deploy-doctor.mjs --strict",
"deploy:static:local": "node scripts/deploy-static-local.mjs",
"hermes:watchdog": "node scripts/hermes-watchdog.mjs",
"hermes:watchdog:strict": "node scripts/hermes-watchdog.mjs --strict",
"hermes:sync-deploy": "node scripts/hermes-sync-deploy.mjs",
"hermes:install": "node scripts/install-hermes-ops.mjs",
"healthcheck:public": "node scripts/healthcheck-public.mjs",
"verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend",
"verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers",
+79 -11
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { Buffer } from 'node:buffer';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { MongoClient } from 'mongodb';
@@ -15,7 +16,6 @@ const requiredEnv = [
'LIVEKIT_URL',
'LIVEKIT_API_KEY',
'LIVEKIT_API_SECRET',
'GEMINI_API_KEY',
'GITHUB_TOKEN',
'GITHUB_REPO',
'MONGODB_URI',
@@ -33,6 +33,19 @@ function isSet(name) {
return !!process.env[name]?.trim();
}
function configuredGeminiKey() {
const candidates = ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY'];
for (const name of candidates) {
const value = process.env[name]?.trim();
if (!value) continue;
if (/replace|todo|example|your|xxx/i.test(value) || value.length < 20) {
throw new Error(`${name} looks like a placeholder or truncated key`);
}
return { name, value };
}
throw new Error('GEMINI_API_KEY is not set');
}
async function check(name, fn) {
try {
const detail = await fn();
@@ -205,12 +218,12 @@ async function checkGitHub() {
}
async function checkGeminiVision() {
if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set');
const key = configuredGeminiKey();
const model = process.env.GEMINI_VISION_MODEL ?? 'gemini-2.0-flash';
const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
model,
)}:generateContent?key=${encodeURIComponent(process.env.GEMINI_API_KEY)}`,
)}:generateContent?key=${encodeURIComponent(key.value)}`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -224,19 +237,63 @@ async function checkGeminiVision() {
return model;
}
async function checkGeminiLiveListed() {
if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set');
const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-live-2.5-flash';
async function checkGeminiVoiceModel() {
const key = configuredGeminiKey();
const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-tts-preview';
const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(
process.env.GEMINI_API_KEY,
)}`,
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key.value)}`,
);
if (!res.ok) throw new Error(await responseError('Gemini model list', res));
const body = await res.json();
const names = (body.models ?? []).map((m) => m.name?.replace(/^models\//, ''));
if (!names.includes(model)) throw new Error(`${model} not present in Gemini model list`);
return model;
if (!model.includes('tts')) return model;
const tts = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
model,
)}:generateContent?key=${encodeURIComponent(key.value)}`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: 'Say clearly: PodMan voice check.' }] }],
generationConfig: {
responseModalities: ['AUDIO'],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
},
}),
},
);
if (!tts.ok) throw new Error(await responseError('Gemini voice check', tts));
const ttsBody = await tts.json();
const audio = ttsBody.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
if (!audio) throw new Error('Gemini voice response had no audio');
return `${model}, generated ${Buffer.from(audio, 'base64').byteLength} audio bytes`;
}
async function checkGeminiEmbeddings() {
const key = configuredGeminiKey();
const model = process.env.GEMINI_EMBEDDING_MODEL ?? 'gemini-embedding-001';
const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
model,
)}:embedContent?key=${encodeURIComponent(key.value)}`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
content: { parts: [{ text: 'PodMan vector memory check' }] },
taskType: 'RETRIEVAL_DOCUMENT',
outputDimensionality: 768,
}),
},
);
if (!res.ok) throw new Error(await responseError('Gemini embedding check', res));
const body = await res.json();
const dims = body.embedding?.values?.length;
if (!dims) throw new Error('Gemini embedding response had no vector');
return `${model}, ${dims} dimensions`;
}
async function checkVoyage() {
@@ -262,6 +319,16 @@ await check('workspace', checkWorkspace);
for (const name of requiredEnv) {
add(`env:${name}`, isSet(name) ? 'ok' : 'fail', isSet(name) ? 'set' : 'missing');
}
try {
const key = configuredGeminiKey();
add(
'env:GEMINI_API_KEY',
'ok',
key.name === 'GEMINI_API_KEY' ? 'set' : `using ${key.name} alias`,
);
} catch (err) {
add('env:GEMINI_API_KEY', 'fail', summarizeError(err));
}
for (const name of optionalEnv) {
add(`env:${name}`, isSet(name) ? 'ok' : 'warn', isSet(name) ? 'set' : 'optional');
}
@@ -281,7 +348,8 @@ await check('livekit room service', checkLiveKitApi);
await check('mongo ping', checkMongo);
await check('github repo access', checkGitHub);
await check('gemini vision model', checkGeminiVision);
await check('gemini live model listed', checkGeminiLiveListed);
await check('gemini voice model', checkGeminiVoiceModel);
await check('gemini embeddings', checkGeminiEmbeddings);
if (isSet('VOYAGE_API_KEY')) {
await check('voyage embeddings', checkVoyage);
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
const branch = process.env.PODMAN_DEPLOY_BRANCH ?? 'main';
const remote = process.env.PODMAN_DEPLOY_REMOTE ?? 'origin';
const services = (
process.env.PODMAN_DEPLOY_RESTART_SERVICES ??
['podman-platform-api.service', 'podman-platform-agent.service', 'caddy.service'].join(',')
)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const report = {
ok: false,
branch,
remote,
startedAt: new Date().toISOString(),
completedAt: '',
changed: false,
from: '',
to: '',
steps: [],
};
function run(command, args, options = {}) {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd ?? process.cwd(),
env: { ...process.env, ...options.env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr }));
child.on('error', (error) =>
resolve({ code: 127, signal: null, stdout, stderr: error.message }),
);
});
}
function detail(result, max = 1600) {
return [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n').slice(-max);
}
async function step(name, command, args, options) {
const result = await run(command, args, options);
const ok = result.code === 0;
report.steps.push({ name, ok, detail: detail(result) });
if (!ok) throw new Error(`${name} failed`);
return result;
}
async function gitOutput(args) {
const result = await run('git', args);
if (result.code !== 0) throw new Error(`git ${args.join(' ')} failed: ${detail(result)}`);
return result.stdout.trim();
}
async function main() {
const currentBranch = await gitOutput(['branch', '--show-current']);
if (currentBranch !== branch)
throw new Error(`expected branch ${branch}, found ${currentBranch}`);
await step('fetch', 'git', ['fetch', remote, branch]);
const dirty = await gitOutput(['status', '--porcelain']);
if (dirty) throw new Error(`working tree is dirty; refusing auto-deploy:\n${dirty}`);
const local = await gitOutput(['rev-parse', 'HEAD']);
const upstream = await gitOutput(['rev-parse', `${remote}/${branch}`]);
report.from = local;
report.to = upstream;
if (local === upstream) {
report.ok = true;
report.completedAt = new Date().toISOString();
console.log(JSON.stringify(report, null, 2));
return;
}
await step('fast-forward', 'git', ['merge', '--ff-only', `${remote}/${branch}`]);
report.changed = true;
await step('install', 'pnpm', ['install', '--frozen-lockfile'], {
env: { CI: 'true' },
});
await step('build', 'pnpm', ['build']);
await step('deploy static', 'pnpm', ['deploy:static:local']);
for (const service of services)
await step(`restart ${service}`, 'systemctl', ['restart', service]);
await step('hermes watchdog', 'pnpm', ['hermes:watchdog:strict']);
report.ok = true;
report.completedAt = new Date().toISOString();
console.log(JSON.stringify(report, null, 2));
}
try {
await main();
} catch (error) {
report.ok = false;
report.completedAt = new Date().toISOString();
report.error = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify(report, null, 2));
process.exit(1);
}
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { config as loadEnv } from 'dotenv';
const envPath = process.env.DOTENV_CONFIG_PATH ?? (existsSync('.env') ? '.env' : 'backend/.env');
loadEnv({ path: envPath, quiet: true });
const { AbortController, clearTimeout, fetch, setTimeout } = globalThis;
const args = new Set(process.argv.slice(2));
const remediate = !args.has('--no-remediate') && process.env.PODMAN_HERMES_REMEDIATE !== '0';
const strict = args.has('--strict') || process.env.PODMAN_HERMES_STRICT === '1';
const jsonOnly = args.has('--json');
const rootUrl = process.env.PODMAN_PUBLIC_URL ?? 'https://165-22-129-249.sslip.io/';
const apiUrl = process.env.PODMAN_PUBLIC_API_URL ?? new URL('/api/pods', rootUrl).toString();
const healthUrl = process.env.PODMAN_PUBLIC_HEALTH_URL ?? new URL('/health', rootUrl).toString();
const timeoutMs = Number(process.env.PODMAN_HERMES_TIMEOUT_MS ?? 8000);
const stateDir = process.env.PODMAN_HERMES_STATE_DIR ?? '/var/log/podman';
const services = (
process.env.PODMAN_HERMES_SERVICES ??
[
'mongod.service',
'podman-platform-api.service',
'podman-platform-agent.service',
'caddy.service',
].join(',')
)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const report = {
ok: false,
strict,
remediate,
startedAt: new Date().toISOString(),
completedAt: '',
checks: [],
remediation: [],
logs: {},
};
function addCheck(name, ok, detail = '') {
report.checks.push({ name, ok, detail });
return ok;
}
function summarizeOutput(result, max = 1200) {
return [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n').slice(-max);
}
function run(command, args = [], options = {}) {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd ?? process.cwd(),
env: { ...process.env, ...options.env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 2000).unref();
}, options.timeoutMs ?? timeoutMs);
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('close', (code, signal) => {
clearTimeout(timer);
resolve({ code, signal, stdout, stderr });
});
child.on('error', (error) => {
clearTimeout(timer);
resolve({ code: 127, signal: null, stdout, stderr: error.message });
});
});
}
async function fetchWithTimeout(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
const text = await res.text().catch(() => '');
return { ok: res.ok, status: res.status, text: text.slice(0, 300) };
} catch (error) {
return { ok: false, status: 0, text: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
async function checkUrls() {
for (const [name, url] of [
['public root', rootUrl],
['public health', healthUrl],
['public api', apiUrl],
]) {
const result = await fetchWithTimeout(url);
addCheck(name, result.ok, `${url} -> ${result.status || result.text}`);
}
}
async function checkServices() {
for (const service of services) {
const active = await run('systemctl', ['is-active', '--quiet', service], { timeoutMs: 5000 });
addCheck(`service:${service}`, active.code === 0, `systemctl is-active exit ${active.code}`);
}
}
async function checkDoctor() {
const doctorArgs = ['deploy:doctor'];
if (strict) doctorArgs[0] = 'deploy:doctor:strict';
const result = await run('pnpm', doctorArgs, {
timeoutMs: Number(process.env.PODMAN_HERMES_DOCTOR_TIMEOUT_MS ?? 120000),
});
const ok = result.code === 0 && /"ok":\s*true/.test(result.stdout);
addCheck(`pnpm ${doctorArgs[0]}`, ok, summarizeOutput(result, 2000));
}
async function collectLogs(failedServices = services) {
for (const service of failedServices) {
const result = await run('journalctl', ['-u', service, '-n', '80', '--no-pager'], {
timeoutMs: 8000,
});
report.logs[service] = summarizeOutput(result, 6000);
}
}
async function restart(service) {
const result = await run('systemctl', ['restart', service], { timeoutMs: 20000 });
report.remediation.push({
action: `restart ${service}`,
ok: result.code === 0,
detail: summarizeOutput(result),
});
return result.code === 0;
}
async function validateCaddy() {
if (!existsSync('/etc/caddy/Caddyfile')) return;
const result = await run('caddy', ['validate', '--config', '/etc/caddy/Caddyfile'], {
timeoutMs: 10000,
});
report.remediation.push({
action: 'caddy validate',
ok: result.code === 0,
detail: summarizeOutput(result),
});
if (result.code === 0) {
const reload = await run('systemctl', ['reload', 'caddy.service'], { timeoutMs: 10000 });
report.remediation.push({
action: 'reload caddy.service',
ok: reload.code === 0,
detail: summarizeOutput(reload),
});
}
}
async function remediateFailures() {
const failed = report.checks.filter((c) => !c.ok);
if (!failed.length || !remediate) return;
const failedServiceNames = failed.map((c) => c.name.match(/^service:(.+)$/)?.[1]).filter(Boolean);
if (failedServiceNames.length) {
for (const service of failedServiceNames) await restart(service);
} else {
for (const service of services.filter((s) => s !== 'mongod.service')) await restart(service);
}
if (failed.some((c) => c.name.includes('public'))) await validateCaddy();
await delay(3000);
}
async function writeReport() {
report.completedAt = new Date().toISOString();
report.ok = report.checks.every((c) => c.ok);
await mkdir(stateDir, { recursive: true });
const payload = JSON.stringify(report, null, 2);
await writeFile(join(stateDir, 'hermes-watchdog-latest.json'), payload);
await writeFile(join(stateDir, `hermes-watchdog-${Date.now()}.json`), payload);
return payload;
}
async function alert(payload) {
const url = process.env.PODMAN_ALERT_WEBHOOK_URL;
if (!url || report.ok) return;
const failed = report.checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
const text = `PodMan Hermes watchdog failed ${failed.length} check(s):\n${failed.join('\n')}`;
try {
await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
content: text,
text,
username: 'PodMan Hermes',
report: JSON.parse(payload),
}),
});
} catch (error) {
report.remediation.push({
action: 'send alert',
ok: false,
detail: error instanceof Error ? error.message : String(error),
});
}
}
await checkServices();
await checkUrls();
await checkDoctor();
const firstFailed = report.checks.filter((c) => !c.ok);
await remediateFailures();
if (firstFailed.length && remediate) {
report.checks.push({ name: 'retry boundary', ok: true, detail: 'after remediation' });
await checkServices();
await checkUrls();
await checkDoctor();
}
await collectLogs(
report.checks
.filter((c) => !c.ok)
.map((c) => c.name.match(/^service:(.+)$/)?.[1])
.filter(Boolean),
);
const payload = await writeReport();
await alert(payload);
if (!jsonOnly) {
for (const check of report.checks) {
console.log(
`${check.ok ? 'OK ' : 'FAIL'} ${check.name}${check.detail ? ` - ${check.detail}` : ''}`,
);
}
for (const action of report.remediation) {
console.log(`${action.ok ? 'OK ' : 'FAIL'} remediate:${action.action}`);
}
}
console.log(payload);
process.exit(report.ok || !strict ? 0 : 1);
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { chmod, copyFile, mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
const root = process.cwd();
const dryRun = process.argv.includes('--dry-run');
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: 'inherit' });
child.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`${command} exited ${code}`)),
);
child.on('error', reject);
});
}
async function installFile(source, target, mode = 0o644) {
console.log(`${dryRun ? 'would install' : 'install'} ${source} -> ${target}`);
if (dryRun) return;
await copyFile(source, target);
await chmod(target, mode);
}
async function installGitHook() {
const hookDir = `${root}/.git/hooks`;
if (!existsSync(hookDir)) return;
const hook = `#!/usr/bin/env bash
set -euo pipefail
cd "${root}"
echo "[hermes] running pre-push verification"
pnpm -r typecheck
pnpm lint
pnpm hermes:watchdog -- --no-remediate --json >/tmp/podman-hermes-pre-push.json
echo "[hermes] pre-push verification passed"
`;
console.log(`${dryRun ? 'would write' : 'write'} ${hookDir}/pre-push`);
if (dryRun) return;
await writeFile(`${hookDir}/pre-push`, hook);
await chmod(`${hookDir}/pre-push`, 0o755);
}
await installFile(
'infra/systemd/podman-hermes-watchdog.service',
'/etc/systemd/system/podman-hermes-watchdog.service',
);
await installFile(
'infra/systemd/podman-hermes-watchdog.timer',
'/etc/systemd/system/podman-hermes-watchdog.timer',
);
await installFile(
'infra/systemd/podman-hermes-sync-deploy.service',
'/etc/systemd/system/podman-hermes-sync-deploy.service',
);
await installFile(
'infra/systemd/podman-hermes-sync-deploy.timer',
'/etc/systemd/system/podman-hermes-sync-deploy.timer',
);
await installFile(
'infra/systemd/podman-public-healthcheck.service',
'/etc/systemd/system/podman-public-healthcheck.service',
);
await installFile(
'infra/systemd/podman-public-healthcheck.timer',
'/etc/systemd/system/podman-public-healthcheck.timer',
);
await mkdir('/var/log/podman', { recursive: true });
await installGitHook();
if (!dryRun) {
await run('systemctl', ['daemon-reload']);
await run('systemctl', ['enable', '--now', 'podman-hermes-watchdog.timer']);
await run('systemctl', ['enable', '--now', 'podman-hermes-sync-deploy.timer']);
await run('systemctl', ['enable', '--now', 'podman-public-healthcheck.timer']);
await run('systemctl', [
'status',
'--no-pager',
'podman-hermes-watchdog.timer',
'podman-hermes-sync-deploy.timer',
'podman-public-healthcheck.timer',
]);
}
console.log(JSON.stringify({ ok: true, installed: !dryRun }, null, 2));
+13
View File
@@ -155,8 +155,21 @@ async function verifyMemoryRecall() {
detectedAt: new Date().toISOString(),
};
await recordCollision(seed);
const { getDb } = await import('../backend/dist/memory/db.js');
const db = await getDb();
const stored = await db.collection('collisions').findOne({ id: seed.id });
if (!Array.isArray(stored?.embedding) || stored.embedding.length < 1) {
fail('memory collision was not enriched with an embedding');
}
const recalled = await recallSimilar({ ...seed, id: `${seed.id}_query` });
if (!recalled) fail('memory recall did not find seeded collision');
const vectorRecalled = await recallSimilar({
...seed,
id: `${seed.id}_vector_query`,
file: 'src/nearby-memory.ts',
symbol: 'nearbyMemory',
});
if (!vectorRecalled) fail('vector memory recall did not find semantically similar collision');
}
async function verifyGraph() {
+52 -7
View File
@@ -109,6 +109,20 @@ async function publishIntervention(room, podId) {
});
}
async function waitForInterventionCard(page, room, podId) {
const cardText = 'Verification collision: two engineers are editing frontend/src/App.tsx.';
for (let attempt = 1; attempt <= 3; attempt++) {
await publishIntervention(room, podId);
try {
await page.getByText(cardText).waitFor({ timeout: 5_000 });
return;
} catch (error) {
if (attempt === 3) throw error;
await delay(500);
}
}
}
let preview = null;
if (shouldStartPreview) {
preview = spawn(
@@ -124,6 +138,27 @@ if (shouldStartPreview) {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.addInitScript(() => {
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
configurable: true,
value: {
...(globalThis.navigator.mediaDevices ?? {}),
async getDisplayMedia() {
const canvas = globalThis.document.createElement('canvas');
canvas.width = 640;
canvas.height = 360;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas context unavailable');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#111';
ctx.font = '28px sans-serif';
ctx.fillText('PodMan verification screen', 32, 72);
return canvas.captureStream(5);
},
},
});
});
const consoleErrors = [];
const pageErrors = [];
@@ -156,11 +191,17 @@ try {
await page.getByRole('button', { name: 'Team memory' }).click();
await page.getByText('Workflow metrics').waitFor({ timeout: 15_000 });
await page.getByText('Learning edges').waitFor({ timeout: 15_000 });
await page.getByRole('img', { name: 'PodMan team-memory graph' }).waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'engineer: Karti' }).click();
await page.getByText('Learned owner of auth; backend + DB wiring.').waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'Whole graph' }).click();
await page.getByRole('button', { name: /Pods/i }).click();
await page.getByPlaceholder('Your name').first().waitFor({ timeout: 15_000 });
await page.getByPlaceholder('Your name').first().fill(verifyMember);
await page.getByRole('button', { name: 'Add and join' }).first().click();
const frontendPodCard = page
.getByText('Frontend Pod', { exact: true })
.locator('xpath=ancestor::*[.//input[@placeholder="Your name"]][1]');
await frontendPodCard.getByPlaceholder('Your name').fill(verifyMember);
await frontendPodCard.getByRole('button', { name: 'Add and join' }).click();
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
const joinedText = await page.locator('body').innerText();
@@ -173,12 +214,15 @@ try {
throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`);
}
await page.getByRole('button', { name: 'Share screen' }).click();
await page.getByRole('button', { name: 'Stop sharing' }).waitFor({ timeout: 15_000 });
await page.getByText(/Screen\s*published/i).waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'Stop sharing' }).click();
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
const publisher = await connectPublisher('frontend-pod');
try {
await publishIntervention(publisher, 'frontend-pod');
await page
.getByText('Verification collision: two engineers are editing frontend/src/App.tsx.')
.waitFor({ timeout: 15_000 });
await waitForInterventionCard(page, publisher, 'frontend-pod');
await page.getByRole('button', { name: 'Dismiss' }).click();
await page.getByText('No collision detected').waitFor({ timeout: 15_000 });
} finally {
@@ -199,6 +243,7 @@ try {
bodyLength: bodyText.length,
graph: true,
joined: true,
screenShare: true,
intervention: true,
member: verifyMember,
},
+1
View File
@@ -9,6 +9,7 @@ export type {
SuggestedActionKind,
} from './intervention.js';
export * from './messages.js';
export type { HermesMessage } from './messages.js';
export type {
PodGraph,
PodGraphNode,
+12
View File
@@ -7,10 +7,22 @@ export const DATA_TOPIC = 'podman.intervention' as const;
/** Wire messages exchanged between the PodMan agent and engineer PWAs. */
export type DataMessage =
| { type: 'COLLISION'; collision: Collision; intervention: Intervention }
| { type: 'HERMES_MESSAGE'; message: HermesMessage }
| { type: 'VOICE_CUE'; text: string }
| { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string }
| { type: 'GIT_REPORT'; report: LocalGitReport };
/** A targeted teammate/project-channel notification from the Hermes action layer. */
export interface HermesMessage {
id: string;
podId: string;
interventionId: string;
recipients: string[];
text: string;
urgency: 'normal' | 'urgent';
createdAt: string;
}
/** Outcome of an intervention — the supervision signal for policy learning. */
export interface InterventionOutcome {
interventionId: string;