4 Commits

Author SHA1 Message Date
sb-iam 7d4029a046 docs(spec): note the shadcn/ruixen unifying-template rule in §4b
Chrome composes from @/components/ui/* primitives (npx shadcn add
ruixen.com/r/[component]); only the SVG canvas is bespoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:49:10 -07:00
sb-iam 9dd8f3ce60 docs(graph): re-theme Team Memory to shadcn (R2) — spec update
The app shipped light shadcn; GraphView shipped dark-Bauhaus and clashed.
Spec now calls for theme-aware shadcn tokens (var(--card)/--foreground/
--border/--chart-*) instead of the hardcoded dark .pm-* palette; functional
node-shape/color encoding unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:35:46 -07:00
sb-iam 1ea3e3c9e6 docs(graph): revise live-ui spec per PR #3 review (R1)
Address all 5 review findings:
- P1 supervised learning: outcome carries learnedOwner+file -> team_model.ownership
  (authoritative); observation owner is only a low-confidence fallback.
- P1 changedFiles: parse git status --short lines (parseGitStatusPath) before
  matching collision files.
- P2 ws bridge: GRAPH_DIRTY is in-process; agent opens a ws client to /api/events
  for the nice-to-have instant COLLISION/VOICE push; poll is the floor.
- P2 demo metrics: PodGraph gains source:'live'|'demo'; demo/empty suppresses
  the baked 5/2/86% so it never reads as live.
- P3: PR is spec-only; PLAN.md task lands with the implementation PR.

Adds an R1 revisions section; contract changes (messages.ts, graph.ts,
useInterventions.ts) listed in §7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:28:48 -07:00
sb-iam 5a96e2b1ae docs(graph): spec for the live continual-learning UI
Adds docs/live-ui-spec.md — how to make the demo-backed graph REAL by
materializing it from the live Mongo collections the agent already writes
(pods, engineer_states, observations, collisions, interventions, outcomes).
Extends docs/graph.md; spec only, no implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:07:31 -07:00
37 changed files with 555 additions and 1474 deletions
+1 -19
View File
@@ -7,11 +7,8 @@ LIVEKIT_API_SECRET=
# --- Gemini (vision + event detection + voice) --- # --- Gemini (vision + event detection + voice) ---
GEMINI_API_KEY= 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_VISION_MODEL=gemini-2.0-flash
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview GEMINI_LIVE_MODEL=gemini-live-2.5-flash
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
# --- GitHub (repo state + sync PR artifacts) --- # --- GitHub (repo state + sync PR artifacts) ---
GITHUB_TOKEN= GITHUB_TOKEN=
@@ -36,18 +33,3 @@ VITE_BACKEND_URL=http://localhost:8787
# --- Deployment verification --- # --- Deployment verification ---
# Optional override when the deployed SPA and API use different origins. # Optional override when the deployed SPA and API use different origins.
FRONTEND_URL=http://localhost:4173 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
@@ -1,23 +0,0 @@
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
@@ -1,39 +0,0 @@
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,
});
}
+4 -17
View File
@@ -9,7 +9,6 @@ import { getGitStates } from '../memory/db.js';
import { recallSimilar } from '../memory/vectors.js'; import { recallSimilar } from '../memory/vectors.js';
import { shouldIntervene, preferredAction } from '../memory/policy.js'; import { shouldIntervene, preferredAction } from '../memory/policy.js';
import { speak } from '../voice/live.js'; import { speak } from '../voice/live.js';
import { publishHermesMessage } from '../action/hermes.js';
export class PodMan { export class PodMan {
private contexts = new Map<string, EngineerContext>(); private contexts = new Map<string, EngineerContext>();
@@ -55,7 +54,7 @@ export class PodMan {
} }
private async handle(collision: Collision): Promise<void> { private async handle(collision: Collision): Promise<void> {
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence const prior = await recallSimilar(collision); // Loop A: vector recall raises confidence
if (prior) collision.severity = 'critical'; if (prior) collision.severity = 'critical';
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
@@ -65,11 +64,7 @@ export class PodMan {
const message = const message =
`${names} are both editing ${collision.file}` + `${names} are both editing ${collision.file}` +
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') + (collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
(prior?.priorOutcome?.accepted (prior ? ` I've seen this conflict pattern before.` : '');
? ` 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 = { const intervention: Intervention = {
id: `int_${Date.now()}`, id: `int_${Date.now()}`,
@@ -77,14 +72,7 @@ export class PodMan {
podId: this.podId, podId: this.podId,
kind: 'card', kind: 'card',
message, message,
suggestedAction: { suggestedAction: { kind: action },
kind: action,
params: {
file: collision.file,
summary: message,
engineers: collision.engineers,
},
},
status: 'pending', status: 'pending',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
@@ -95,7 +83,6 @@ export class PodMan {
reliable: true, reliable: true,
topic: DATA_TOPIC, topic: DATA_TOPIC,
}); });
await publishHermesMessage(this.room, collision, intervention); await speak(this.room, message); // gemini-3.1-flash-live voice into the room
if (collision.severity === 'critical') await speak(this.room, message);
} }
} }
+2 -11
View File
@@ -5,13 +5,6 @@ function req(name: string): string {
if (!v) throw new Error(`Missing required env var: ${name}`); if (!v) throw new Error(`Missing required env var: ${name}`);
return v; 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 { function opt(name: string, fallback = ''): string {
return process.env[name] ?? fallback; return process.env[name] ?? fallback;
} }
@@ -22,10 +15,9 @@ export const env = {
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'), LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'), LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
// Gemini // Gemini
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']), GEMINI_API_KEY: req('GEMINI_API_KEY'),
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'), 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_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-live-2.5-flash'),
GEMINI_EMBEDDING_MODEL: opt('GEMINI_EMBEDDING_MODEL', 'gemini-embedding-001'),
// GitHub // GitHub
GITHUB_TOKEN: req('GITHUB_TOKEN'), GITHUB_TOKEN: req('GITHUB_TOKEN'),
GITHUB_REPO: req('GITHUB_REPO'), // owner/name GITHUB_REPO: req('GITHUB_REPO'), // owner/name
@@ -35,7 +27,6 @@ export const env = {
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'), VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
// Server // Server
PORT: Number(opt('PORT', '8787')), PORT: Number(opt('PORT', '8787')),
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
} as const; } as const;
export function repoParts(): { owner: string; repo: string } { export function repoParts(): { owner: string; repo: string } {
+1 -33
View File
@@ -39,45 +39,13 @@ export async function createSyncPr(input: { headBranch: string; file: string; su
ref: `refs/heads/${branch}`, ref: `refs/heads/${branch}`,
sha: mainRef.object.sha, 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({ const { data: pr } = await gh.rest.pulls.create({
owner, owner,
repo, repo,
title: `PodMan: sync ${input.file} before collision`, title: `PodMan: sync ${input.file} before collision`,
head: branch, head: branch,
base: 'main', base: 'main',
body: [ body: input.summary,
input.summary,
'',
`PodMan created a visible sync artifact at \`${artifactPath}\` so the team can coordinate before pushing overlapping work.`,
].join('\n'),
}); });
return pr; return pr;
} }
-1
View File
@@ -99,7 +99,6 @@ export async function initMemory(): Promise<void> {
'collisions.memorySignature', 'collisions.memorySignature',
() => c.collisions.createIndex({ podId: 1, memorySignature: 1 }), () => 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 })], ['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })], ['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
]; ];
+11 -31
View File
@@ -1,37 +1,17 @@
import type { Collision, SuggestedActionKind } from '@podman/shared'; import type { Collision, SuggestedActionKind } from '@podman/shared';
import type { RecalledCollision } from './vectors.js';
const lastNudgeByPod = new Map<string, number>(); /**
* Policy gate: decides whether PodMan should intervene.
function cooldownMs(): number { * Stub: always intervene on warn/critical.
return Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'); */
export function shouldIntervene(collision: Collision, _prior: unknown): boolean {
return collision.severity !== 'info';
} }
/** Policy gate: combines severity, exact recall outcomes, and per-pod cooldown. */ /**
export function shouldIntervene(collision: Collision, prior: RecalledCollision | null): boolean { * Preferred action selection based on collision + prior history.
if (collision.severity === 'info') return false; * Stub: open sync PR for critical, ping teammate otherwise.
*/
const priorOutcome = prior?.priorOutcome; export function preferredAction(collision: Collision, _prior: unknown): SuggestedActionKind {
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'; return collision.severity === 'critical' ? 'open_sync_pr' : 'ping_teammate';
} }
+1 -8
View File
@@ -34,14 +34,7 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
} }
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> { export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
await persist('outcome', async () => { await persist('outcome', async () => (await collections()).outcomes.insertOne({ ...outcome }));
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. */ /** Document counts per collection — used by the /api/memory/stats endpoint. */
+16 -148
View File
@@ -1,4 +1,4 @@
import type { Collision, Intervention, InterventionOutcome } from '@podman/shared'; import type { Collision } from '@podman/shared';
import { env } from '../env.js'; import { env } from '../env.js';
import { getDb } from './db.js'; import { getDb } from './db.js';
@@ -6,35 +6,18 @@ type StoredCollision = Collision & {
memorySignature?: string; memorySignature?: string;
memoryText?: string; memoryText?: string;
embedding?: number[]; embedding?: number[];
embeddingProvider?: string;
};
export type RecalledCollision = Collision & {
priorIntervention?: Intervention;
priorOutcome?: InterventionOutcome;
}; };
interface VoyageEmbeddingResponse { interface VoyageEmbeddingResponse {
data?: Array<{ embedding?: number[] }>; data?: Array<{ embedding?: number[] }>;
} }
interface GeminiEmbeddingResponse {
embedding?: { values?: number[] };
}
function normalize(value: string | undefined): string { function normalize(value: string | undefined): string {
return (value ?? '').trim().toLowerCase(); return (value ?? '').trim().toLowerCase();
} }
function signature(collision: Collision): string { function signature(collision: Collision): string {
return [ return [normalize(collision.file), normalize(collision.symbol)].filter(Boolean).join('#');
normalize(collision.file),
normalize(collision.symbol),
[...collision.engineers].sort().map(normalize).join('+'),
'collision',
]
.filter(Boolean)
.join('#');
} }
function memoryText(collision: Collision): string { function memoryText(collision: Collision): string {
@@ -49,30 +32,7 @@ function memoryText(collision: Collision): string {
.join('\n'); .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> { 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; if (!env.VOYAGE_API_KEY) return null;
try { try {
const res = await fetch('https://api.voyageai.com/v1/embeddings', { const res = await fetch('https://api.voyageai.com/v1/embeddings', {
@@ -99,38 +59,6 @@ async function embedWithVoyage(
} }
} }
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> { export async function enrichCollisionMemory(collision: Collision): Promise<StoredCollision> {
const text = memoryText(collision); const text = memoryText(collision);
const embedding = await embed(text, 'document'); const embedding = await embed(text, 'document');
@@ -138,45 +66,16 @@ export async function enrichCollisionMemory(collision: Collision): Promise<Store
...collision, ...collision,
memorySignature: signature(collision), memorySignature: signature(collision),
memoryText: text, memoryText: text,
...(embedding ...(embedding ? { embedding } : {}),
? { embedding, embeddingProvider: env.VOYAGE_API_KEY ? 'voyage' : 'gemini' }
: {}),
}; };
} }
async function attachOutcome(match: StoredCollision): Promise<RecalledCollision> { async function recallByVector(collision: Collision): Promise<Collision | null> {
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'); const queryVector = await embed(memoryText(collision), 'query');
if (!queryVector) return null; if (!queryVector) return null;
const db = await getDb();
try { try {
const db = await getDb();
const [match] = await db const [match] = await db
.collection<StoredCollision>('collisions') .collection<StoredCollision>('collisions')
.aggregate<StoredCollision>([ .aggregate<StoredCollision>([
@@ -194,62 +93,31 @@ async function recallByVector(collision: Collision): Promise<RecalledCollision |
{ $project: { _id: 0, embedding: 0 } }, { $project: { _id: 0, embedding: 0 } },
]) ])
.toArray(); .toArray();
return match ? attachOutcome(match) : null; return match ?? null;
} catch (err) { } catch (err) {
console.warn(`[memory] atlas vector recall unavailable: ${(err as Error).message}`); console.warn(`[memory] vector recall unavailable: ${(err as Error).message}`);
return null;
} }
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<RecalledCollision | null> { async function recallBySignature(collision: Collision): Promise<Collision | null> {
const db = await getDb(); const db = await getDb();
const sig = signature(collision); const sig = signature(collision);
const matches = await db const match = await db.collection<StoredCollision>('collisions').findOne(
.collection<StoredCollision>('collisions')
.find(
{ {
podId: collision.podId, podId: collision.podId,
id: { $ne: collision.id }, id: { $ne: collision.id },
$or: [{ memorySignature: sig }, { file: collision.file }], $or: [{ memorySignature: sig }, { file: collision.file }],
}, },
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 }, limit: 10 }, { sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 } },
) );
.toArray(); return match ?? null;
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. Atlas Vector Search is preferred when * Recall prior collision patterns. Exact Mongo recall is always available;
* available; standalone MongoDB falls back to app-side cosine search over * Voyage + Atlas Vector Search is used first when configured.
* stored embeddings, then exact signature/file matching.
*/ */
export async function recallSimilar(collision: Collision): Promise<RecalledCollision | null> { export async function recallSimilar(collision: Collision): Promise<Collision | null> {
return (await recallByVector(collision)) ?? recallBySignature(collision); return (await recallByVector(collision)) ?? recallBySignature(collision);
} }
+19 -57
View File
@@ -1,4 +1,3 @@
import { Buffer } from 'node:buffer';
import { import {
AudioFrame, AudioFrame,
AudioSource, AudioSource,
@@ -13,7 +12,6 @@ import { env } from '../env.js';
const SAMPLE_RATE = 24_000; const SAMPLE_RATE = 24_000;
const CHANNELS = 1; const CHANNELS = 1;
const FRAME_SAMPLES = SAMPLE_RATE / 10;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY }); const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
@@ -46,53 +44,37 @@ function audioFrames(message: LiveServerMessage): AudioFrame[] {
return out; return out;
} }
function framesFromPcmBase64(data: string, mimeType?: string): AudioFrame[] { /**
const frame = audioFrameFromBase64(data, mimeType); * Speak a message into the LiveKit room using Gemini Live audio. A data-channel
if (!frame) return []; * 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 samples = frame.data; const source = new AudioSource(SAMPLE_RATE, CHANNELS);
const frames: AudioFrame[] = []; const track = LocalAudioTrack.createAudioTrack('podman-hermes-voice', source);
for (let offset = 0; offset < samples.length; offset += FRAME_SAMPLES) { const options = new TrackPublishOptions();
const chunk = samples.subarray(offset, Math.min(offset + FRAME_SAMPLES, samples.length)); options.source = TrackSource.SOURCE_MICROPHONE;
frames.push(new AudioFrame(chunk, SAMPLE_RATE, CHANNELS, chunk.length / CHANNELS));
}
return frames;
}
async function generateTtsFrames(message: string): Promise<AudioFrame[]> { try {
const res = await ai.models.generateContent({ const publication = await room.localParticipant.publishTrack(track, options);
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 = () => {}; let done: () => void = () => {};
const donePromise = new Promise<void>((resolve) => { const donePromise = new Promise<void>((resolve) => {
done = resolve; done = resolve;
}); });
const session: Session = await ai.live.connect({ let session: Session | null = null;
session = await ai.live.connect({
model: env.GEMINI_LIVE_MODEL, model: env.GEMINI_LIVE_MODEL,
config: { responseModalities: [Modality.AUDIO] }, config: { responseModalities: [Modality.AUDIO] },
callbacks: { callbacks: {
onmessage: (event) => { onmessage: (event) => {
void (async () => { void (async () => {
for (const frame of audioFrames(event)) await source.captureFrame(frame); 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) => { onerror: (event) => {
@@ -110,26 +92,6 @@ async function speakWithLive(source: AudioSource, message: string): Promise<void
await Promise.race([donePromise, new Promise((resolve) => setTimeout(resolve, 15_000))]); await Promise.race([donePromise, new Promise((resolve) => setTimeout(resolve, 15_000))]);
session.close(); 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); if (publication.sid) await room.localParticipant.unpublishTrack(publication.sid, true);
await source.close(); await source.close();
} catch (err) { } catch (err) {
+25 -31
View File
@@ -178,41 +178,36 @@ From the remote plan snapshot and health check on `2026-06-27`:
- Treat this as operational evidence, not architecture truth. Reverify before - Treat this as operational evidence, not architecture truth. Reverify before
demo. demo.
### Partial / completed since the original audit ### Partial / stubbed
- `backend/src/voice/live.ts` now publishes a `VOICE_CUE` fallback and attempts - `backend/src/voice/live.ts` logs only; it does not publish real voice/audio
Gemini audio publication into LiveKit. The agent only calls it for critical into LiveKit yet.
interventions so voice remains an urgent escalation path. - Hermes is a product/action/messaging layer in the plan, but the current repo
- Hermes now has a data-channel teammate message path via `HERMES_MESSAGE` on does not yet implement a complete Hermes notification bridge.
the existing `podman.intervention` topic. This is the MVP notification bridge, - `backend/src/memory/vectors.ts` is not a real Voyage/Atlas Vector Search
not a Slack/Discord integration. implementation yet.
- `backend/src/memory/vectors.ts` implements exact-signature recall first and - Exact-signature recall is the required MVP fallback before vectors.
can use Voyage/Gemini embeddings with Atlas Vector Search when configured. - `backend/src/memory/policy.ts` is a simple gate; it does not learn thresholds
- Exact-signature recall now attaches prior interventions/outcomes and prefers from outcomes yet.
accepted real collisions, giving the learning beat deterministic MongoDB - `POST /api/sync-pr` creates a PR artifact path but does not yet build a
proof before vector search. meaningful sync diff.
- `backend/src/memory/policy.ts` now uses severity, per-pod cooldown, and prior - Frontend `PodView` has only a placeholder intervention area unless/until live
outcome history. It is still a simple policy, not a trained threshold model. intervention rendering is wired.
- `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 - 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 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 `origin/main` screen-share button appears to address this; local code remains
behind until that commit is merged. behind until that commit is merged.
- `GIT_REPORT` exists in shared types and agent handling. `scripts/podman-agent.mjs` - `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 is the finished per-laptop git sidecar — polls every 15 s, upserts git fields
to `engineer_states` collection. The backend agent now fuses those Mongo to `engineer_states` collection. Not yet wired to publish a `GIT_REPORT` data
git-state fields into live contexts before collision detection; direct channel message into the LiveKit room (agent fusion step still needed).
LiveKit `GIT_REPORT` publication from the sidecar remains optional.
- Background research recommendations are a product requirement and demo goal, - Background research recommendations are a product requirement and demo goal,
not an implemented research agent yet. not an implemented research agent yet.
- Deployment reliability is partial; API health is reachable, but API/static - Deployment reliability is partial; API health is reachable, but API/static
site/worker together must still be reverified before demo. site/worker together must still be reverified before demo.
- Env docs now align on `gemini-3.5-flash` for vision and - Env docs are inconsistent: backend defaults are `gemini-3.5-flash` and
`gemini-3.1-flash-tts-preview` for voice. The backend still preserves a Gemini `gemini-3.1-flash-live-preview`, while `.env.example` still lists older
Live path for future available Live models. Gemini model names.
### Not yet proven ### Not yet proven
@@ -672,16 +667,15 @@ Before saying PodMan is demo-ready:
- [ ] Backend agent subscribes to the screen-share track. - [ ] Backend agent subscribes to the screen-share track.
- [ ] Agent logs at least one parsed Gemini context from a real IDE screen. - [ ] 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. - [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.
- [x] Frontend renders a real intervention card. - [ ] Frontend renders a real intervention card.
- [x] Hermes notification path works for teammate messages over the LiveKit data - [ ] Hermes notification path works for teammate messages.
channel.
- [ ] Voice is heard only for urgent escalation or a fallback is declared. - [ ] Voice is heard only for urgent escalation or a fallback is declared.
- [x] Outcome ACK writes to MongoDB and updates intervention status. - [ ] Outcome ACK writes to MongoDB.
- [x] `/api/memory/stats` shows counts increasing. - [ ] `/api/memory/stats` shows counts increasing.
- [x] Second similar situation uses prior exact memory in the message. - [ ] Second similar situation uses prior memory in the message.
- [ ] Research recommendation card is evidence-backed, or fallback collision demo - [ ] Research recommendation card is evidence-backed, or fallback collision demo
is used. is used.
- [x] Sync PR action creates a visible GitHub artifact if used in demo. - [ ] Sync PR action creates a visible GitHub artifact if used in demo.
- [ ] DigitalOcean deployment or local fallback is rehearsed. - [ ] DigitalOcean deployment or local fallback is rehearsed.
- [ ] Backup recording is ready on a separate device. - [ ] Backup recording is ready on a separate device.
+2 -39
View File
@@ -69,7 +69,7 @@ LIVEKIT_API_SECRET=...
GEMINI_API_KEY=... GEMINI_API_KEY=...
GEMINI_VISION_MODEL=gemini-2.0-flash GEMINI_VISION_MODEL=gemini-2.0-flash
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview GEMINI_LIVE_MODEL=gemini-live-2.5-flash
GITHUB_TOKEN=... GITHUB_TOKEN=...
GITHUB_REPO=karti-ai/podman GITHUB_REPO=karti-ai/podman
@@ -170,51 +170,14 @@ The droplet production fallback uses systemd units from `infra/systemd/`:
```bash ```bash
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/ 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-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 daemon-reload
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer sudo systemctl enable --now podman-platform-api podman-platform-agent
``` ```
Expected runtime proof: Expected runtime proof:
```bash ```bash
systemctl is-active podman-platform-api podman-platform-agent 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 curl http://127.0.0.1:8787/health
journalctl -u podman-platform-agent -n 20 --no-pager 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 TTS via LiveKit ## 4. Voice Output — Gemini Live 2.5 via LiveKit
**Model:** `gemini-3.1-flash-tts-preview` **Model:** `gemini-live-2.5-flash` (confirm exact ID from LiveKit Agents docs)
**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. **Integration:** LiveKit Agents framework — Hermes runs as a LiveKit Agent with Gemini Live 2.5 as the voice provider
**Flow:** **Flow:**
+1 -11
View File
@@ -79,20 +79,10 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
- `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`) - `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`)
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections) - `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)` - `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
- `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`) - `frontend/src/components/GraphView.tsx` — dark-Bauhaus SVG graph (toggle from `App.tsx`)
## Demo-first plan ## Demo-first plan
1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path). 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. 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. 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.
+203
View File
@@ -0,0 +1,203 @@
# Real Continual-Learning UI — Spec
> Owner: graph data + visualization (live data). Status: **demo-backed graph shipped (`docs/graph.md`); this spec makes it REAL.**
> **Extends `docs/graph.md` — does not duplicate it.** Same files, same contracts (`shared/src/graph.ts`, `PodGraph`, the two collections, the two routes). This spec only adds: a live materializer behind `loadPodGraph`, an outcomes aggregation, a ws push of `GRAPH_DIRTY`, and the per-pod GraphView wiring. Everything in `graph.md` (node/edge kinds, `$graphLookup`, demo fallback) remains the contract.
## 0. Purpose & framing
The demo graph already renders the "it learned" story (`createDemoPodGraph` → Karti owns auth, `learned_from` edge, 86% accept rate). The problem: **none of it is real**`team_model`/`graph_nodes`/`graph_edges` are never written (`seedGraph` has zero callers), so `loadPodGraph` always returns the hardcoded demo. The graph looks alive but is a poster.
**This spec makes the same poster a live render of the 6 collections the agent actually writes** (`pods`, `engineer_states`, `observations`, `collisions`, `interventions`, `outcomes`), so the continual-learning loop the judges see is backed by data the pipeline produced this session.
The visible self-improving loop, before → after:
- **Before:** Two engineers edit `auth.ts`, one unpushed. A `collisions` doc is written, an `interventions` doc (status `pending`). The graph grows a red `collision` triangle and a `warns` edge to the intervention diamond. Copy: _"new — first time PodMan saw this path."_
- **After:** The human clicks Accept → `POST /api/outcome` writes `{accepted:true, wasRealCollision:true}`. The materializer turns that outcome into a **`learned_from` edge** (`intervention → engineer`, label `learned: owns auth.ts`), flips the engineer node to `status:'learned'`, and bumps the **Learned owners** metric. Copy on the next similar collision: _"I've seen this before — last time the team accepted sync PR"_ (driven by `collisions.memorySignature` recall, already live).
**Why this is not a dashboard** (hard constraint): it stays the **secondary, toggle-opened** view behind the pods list (`graph.md`), keeps a single-canvas SVG themed to match the app's shadcn UI (one graph, not a grid of charts), and every visible element is anchored to a live write + an action loop. The metrics rail is 3 numbers derived from real counts, not a wall of KPIs. The hero remains the intervention card in `PodView`; this view exists only to make _"PodMan got better"_ legible in 10 seconds.
## R1 — Revisions (resolves PR #3 review)
Revised after review. The five findings and resolutions (the sections below reflect these):
- **P1 — learned owner was guessed, not supervised → fixed by a contract change.** The Accept flow now carries the confirmed owner: `InterventionOutcome` (`shared/src/messages.ts`) gains `learnedOwner?: string` + `file?: string`, and `frontend/src/livekit/useInterventions.ts` sends `learnedOwner` (the confirming engineer) + `file` (`collision.file`). On `accepted && wasRealCollision`, `POST /api/outcome` writes `team_model.ownership[file] = learnedOwner` — the existing-but-never-written `TeamModel.ownership` type. The materializer draws `owns`/`learned_from` edges from `team_model.ownership` (**authoritative**); the most-recent-observation owner survives only as a clearly-labelled **low-confidence fallback**. Ownership is now **stored, not inferred**.
- **P1 — `changedFiles` aren't clean paths → fixed in the materializer.** They are raw `git status --short` lines (`"M src/auth.ts"`, `"?? x"`, `"R a -> b"`). `live.ts` runs `parseGitStatusPath()` (strip the XY code; post-`->` target for renames) then `normalizeFile()` so file ids match `collisions.file`.
- **P2 — ws bridge made concrete.** `GRAPH_DIRTY` (must-have) is emitted **inside the Express process** on `POST /api/outcome` (no bridge). The agent is a **separate process** publishing to LiveKit; for the nice-to-have instant `COLLISION`/`VOICE_CUE` push, the agent opens a ws **client** to `ws://127.0.0.1:${PORT}/api/events` and forwards them. If unreachable, the 5s poll covers it. Instant push = nice-to-have; poll = floor.
- **P2 — demo metrics shown as live → fixed with a `source` marker.** `PodGraph` gains `source: 'live' | 'demo'` (`createDemoPodGraph`=`'demo'`, materializer=`'live'`). When `source==='demo'` or live-with-zero outcomes, `GraphView` shows a **"demo · waiting for first observation"** badge and renders metric values as `—`, so `5 / 2 / 86%` never reads as live.
- **P3 — PLAN-before-code inconsistency → softened.** This PR is **spec-only**; the `docs/PLAN.md` task entry + `docs/graph.md` cross-link land **with the first implementation PR** (PLAN.md is hot/concurrent; the task ships alongside its code).
**Contract changes (small, additive):** `shared/src/messages.ts` (`InterventionOutcome` +`learnedOwner`/`file`; `TeamModel.ownership` now written), `shared/src/graph.ts` (`PodGraph.source`), `frontend/src/livekit/useInterventions.ts` (Accept sends owner+file).
## R2 — Theme (matches the app)
The app shipped as **light shadcn**; the shipped `GraphView` was **hardcoded dark-Bauhaus** and clashed. Resolution: re-theme `GraphView.tsx` to shadcn tokens (`var(--card)` / `--foreground` / `--muted-foreground` / `--border`, `--chart-1..5` for node hues) so it is **theme-aware** — light like the rest of the app today, dark if the app toggles — and reads as native. The functional encoding (geometric node shapes, per-kind colors, the 3-number metrics rail) is unchanged. This re-theme ships as its own small change to the live component (`feat/team-memory-theme`), independent of the live-data work.
## 1. Live data → graph mapping
The materializer (`backend/src/graph/live.ts`, §3) reads these collections per `podId` and emits `PodGraph` (`shared/src/graph.ts`). Node ids stay stable so realtime refreshes don't reshuffle: `engineer:<name>`, `file:<normalizedFile>`, `collision:<collision.id>`, `intervention:<intervention.id>`.
| Source collection | Fields read | Produces |
| ------------------------------------------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`pods`** | `members[]`, `name`, `repo` | Baseline `engineer:` nodes for every roster member (so the graph isn't empty pre-activity). `summary` = pod `repo`. |
| **`engineer_states`** (via `getGitStates`) | `name`, `changedFiles[]`, `branch`, `recentCommit`, `gitUpdatedAt` | `engineer:<name>` node `status:'risk'` when `changedFiles.length>0`. **`changedFiles[]` are raw `git status --short` lines (e.g. `"M src/auth.ts"`, `"?? x.ts"`, `"R a -> b"`), NOT clean paths (R1)** — `live.ts` runs `parseGitStatusPath()` (strip the XY code; post-`->` target for renames) then `normalizeFile()` so file ids match `collisions.file`. One `file:` node per parsed path; `editing` edge engineer→file, `strength` 0.6. Engineer `summary` = `"N changed files on <branch>"`. |
| **`observations`** (`EngineerContext`) | `engineerId`, `currentFile`, `currentSymbol`, `activity`, `confidence`, `observedAt` | `engineer:<engineerId>` node `status:'active'` if a `observedAt` within last 60s exists; `file:<currentFile>` node; `editing` edge engineer→file, `strength` = `confidence` (Gemini meter). Most-recent `observedAt` gives a file's de-facto owner — used **only as the low-confidence fallback** for the `owns` edge when `team_model.ownership` is empty (R1). Edge `label` = `activity`. |
| **`collisions`** (`Collision`) | `id`, `file`, `symbol`, `engineers[]`, `severity`, `detectedAt`, `memorySignature` | One `collision:<id>` node, `kind:'collision'`, `status:'risk'`, `weight` by severity (`info`0.4/`warn`0.7/`critical`1.0). `collides` edge per name in `engineers[]` (engineer→collision, `strength` from severity). `touches` edge `file:<file>`→collision. A `summary` badge `"seen before"` when `memorySignature` matched a prior collision (severity escalated to `critical` — already the live recall signal). |
| **`interventions`** (`Intervention`) | `id`, `collisionId`, `kind`, `suggestedAction.kind`, `status`, `createdAt` | One `intervention:<id>` diamond. `warns` edge `collision:<collisionId>`→intervention, `label` from `suggestedAction.kind` (`open_sync_pr``"sync PR"`, `ping_teammate``"ping"`, `none``"watch"`). **Color cannot come from `status`** (always `pending` — never updated), so it is joined to `outcomes` (next row). |
| **`outcomes`** (`InterventionOutcome`) | `interventionId`, `wasRealCollision`, `accepted`, `recordedAt` | **The supervised learning signal (R1).** On `accepted && wasRealCollision` the outcome carries `learnedOwner` + `file`; the server writes `team_model.ownership[file]=learnedOwner`. The materializer reads `team_model.ownership` (**authoritative**) to draw the `owns` edge and a `learned_from` edge `intervention:<id>``engineer:<learnedOwner>` (`label: learned: owns <file>`, `strength` 0.6), flipping that engineer + intervention to `status:'learned'`. `accepted===false` → intervention `status:'stable'` ("dismissed"). Observation-based ownership is **only** a low-confidence fallback when `team_model.ownership` is empty — never the primary signal. |
**Metrics rail** (`PodGraphMetric[]`, replacing demo's hardcoded `5 / 2 / 86%`), computed live in `live.ts`:
| `label` | `value` | `detail` | Source |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------- |
| `Learned owners` | count of accepted+real outcomes | `"Ownership edges retained from accepted interventions."` | `outcomes` aggregation |
| `Open risk paths` | count of `collisions` with `engineers.length>=2` and any colliding engineer has unpushed (`engineer_states.changedFiles` non-empty) | `"Files with converging editors and unpushed work."` | `collisions` × `engineer_states` |
| `Accept rate` | `round(accepted / total outcomes * 100)%` (`"—"` when total 0) | `"Interventions accepted this session."` | `outcomes` aggregation |
`x`/`y` layout: `live.ts` runs a deterministic column layout (engineers x≈78, files x≈300, collisions x≈470, interventions/features x≈620; y spread evenly per column within the `0..472` viewBox) so the existing SVG renders unchanged.
## 2. The visible workflow (observe → store → predict → outcome → adapt)
The graph view narrates the same loop the agent runs, mapped to what the viewer sees:
1. **Observe** (`observations`, ~1fps; `engineer_states`, 15s): engineer nodes light `active`; `editing` edges thicken with Gemini `confidence`. A small caption under the canvas: _"Yahya — editing auth.ts (unpushed)"_ from the latest observation + git chip.
2. **Store / Predict** (`collisions` + vector recall): when ≥2 engineers + unpushed, a red `collision` triangle and `collides`/`touches` edges appear. If `memorySignature` recall hit (severity `critical`), the node carries a **"seen before"** badge — the self-improvement signal.
3. **Intervene** (`interventions`): a `warns` edge draws to a new `intervention` diamond labeled by `suggestedAction.kind`. The view shows a _"PodMan is speaking"_ pulse on that edge when a `VOICE_CUE` arrives over ws (§5).
4. **Outcome** (`POST /api/outcome` from the `PodView` card): the moment the human clicks Accept.
5. **Adapt** (`outcomes``learned_from`): on the next graph refresh, a dashed purple `learned_from` edge animates in, the engineer node flips to `learned` (gold/locked), and **Learned owners** + **Accept rate** tick up.
**The live "money moment" (sequence the demo lands):** collision detected → red triangle + edges appear and PodMan speaks (pulse) → human clicks Accept on the card in `PodView` → switch to Team memory → the `learned_from` edge to `engineer:karti` (`learned: owns auth.ts`) is now present, the engineer is gold, metrics rose. A second collision on the same signature renders with the "seen before" badge. That single before→after transition, all from collections written this session, is the continual-learning proof.
## 3. Backend changes
All additive / behind existing signatures — `graph.md` contracts unchanged.
### 3a. Live materializer — `backend/src/graph/live.ts` (NEW)
`export async function materializePodGraph(podId: string): Promise<PodGraph>`. Reads the 6 collections via `collections()` + `getGitStates(podId)` (`memory/db.ts`), builds nodes/edges/metrics per §1, runs the column layout, returns `PodGraph`. Pure-read; never writes. Wrapped so any Mongo error throws to the caller (which falls back to demo, §3b). Helpers: `parseGitStatusPath()` (strip the `git status --short` XY code; take the post-`->` target for renames) applied to `engineer_states.changedFiles`, then `normalizeFile()` reused from `collision/detector.ts` so file ids match `collisions.file` exactly. Ownership for `owns`/`learned_from` edges is read from `team_model.ownership` (authoritative); observation-based ownership is only a marked low-confidence fallback.
### 3b. `loadPodGraph` — `backend/src/graph/store.ts` (MODIFY)
Replace the body so it prefers live, then `team_model`, then demo:
```
1. const graph = await materializePodGraph(podId)
2. if (graph.nodes.length > <BASELINE>) return graph // real activity exists
3. const doc = team_model.findOne({podId}); if (doc?.graph) return doc.graph
4. return createDemoPodGraph(podId) // demo-stability fallback
```
`<BASELINE>` = the count of pure roster `engineer:` nodes (no files/collisions). If only roster nodes exist (no observations/collisions yet) the view shows demo so the canvas is never empty mid-demo (§5). Signature, route, and demo fallback all unchanged.
### 3c. Keep `$graphLookup` real — mirror into `graph_nodes`/`graph_edges`
Add `export async function materializeAndSeed(podId)` in `store.ts`: calls `materializePodGraph`, then reuses `seedGraph`'s existing upsert/`deleteMany`/`insertMany` block to write `team_model.graph` + `graph_nodes` + `graph_edges` from the **live** graph (today `seedGraph` writes the demo graph from `createDemoPodGraph`; this variant takes the live one). Called (a) on every `POST /api/outcome` (so `reachFrom` reflects the new `learned_from` edge), and (b) lazily inside the graph route after `loadPodGraph` returns a live graph. This is the only way `reachFrom`/`/graph/reach/:node` stops returning empty.
### 3d. Routes — `backend/src/server.ts` (MODIFY, additive only)
- `GET /api/pods/:id/graph` — unchanged signature; now returns live graph via §3b.
- `GET /api/pods/:id/graph/reach/:node` — unchanged; now non-empty once §3c runs.
- `GET /api/pods/:id/graph/metrics` (NEW, small) — returns just `PodGraphMetric[]` (the outcomes aggregation: `learned owners`, `open risk paths`, `accept rate`) so the rail can poll cheaply without re-sending the whole graph. Backed by a `db.collection('outcomes').aggregate` group on `podId` (count, `$sum accepted`).
- `POST /api/outcome` (MODIFY): after `recordOutcome`, when `accepted && wasRealCollision` **write `team_model.ownership[outcome.file] = outcome.learnedOwner`** (the supervised signal — `TeamModel` type, upsert on `podId`), then call `materializeAndSeed(podId)` (best-effort, never throws) **and** broadcast `{type:'GRAPH_DIRTY', podId}` to ws `/api/events` clients (reuse the existing `clients` set / `c.send`). This is the only place the loop closes.
### 3e. Realtime push — ws `/api/events` (MODIFY)
The relay already fans out any JSON. **`GRAPH_DIRTY` needs no bridge** — it is emitted from inside the Express process on `POST /api/outcome`, so it reaches ws clients directly. **The agent runs in a SEPARATE process** (`dev:agent` / `agent.ts`) and publishes `COLLISION`/`VOICE_CUE` on the **LiveKit** data channel, which the server's ws clients never see. Concrete bridge for the (nice-to-have) instant push: the agent opens a WebSocket **client** to `ws://127.0.0.1:${PORT}/api/events` and forwards the same `COLLISION`/`VOICE_CUE` JSON; the relay fans it out. If the agent can't reach the API, the **5s poll (§5) covers it** (≤5s). This instant push is explicitly **nice-to-have** (§6); the poll is the floor.
## 4. Frontend changes
### 4a. Per-pod entry point — `frontend/src/App.tsx` (MODIFY)
The "Team memory" button currently passes `pods[0]?.id ?? 'demo-pod'` (line 271) — wrong pod for a multi-pod demo. Change to open the graph for the **pod in context**: add a `BrainCircuitIcon` action on each `PodCard` (`onOpenGraph(pod.id)`) wired to `setGraphPodId(pod.id)`, and keep the header button as a fallback that opens the **selected/first live pod** (the one with presence). The router slot (`if (graphPodId) return <GraphView podId={graphPodId} …/>`, line 237-239) is unchanged.
### 4b. `GraphView.tsx` — match the app's shadcn theme, add realtime
**Themed to match the command-center (R2).** The hardcoded dark `.pm-*` palette is replaced with shadcn tokens (`var(--card)` / `--foreground` / `--muted-foreground` / `--border`) so Team Memory is **theme-aware** (renders light like the rest of the app today, follows dark mode if the app toggles) and reads as native, not a dark island. **Unifying-template rule:** the chrome composes from the shadcn/ruixen registry primitives — `Button`, `Badge`, and the app's Tailwind utility patterns (`StatPill`/`BriefLine`-style) from `@/components/ui/*`; add any new primitive with `npx shadcn@latest add "https://ruixen.com/r/[component]"`. **Only the SVG node-link canvas is bespoke** (3 SVG-only CSS rules). The functional encoding stays: geometric node shapes (engineer square / file square-outline / feature circle / collision triangle / intervention diamond) and per-kind light-readable hues. What changes:
- **Realtime refresh:** open a ws to `/api/events` on mount; on `{type:'GRAPH_DIRTY', podId}` (matching this pod) or `COLLISION`/`VOICE_CUE`, re-call `fetchPodGraph(podId)` (and `fetchGraphMetrics`). Also a **5s poll** of `fetchPodGraph` as the floor (mirrors `App.tsx`'s existing 5s presence/memory poll) so it's live even if ws drops. New incoming nodes/edges fade in (CSS opacity transition on `<line>`/shape); `learned_from` edges animate the dashed stroke.
- **"new" vs "seen before" copy** in the detail rail from `collision` node `summary` badge (§1).
- **Speaking pulse** on the `warns` edge when a `VOICE_CUE` lands.
### 4c. `frontend/src/lib/graph.ts` (MODIFY)
- Fix `BACKEND_URL` to follow `lib/api.ts`'s resolution (empty string in prod) instead of hardcoding `localhost:8787`.
- Add `fetchGraphMetrics(podId)``GET /api/pods/:id/graph/metrics`.
- Add `openGraphEvents(podId, onDirty)` → thin `WebSocket('/api/events')` subscription helper (reused by `GraphView`).
### 4d. Loading / empty / error states (collections empty is the common real case)
- **Loading:** existing on-mount spinner; keep.
- **Empty (no activity yet):** §3b returns the **demo graph** so the canvas is never blank — but `PodGraph` now carries **`source: 'live' | 'demo'`** (R1): `createDemoPodGraph` sets `'demo'`, the materializer `'live'`. When `graph.source==='demo'` (or `'live'` with metrics total 0), `GraphView` renders a **"demo · waiting for first observation"** badge and **suppresses the metric values (shows `—`)** so the baked `5 / 2 / 86%` never reads as live. No empty-grid placeholder.
- **Error / backend down:** `fetchPodGraph` rejects → render the last good graph if any, else the demo graph rendered client-side is not available; show a single-line `.pm-` error chip _"memory offline — retrying"_ and keep polling. ws errors are swallowed (poll covers it).
## 5. Realtime & data freshness
| Surface | Transport | Cadence |
| --------------------------------- | ---------------------------------------------------- | ------------------------------- |
| Engineer `active`/file edits | ws `COLLISION` passthrough + 5s `fetchPodGraph` poll | ~1fps source data, surfaced ≤5s |
| Git chip (changed files / branch) | folded into 5s graph poll (`engineer_states`) | 15s underlying write |
| Collision / intervention nodes | ws `COLLISION` (instant) → triggers refetch | instant on event |
| Speaking pulse | ws `VOICE_CUE` | instant |
| `learned_from` edge + metrics | ws `GRAPH_DIRTY` on `POST /api/outcome` → refetch | instant on Accept |
**Polling is the floor, ws is the accelerator** — never gate the canvas on ws. **Demo-stability fallback:** if the live materializer yields ≤ baseline nodes or Mongo is unreachable, the route serves `createDemoPodGraph` (§3b) so the toggle always shows a coherent graph on stage. The Accept→`learned_from` beat is driven by `POST /api/outcome``materializeAndSeed``GRAPH_DIRTY`, the one path that must be solid.
## 6. Demo path
**Must-have (the 3-min money moment):**
- `materializePodGraph` reading `collisions` + `interventions` + `outcomes` + `engineer_states` so the graph reflects this session.
- `POST /api/outcome``materializeAndSeed` + `GRAPH_DIRTY`; GraphView refetches and the `learned_from` edge + gold node + risen metrics appear after Accept.
- Live metrics rail (Learned owners / Open risk paths / Accept rate) from the outcomes aggregation.
- "seen before" badge from `collisions.memorySignature` (already live) on the second collision.
- Demo-graph fallback when collections are empty (stage safety).
**Nice-to-have:**
- ws `VOICE_CUE` speaking pulse on the `warns` edge.
- `/graph/reach/:node` lit risk-path walk (`$graphLookup`) once `materializeAndSeed` populates `graph_edges`.
- Fade/stroke animations on incoming edges.
- Per-`PodCard` graph entry button.
**Cut if behind (per CLAUDE.md 12h box):**
- Vector-search dependency for recall (exact `memorySignature` recall already covers the learning beat).
- Any new chart primitive / recharts — keep the SVG.
- Auth/pod-scoping on ws `/api/events`.
- Historical/time-scrubbed graph; only "now" is needed.
**The 3-min beat (extends `PLAN.md` §9, ends in this view):** IDE with unpushed `auth.ts` → live caption → second engineer opens same file → **COLLISION** card in `PodView` (named teammate + sync PR action) → Hermes voice → human clicks **Accept** → toggle **Team memory** → the `learned_from` "learned: Karti owns auth.ts" edge is now real, metrics rose → second collision shows "I've seen this before" → close on the public repo PR URL + the live metrics.
## 7. Files & tasks (documentation-first)
> Per CLAUDE.md gate: the spec lands before code. **This PR is spec-only.** The `docs/PLAN.md` task entry (`Pxx — Live continual-learning graph`, Files list below) + the `docs/graph.md` "Live data backing" cross-link land **with the first implementation PR** — `PLAN.md` is a hot, concurrently-edited file, so the task ships alongside the code that satisfies it rather than as a separate doc-only edit that would conflict.
**Create:**
- `backend/src/graph/live.ts``materializePodGraph(podId)`, `normalizeFile`, metrics aggregation, column layout. _(Task: live materializer)_
**Modify:**
- `docs/graph.md` — append a **"Live data backing"** section pointing to this spec (the §1 mapping table + `live.ts`); change the "Demo-first plan" step 3 ("Swap `loadPodGraph`…") to "done via `materializePodGraph`." _(documentation-first)_
- `docs/PLAN.md` — add the task + Files list. _(documentation-first)_
- `backend/src/graph/store.ts``loadPodGraph` prefers live (§3b); add `materializeAndSeed(podId)` (§3c). _(Task: live wiring + $graphLookup)_
- `backend/src/server.ts``POST /api/outcome` calls `materializeAndSeed` + broadcasts `GRAPH_DIRTY`; new `GET /api/pods/:id/graph/metrics`; ws passthrough of `COLLISION`/`VOICE_CUE`/`GRAPH_DIRTY`. _(Task: routes + realtime)_
- `backend/src/agent/podman.ts` — also emit `COLLISION`/`VOICE_CUE` onto ws `/api/events` (mirror of the LiveKit data-channel publish) so the dashboard-level GraphView reacts. _(Task: realtime mirror)_
- `frontend/src/lib/graph.ts``BACKEND_URL` resolution fix; `fetchGraphMetrics`; `openGraphEvents`. _(Task: client)_
- `shared/src/messages.ts``InterventionOutcome` +`learnedOwner?`/`file?`; `TeamModel.ownership` now written (R1). _(Task: supervised signal)_
- `shared/src/graph.ts``PodGraph` +`source: 'live' | 'demo'` (R1). _(Task: live/demo honesty)_
- `frontend/src/livekit/useInterventions.ts` — Accept `postOutcome` sends `learnedOwner` + `file` (R1). _(Task: supervised signal)_
- `frontend/src/components/GraphView.tsx` — re-theme to shadcn tokens (theme-aware, R2); ws subscription + 5s poll + animations + "new/seen-before" rail copy + speaking pulse. _(Task: client)_
- `frontend/src/App.tsx` — per-pod graph entry; header button opens live/selected pod not `pods[0]`. _(Task: entry point)_
- `frontend/src/components/PodCard.tsx``onOpenGraph(pod.id)` action. _(Task: entry point)_
**Contract changes (small, additive — R1):** `shared/src/messages.ts` (`InterventionOutcome` +`learnedOwner`/`file`; `TeamModel.ownership` now written), `shared/src/graph.ts` (`PodGraph` +`source`), `frontend/src/livekit/useInterventions.ts` (Accept sends owner+file). **Still unchanged:** the two graph routes' signatures, node/edge kinds, `createDemoPodGraph` (kept as fallback), `seedGraph`/`graph:seed` (kept; `materializeAndSeed` reuses its write block).
## 8. Risks & open questions
- **`engineer_states` is keyed by `name`; `observations` by `engineerId`; collisions by names in `engineers[]`.** The materializer must reconcile these to one `engineer:<name>` node. Assumption (from data map): LiveKit identity == `--name` == engineer name. If they diverge, edges will orphan. **Mitigation:** key all engineer nodes off `pods.members` and match case-insensitively; drop unmatched.
- **`outcomes` had no owner identity (review P1) — resolved (R1):** the Accept payload now carries `learnedOwner` + `file`, persisted to `team_model.ownership[file]`, so the `learned_from`/`owns` edges are **supervised, not guessed**. The `outcome → intervention → collision` join + most-recent-observation owner remains **only** as a clearly-marked low-confidence fallback when `learnedOwner`/`team_model.ownership` is absent.
- **`observations` TTL may not fire** (init.ts indexes `observedAt` as Date but it's stored as ISO string) — so "active" windowing must compare parsed ISO timestamps in `live.ts`, not rely on TTL eviction; old observations could otherwise inflate "active." **Open:** cap to most-recent observation per `(engineerId,file)`.
- **`intervention.status` never advances past `pending`** — confirmed; the UI must color interventions from the `outcomes` join, never from `status`. Already handled in §1, but worth a one-line code comment so a future dev doesn't "fix" it by reading `status`.
- **ws `/api/events` has no pod-scoping** — `GRAPH_DIRTY` carries `podId` and the client filters; acceptable for the demo, flagged as the known shortcut.
- **Open question:** should `materializeAndSeed` run on every `POST /api/outcome` (simple, slightly heavy) or be debounced? For a 3-engineer demo, run inline; revisit only if outcome volume spikes.
- **Open question:** when both a live `materializePodGraph` graph and a `team_model.graph` exist, live wins (§3b). Confirm no flow expects `team_model.graph` to be authoritative — currently nothing writes it except the dead `seedGraph`, so live-wins is safe.
+4 -4
View File
@@ -89,11 +89,11 @@ Hermes uses the same endpoint. Grants:
--- ---
## Gemini voice model ## Gemini Live 2.5 model
- Model ID: `gemini-3.1-flash-tts-preview` - Model ID: `gemini-live-2.5-flash` — confirm exact ID from LiveKit Agents + Gemini docs at build time
- Hermes generates Gemini TTS audio and publishes it as a LiveKit audio track. - LiveKit Agents has native Gemini Live integration — no manual audio encoding needed
- The backend keeps a Gemini Live path for future model availability, but the verified deployment path uses TTS. - Hermes passes text string → Agents handles streaming audio publication
--- ---
@@ -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 }` - **Vision:** `gemini-2.0-flash` — screen → `{ currentFile, inferredTask, terminalVisible, recentTerminalOutput, confidence }`
- **Event detection:** `gemini-2.0-flash` — all engineer states → `{ event, involvedEngineers, file, reason }` - **Event detection:** `gemini-2.0-flash` — all engineer states → `{ event, involvedEngineers, file, reason }`
- **Nudge generation:** `gemini-2.0-flash` — event → spoken message text - **Nudge generation:** `gemini-2.0-flash` — event → spoken message text
- **Voice:** `gemini-3.1-flash-tts-preview` via LiveKit audio publication — text → audio - **Voice:** `gemini-live-2.5-flash` via LiveKit Agents — text → streaming audio
### MongoDB Atlas (4 collections) ### MongoDB Atlas (4 collections)
+121 -110
View File
@@ -1,36 +1,24 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react'; import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared'; import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
import { fetchPodGraph } from '../lib/graph.js'; import { fetchPodGraph } from '../lib/graph.js';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
type Mode = 'risk' | 'learn' | 'all'; 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> = { const KIND_COLOR: Record<PodGraphNodeKind, string> = {
engineer: BLUE, engineer: '#3B5BFF',
file: SLATE, file: '#ECE7DA',
feature: AMBER, feature: '#F6C445',
collision: RED, collision: '#E2403A',
intervention: VIOLET, intervention: '#8b6cff',
}; };
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = { const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
owns: { c: BLUE, w: 2.6 }, owns: { c: '#3B5BFF', w: 2.6 },
editing: { c: SLATE_EDGE, w: 2 }, editing: { c: '#ECE7DA', w: 2 },
touches: { c: SLATE_FAINT, w: 1.6 }, touches: { c: '#5d5d66', w: 1.6 },
collides: { c: RED, w: 3.2 }, collides: { c: '#E2403A', w: 3.2 },
warns: { c: AMBER, w: 3.2 }, warns: { c: '#F6C445', w: 3.2 },
learned_from: { c: VIOLET, w: 2.4, dash: true }, learned_from: { c: '#8b6cff', w: 2.4, dash: true },
}; };
function NodeShape({ node }: { node: PodGraphNode }) { function NodeShape({ node }: { node: PodGraphNode }) {
@@ -38,7 +26,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
const { x, y } = node; const { x, y } = node;
switch (node.kind) { switch (node.kind) {
case 'engineer': case 'engineer':
return <rect x={x - 15} y={y - 15} width={30} height={30} rx={4} fill={c} />; return <rect x={x - 15} y={y - 15} width={30} height={30} fill={c} />;
case 'file': case 'file':
return ( return (
<rect <rect
@@ -46,7 +34,6 @@ function NodeShape({ node }: { node: PodGraphNode }) {
y={y - 15} y={y - 15}
width={30} width={30}
height={30} height={30}
rx={4}
fill="none" fill="none"
stroke={c} stroke={c}
strokeWidth={2.6} strokeWidth={2.6}
@@ -94,19 +81,16 @@ function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Hig
} }
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [ const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
{ label: 'engineer', swatch: { background: BLUE } }, { label: 'engineer', swatch: { background: '#3B5BFF' } },
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } }, { label: 'file', swatch: { border: '2px solid #ECE7DA' } },
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } }, { label: 'feature', swatch: { background: '#F6C445', borderRadius: '50%' } },
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } }, {
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } }, label: 'collision',
swatch: { background: '#E2403A', clipPath: 'polygon(50% 0,100% 100%,0 100%)' },
},
{ label: 'intervention', swatch: { background: '#8b6cff', 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 }) { export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
const [graph, setGraph] = useState<PodGraph | null>(null); const [graph, setGraph] = useState<PodGraph | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -144,70 +128,99 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
setSelected(null); setSelected(null);
} }
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="pm-graph">
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<style>{` <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-node{cursor:pointer}
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500} .pm-lbl{font-weight:500;font-size:11px;letter-spacing:.06em;fill:var(--paper);text-transform:uppercase}
.pm-dim{opacity:.18;transition:opacity .25s} .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)}}
`}</style> `}</style>
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground"> <div className="pm-hd">
<div className="flex items-center justify-between border-b px-5 py-4">
<div> <div>
<h2 className="text-base font-medium">Team memory</h2> <div className="pm-ttl">Team memory</div>
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p> <div className="pm-sub">What PodMan learned · {podId}</div>
</div> </div>
<Button variant="outline" size="sm" onClick={onClose}> <button className="pm-x" onClick={onClose}>
Pods Pods
</Button> </button>
</div> </div>
<div className="flex flex-wrap gap-2 border-b px-4 py-3"> <div className="pm-bar">
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}> <button
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`}
onClick={() => pick('risk')}
>
Risk path Risk path
</Button> </button>
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}> <button
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`}
onClick={() => pick('learn')}
>
Learning edges Learning edges
</Button> </button>
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}> <button
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
onClick={() => pick('all')}
>
Whole graph Whole graph
</Button> </button>
</div> </div>
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>} {error && (
<p style={{ padding: '16px', color: '#ff7d76', fontSize: 13 }}>Graph error: {error}</p>
)}
{!graph && !error && ( {!graph && !error && (
<p className="px-4 py-4 text-sm text-muted-foreground">Loading graph</p> <p style={{ padding: '16px', color: '#8d897e', fontSize: 13 }}>Loading graph</p>
)} )}
{graph && ( {graph && (
<> <>
<div className="grid lg:grid-cols-[190px_1fr_250px]"> <div className="pm-grid">
<div className="space-y-3 p-4"> <div className="pm-col">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="pm-st">Workflow metrics</div>
Workflow metrics
</p>
{graph.metrics.map((m) => ( {graph.metrics.map((m) => (
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5"> <div className="pm-kpi" key={m.label}>
<p className="text-2xl font-medium tabular-nums">{m.value}</p> <div className="pm-num">{m.value}</div>
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground"> <div className="pm-klab">{m.label}</div>
{m.label} <div className="pm-kdet">{m.detail}</div>
</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
</div> </div>
))} ))}
</div> </div>
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0"> <div className="pm-canvas">
<svg <svg viewBox="0 0 720 472" role="img" aria-label="PodMan team-memory graph">
viewBox="0 0 720 472"
role="img"
aria-label="PodMan team-memory graph"
className="block h-auto w-full"
>
{graph.edges.map((e) => { {graph.edges.map((e) => {
const a = nodeById.get(e.source); const a = nodeById.get(e.source);
const b = nodeById.get(e.target); const b = nodeById.get(e.target);
@@ -245,73 +258,71 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
> >
<NodeShape node={n} /> <NodeShape node={n} />
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle"> <text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
{n.label} {n.label.toUpperCase()}
</text> </text>
</g> </g>
))} ))}
</svg> </svg>
</div> </div>
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0"> <div className="pm-col pm-railR">
{sel ? ( {sel ? (
<> <>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <div className="pm-dkind">{sel.kind}</div>
{sel.kind} <div className="pm-dname">{sel.label}</div>
</p> <div className="pm-drow">
<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> <span>Status</span>
<Badge variant="outline" style={{ color: statusColor(sel.status) }}> <b
style={{
color:
sel.status === 'risk'
? '#E2403A'
: sel.status === 'learned'
? '#b7a4ff'
: '#ECE7DA',
}}
>
{sel.status} {sel.status}
</Badge> </b>
</div> </div>
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground"> <div className="pm-drow">
<span>Relationships</span> <span>Relationships</span>
<span className="font-medium text-foreground">{relCount}</span> <b>{relCount}</b>
</div> </div>
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground"> <div className="pm-note">{sel.summary}</div>
{sel.summary}
</p>
</> </>
) : ( ) : (
<> <>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <div className="pm-dkind">Continual learning</div>
Continual learning <div className="pm-dname">It learned</div>
</p> <div className="pm-note">
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3> Violet <b style={{ color: '#b7a4ff' }}>learned_from</b> edges are ownership
<p className="text-sm leading-relaxed text-muted-foreground"> PodMan retained from accepted interventions the graph gets sharper every
The violet{' '} session. Click any node to trace its relationships.
<span className="font-medium" style={{ color: VIOLET }}> </div>
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> </div>
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground"> <div className="pm-legend">
{LEGEND.map((l) => ( {LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5"> <span className="pm-lg" key={l.label}>
<span className="inline-block size-3" style={l.swatch} /> <span className="pm-sw" style={l.swatch} />
{l.label} {l.label}
</span> </span>
))} ))}
<span className="flex items-center gap-1.5"> <span className="pm-lg">
<span className="inline-block h-[3px] w-3" style={{ background: RED }} /> <span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
collides collides
</span> </span>
<span className="flex items-center gap-1.5"> <span className="pm-lg">
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} /> <span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
learned_from learned_from
</span> </span>
</div> </div>
</> </>
)} )}
</div> </div>
</div>
</div>
); );
} }
+3 -46
View File
@@ -4,8 +4,6 @@ import {
ArrowLeftIcon, ArrowLeftIcon,
CheckIcon, CheckIcon,
CircleDotIcon, CircleDotIcon,
ExternalLinkIcon,
MessageSquareIcon,
MonitorUpIcon, MonitorUpIcon,
RadioTowerIcon, RadioTowerIcon,
SparklesIcon, SparklesIcon,
@@ -82,7 +80,7 @@ export function PodView({
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
const [playingBeat, setPlayingBeat] = useState(false); const [playingBeat, setPlayingBeat] = useState(false);
const [note, setNote] = useState<string | null>(null); const [note, setNote] = useState<string | null>(null);
const { active, hermes, voiceCue, actionUrl, respond } = useInterventions(room); const { active, respond } = useInterventions(room);
const audioRef = useRef<HTMLDivElement>(null); const audioRef = useRef<HTMLDivElement>(null);
const beatRef = useRef<BeatHandle | null>(null); const beatRef = useRef<BeatHandle | null>(null);
@@ -183,15 +181,6 @@ 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'); const podmanPresent = participants.some((p) => p.name.toLowerCase() === 'podman');
return ( return (
@@ -310,24 +299,6 @@ export function PodView({
{active.suggestedAction.kind.replaceAll('_', ' ')} {active.suggestedAction.kind.replaceAll('_', ' ')}
</Badge> </Badge>
</div> </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> </div>
) : ( ) : (
<Empty className="min-h-72 border-0 p-0"> <Empty className="min-h-72 border-0 p-0">
@@ -343,28 +314,14 @@ export function PodView({
</EmptyHeader> </EmptyHeader>
</Empty> </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> </CardContent>
{active && ( {active && (
<CardFooter className="justify-end gap-2"> <CardFooter className="justify-end gap-2">
<Button <Button variant="outline" onClick={() => void respond('dismissed', false)}>
variant="outline"
onClick={() => void answerIntervention('dismissed', false)}
>
<XIcon data-icon="inline-start" /> <XIcon data-icon="inline-start" />
Dismiss Dismiss
</Button> </Button>
<Button onClick={() => void answerIntervention('accepted', true)}> <Button onClick={() => void respond('accepted', true)}>
<CheckIcon data-icon="inline-start" /> <CheckIcon data-icon="inline-start" />
Accept Accept
</Button> </Button>
-14
View File
@@ -46,20 +46,6 @@ export async function postOutcome(outcome: InterventionOutcome): Promise<void> {
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`); 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 --- // --- Pods CRUD ---
export async function listPods(): Promise<Pod[]> { export async function listPods(): Promise<Pod[]> {
+4 -19
View File
@@ -1,26 +1,18 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { RoomEvent, type Room } from 'livekit-client'; import { RoomEvent, type Room } from 'livekit-client';
import type { DataMessage, HermesMessage, Intervention, InterventionStatus } from '@podman/shared'; import type { DataMessage, Intervention, InterventionStatus } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared'; import { DATA_TOPIC } from '@podman/shared';
import { createSyncPr, postOutcome } from '../lib/api'; import { postOutcome } from '../lib/api';
export function useInterventions(room: Room | null) { export function useInterventions(room: Room | null) {
const [active, setActive] = useState<Intervention | null>(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(() => { useEffect(() => {
if (!room) return; if (!room) return;
const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => { const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => {
if (topic !== DATA_TOPIC) return; if (topic !== DATA_TOPIC) return;
const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage; const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;
if (msg.type === 'COLLISION') { if (msg.type === 'COLLISION') setActive(msg.intervention);
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); room.on(RoomEvent.DataReceived, onData);
return () => { return () => {
@@ -31,13 +23,6 @@ export function useInterventions(room: Room | null) {
const respond = useCallback( const respond = useCallback(
async (status: InterventionStatus, accepted: boolean) => { async (status: InterventionStatus, accepted: boolean) => {
if (!active) return; 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({ await postOutcome({
interventionId: active.id, interventionId: active.id,
collisionId: active.collisionId, collisionId: active.collisionId,
@@ -52,5 +37,5 @@ export function useInterventions(room: Room | null) {
[active], [active],
); );
return { active, hermes, voiceCue, actionUrl, respond }; return { active, respond };
} }
+5 -32
View File
@@ -4,7 +4,7 @@ Deploy targets for PodMan on DigitalOcean.
- `Dockerfile` — builds the backend runtime image from the monorepo root - `Dockerfile` — builds the backend runtime image from the monorepo root
- `app.yaml` — DigitalOcean App Platform spec: static site, API service, agent worker - `app.yaml` — DigitalOcean App Platform spec: static site, API service, agent worker
- `systemd/` — local droplet service/timer units for the API, agent worker, public healthcheck, and Hermes watchdog - `systemd/` — local droplet service units for the API and agent worker
Full deploy spec and env var reference in [`docs/digitalocean.md`](../docs/digitalocean.md). Full deploy spec and env var reference in [`docs/digitalocean.md`](../docs/digitalocean.md).
@@ -31,10 +31,9 @@ On the demo droplet, serve the API and worker with systemd instead of tmux:
```bash ```bash
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/ 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-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 daemon-reload
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer sudo systemctl enable --now podman-platform-api podman-platform-agent
sudo systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer sudo systemctl status podman-platform-api podman-platform-agent
``` ```
The services expect: The services expect:
@@ -48,7 +47,6 @@ Useful checks:
```bash ```bash
curl http://127.0.0.1:8787/health curl http://127.0.0.1:8787/health
journalctl -u podman-platform-api -u podman-platform-agent -f journalctl -u podman-platform-api -u podman-platform-agent -f
journalctl -u podman-hermes-watchdog -f
``` ```
## DigitalOcean deploy ## DigitalOcean deploy
@@ -75,34 +73,9 @@ local LiveKit host.
```bash ```bash
sudo cp infra/systemd/podman-platform-*.service /etc/systemd/system/ 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 daemon-reload
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer sudo systemctl enable --now podman-platform-api podman-platform-agent
systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer systemctl status podman-platform-api podman-platform-agent
```
## 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) ## Fallback (demo safety)
+2 -4
View File
@@ -48,8 +48,7 @@ services:
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET } - { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_API_KEY, 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_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview } - { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET } - { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman } - { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET } - { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
@@ -74,8 +73,7 @@ workers:
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET } - { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
- { key: GEMINI_API_KEY, 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_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview } - { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET } - { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman } - { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET } - { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
@@ -1,16 +0,0 @@
[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
@@ -1,11 +0,0 @@
[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
@@ -1,17 +0,0 @@
[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
@@ -1,11 +0,0 @@
[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,10 +20,6 @@
"doctor": "node scripts/deploy-doctor.mjs", "doctor": "node scripts/deploy-doctor.mjs",
"doctor:strict": "node scripts/deploy-doctor.mjs --strict", "doctor:strict": "node scripts/deploy-doctor.mjs --strict",
"deploy:static:local": "node scripts/deploy-static-local.mjs", "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", "healthcheck:public": "node scripts/healthcheck-public.mjs",
"verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend", "verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend",
"verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers", "verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers",
+11 -79
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Buffer } from 'node:buffer';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { MongoClient } from 'mongodb'; import { MongoClient } from 'mongodb';
@@ -16,6 +15,7 @@ const requiredEnv = [
'LIVEKIT_URL', 'LIVEKIT_URL',
'LIVEKIT_API_KEY', 'LIVEKIT_API_KEY',
'LIVEKIT_API_SECRET', 'LIVEKIT_API_SECRET',
'GEMINI_API_KEY',
'GITHUB_TOKEN', 'GITHUB_TOKEN',
'GITHUB_REPO', 'GITHUB_REPO',
'MONGODB_URI', 'MONGODB_URI',
@@ -33,19 +33,6 @@ function isSet(name) {
return !!process.env[name]?.trim(); 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) { async function check(name, fn) {
try { try {
const detail = await fn(); const detail = await fn();
@@ -218,12 +205,12 @@ async function checkGitHub() {
} }
async function checkGeminiVision() { async function checkGeminiVision() {
const key = configuredGeminiKey(); if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set');
const model = process.env.GEMINI_VISION_MODEL ?? 'gemini-2.0-flash'; const model = process.env.GEMINI_VISION_MODEL ?? 'gemini-2.0-flash';
const res = await doFetch( const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent( `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
model, model,
)}:generateContent?key=${encodeURIComponent(key.value)}`, )}:generateContent?key=${encodeURIComponent(process.env.GEMINI_API_KEY)}`,
{ {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -237,63 +224,19 @@ async function checkGeminiVision() {
return model; return model;
} }
async function checkGeminiVoiceModel() { async function checkGeminiLiveListed() {
const key = configuredGeminiKey(); if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set');
const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-tts-preview'; const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-live-2.5-flash';
const res = await doFetch( const res = await doFetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key.value)}`, `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(
process.env.GEMINI_API_KEY,
)}`,
); );
if (!res.ok) throw new Error(await responseError('Gemini model list', res)); if (!res.ok) throw new Error(await responseError('Gemini model list', res));
const body = await res.json(); const body = await res.json();
const names = (body.models ?? []).map((m) => m.name?.replace(/^models\//, '')); const names = (body.models ?? []).map((m) => m.name?.replace(/^models\//, ''));
if (!names.includes(model)) throw new Error(`${model} not present in Gemini model list`); if (!names.includes(model)) throw new Error(`${model} not present in Gemini model list`);
if (!model.includes('tts')) return model; 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() { async function checkVoyage() {
@@ -319,16 +262,6 @@ await check('workspace', checkWorkspace);
for (const name of requiredEnv) { for (const name of requiredEnv) {
add(`env:${name}`, isSet(name) ? 'ok' : 'fail', isSet(name) ? 'set' : 'missing'); 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) { for (const name of optionalEnv) {
add(`env:${name}`, isSet(name) ? 'ok' : 'warn', isSet(name) ? 'set' : 'optional'); add(`env:${name}`, isSet(name) ? 'ok' : 'warn', isSet(name) ? 'set' : 'optional');
} }
@@ -348,8 +281,7 @@ await check('livekit room service', checkLiveKitApi);
await check('mongo ping', checkMongo); await check('mongo ping', checkMongo);
await check('github repo access', checkGitHub); await check('github repo access', checkGitHub);
await check('gemini vision model', checkGeminiVision); await check('gemini vision model', checkGeminiVision);
await check('gemini voice model', checkGeminiVoiceModel); await check('gemini live model listed', checkGeminiLiveListed);
await check('gemini embeddings', checkGeminiEmbeddings);
if (isSet('VOYAGE_API_KEY')) { if (isSet('VOYAGE_API_KEY')) {
await check('voyage embeddings', checkVoyage); await check('voyage embeddings', checkVoyage);
-111
View File
@@ -1,111 +0,0 @@
#!/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
@@ -1,252 +0,0 @@
#!/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
@@ -1,86 +0,0 @@
#!/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,21 +155,8 @@ async function verifyMemoryRecall() {
detectedAt: new Date().toISOString(), detectedAt: new Date().toISOString(),
}; };
await recordCollision(seed); 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` }); const recalled = await recallSimilar({ ...seed, id: `${seed.id}_query` });
if (!recalled) fail('memory recall did not find seeded collision'); 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() { async function verifyGraph() {
+7 -52
View File
@@ -109,20 +109,6 @@ 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; let preview = null;
if (shouldStartPreview) { if (shouldStartPreview) {
preview = spawn( preview = spawn(
@@ -138,27 +124,6 @@ if (shouldStartPreview) {
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); 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 consoleErrors = [];
const pageErrors = []; const pageErrors = [];
@@ -191,17 +156,11 @@ try {
await page.getByRole('button', { name: 'Team memory' }).click(); await page.getByRole('button', { name: 'Team memory' }).click();
await page.getByText('Workflow metrics').waitFor({ timeout: 15_000 }); await page.getByText('Workflow metrics').waitFor({ timeout: 15_000 });
await page.getByText('Learning edges').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.getByRole('button', { name: /Pods/i }).click();
await page.getByPlaceholder('Your name').first().waitFor({ timeout: 15_000 });
const frontendPodCard = page await page.getByPlaceholder('Your name').first().fill(verifyMember);
.getByText('Frontend Pod', { exact: true }) await page.getByRole('button', { name: 'Add and join' }).first().click();
.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 }); await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
const joinedText = await page.locator('body').innerText(); const joinedText = await page.locator('body').innerText();
@@ -214,15 +173,12 @@ try {
throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`); 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'); const publisher = await connectPublisher('frontend-pod');
try { try {
await waitForInterventionCard(page, publisher, 'frontend-pod'); await publishIntervention(publisher, 'frontend-pod');
await page
.getByText('Verification collision: two engineers are editing frontend/src/App.tsx.')
.waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'Dismiss' }).click(); await page.getByRole('button', { name: 'Dismiss' }).click();
await page.getByText('No collision detected').waitFor({ timeout: 15_000 }); await page.getByText('No collision detected').waitFor({ timeout: 15_000 });
} finally { } finally {
@@ -243,7 +199,6 @@ try {
bodyLength: bodyText.length, bodyLength: bodyText.length,
graph: true, graph: true,
joined: true, joined: true,
screenShare: true,
intervention: true, intervention: true,
member: verifyMember, member: verifyMember,
}, },
-1
View File
@@ -9,7 +9,6 @@ export type {
SuggestedActionKind, SuggestedActionKind,
} from './intervention.js'; } from './intervention.js';
export * from './messages.js'; export * from './messages.js';
export type { HermesMessage } from './messages.js';
export type { export type {
PodGraph, PodGraph,
PodGraphNode, PodGraphNode,
-12
View File
@@ -7,22 +7,10 @@ export const DATA_TOPIC = 'podman.intervention' as const;
/** Wire messages exchanged between the PodMan agent and engineer PWAs. */ /** Wire messages exchanged between the PodMan agent and engineer PWAs. */
export type DataMessage = export type DataMessage =
| { type: 'COLLISION'; collision: Collision; intervention: Intervention } | { type: 'COLLISION'; collision: Collision; intervention: Intervention }
| { type: 'HERMES_MESSAGE'; message: HermesMessage }
| { type: 'VOICE_CUE'; text: string } | { type: 'VOICE_CUE'; text: string }
| { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string } | { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string }
| { type: 'GIT_REPORT'; report: LocalGitReport }; | { 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. */ /** Outcome of an intervention — the supervision signal for policy learning. */
export interface InterventionOutcome { export interface InterventionOutcome {
interventionId: string; interventionId: string;