Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d4029a046 | |||
| 9dd8f3ce60 | |||
| 1ea3e3c9e6 | |||
| 5a96e2b1ae |
+1
-19
@@ -7,11 +7,8 @@ LIVEKIT_API_SECRET=
|
||||
|
||||
# --- Gemini (vision + event detection + voice) ---
|
||||
GEMINI_API_KEY=
|
||||
# GOOGLE_API_KEY= also works as a local alias, but GEMINI_API_KEY is the
|
||||
# canonical deployment secret name used by DigitalOcean and docs.
|
||||
GEMINI_VISION_MODEL=gemini-2.0-flash
|
||||
GEMINI_LIVE_MODEL=gemini-3.1-flash-tts-preview
|
||||
GEMINI_EMBEDDING_MODEL=gemini-embedding-001
|
||||
GEMINI_LIVE_MODEL=gemini-live-2.5-flash
|
||||
|
||||
# --- GitHub (repo state + sync PR artifacts) ---
|
||||
GITHUB_TOKEN=
|
||||
@@ -36,18 +33,3 @@ VITE_BACKEND_URL=http://localhost:8787
|
||||
# --- Deployment verification ---
|
||||
# Optional override when the deployed SPA and API use different origins.
|
||||
FRONTEND_URL=http://localhost:4173
|
||||
|
||||
# --- Hermes operations watchdog ---
|
||||
PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
|
||||
PODMAN_PUBLIC_API_URL=https://165-22-129-249.sslip.io/api/pods
|
||||
PODMAN_PUBLIC_HEALTH_URL=https://165-22-129-249.sslip.io/health
|
||||
PODMAN_HERMES_REMEDIATE=1
|
||||
PODMAN_HERMES_STRICT=0
|
||||
PODMAN_HERMES_STATE_DIR=/var/log/podman
|
||||
# Optional Discord/Slack/generic webhook for failed watchdog runs.
|
||||
PODMAN_ALERT_WEBHOOK_URL=
|
||||
|
||||
# --- Hermes git-sync deploy loop ---
|
||||
PODMAN_DEPLOY_REMOTE=origin
|
||||
PODMAN_DEPLOY_BRANCH=main
|
||||
PODMAN_DEPLOY_RESTART_SERVICES=podman-platform-api.service,podman-platform-agent.service,caddy.service
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { getGitStates } from '../memory/db.js';
|
||||
import { recallSimilar } from '../memory/vectors.js';
|
||||
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
||||
import { speak } from '../voice/live.js';
|
||||
import { publishHermesMessage } from '../action/hermes.js';
|
||||
|
||||
export class PodMan {
|
||||
private contexts = new Map<string, EngineerContext>();
|
||||
@@ -55,7 +54,7 @@ export class PodMan {
|
||||
}
|
||||
|
||||
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 (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
|
||||
|
||||
@@ -65,11 +64,7 @@ export class PodMan {
|
||||
const message =
|
||||
`${names} are both editing ${collision.file}` +
|
||||
(collision.githubState?.unpushed ? ' and one has unpushed changes.' : '.') +
|
||||
(prior?.priorOutcome?.accepted
|
||||
? ` I've seen this conflict pattern before; last time the team accepted the ${prior.priorIntervention?.suggestedAction.kind.replaceAll('_', ' ') ?? 'suggested'} action.`
|
||||
: prior
|
||||
? ` I've seen this conflict pattern before.`
|
||||
: '');
|
||||
(prior ? ` I've seen this conflict pattern before.` : '');
|
||||
|
||||
const intervention: Intervention = {
|
||||
id: `int_${Date.now()}`,
|
||||
@@ -77,14 +72,7 @@ export class PodMan {
|
||||
podId: this.podId,
|
||||
kind: 'card',
|
||||
message,
|
||||
suggestedAction: {
|
||||
kind: action,
|
||||
params: {
|
||||
file: collision.file,
|
||||
summary: message,
|
||||
engineers: collision.engineers,
|
||||
},
|
||||
},
|
||||
suggestedAction: { kind: action },
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -95,7 +83,6 @@ export class PodMan {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
await publishHermesMessage(this.room, collision, intervention);
|
||||
if (collision.severity === 'critical') await speak(this.room, message);
|
||||
await speak(this.room, message); // gemini-3.1-flash-live voice into the room
|
||||
}
|
||||
}
|
||||
|
||||
+2
-11
@@ -5,13 +5,6 @@ function req(name: string): string {
|
||||
if (!v) throw new Error(`Missing required env var: ${name}`);
|
||||
return v;
|
||||
}
|
||||
function reqAny(primary: string, aliases: string[] = []): string {
|
||||
for (const name of [primary, ...aliases]) {
|
||||
const v = process.env[name];
|
||||
if (v) return v;
|
||||
}
|
||||
throw new Error(`Missing required env var: ${primary}`);
|
||||
}
|
||||
function opt(name: string, fallback = ''): string {
|
||||
return process.env[name] ?? fallback;
|
||||
}
|
||||
@@ -22,10 +15,9 @@ export const env = {
|
||||
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
|
||||
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
||||
// 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_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-3.1-flash-tts-preview'),
|
||||
GEMINI_EMBEDDING_MODEL: opt('GEMINI_EMBEDDING_MODEL', 'gemini-embedding-001'),
|
||||
GEMINI_LIVE_MODEL: opt('GEMINI_LIVE_MODEL', 'gemini-live-2.5-flash'),
|
||||
// GitHub
|
||||
GITHUB_TOKEN: req('GITHUB_TOKEN'),
|
||||
GITHUB_REPO: req('GITHUB_REPO'), // owner/name
|
||||
@@ -35,7 +27,6 @@ export const env = {
|
||||
VOYAGE_EMBEDDING_MODEL: opt('VOYAGE_EMBEDDING_MODEL', 'voyage-4-lite'),
|
||||
// Server
|
||||
PORT: Number(opt('PORT', '8787')),
|
||||
NUDGE_COOLDOWN_MS: Number(opt('NUDGE_COOLDOWN_MS', '180000')),
|
||||
} as const;
|
||||
|
||||
export function repoParts(): { owner: string; repo: string } {
|
||||
|
||||
@@ -39,45 +39,13 @@ export async function createSyncPr(input: { headBranch: string; file: string; su
|
||||
ref: `refs/heads/${branch}`,
|
||||
sha: mainRef.object.sha,
|
||||
});
|
||||
|
||||
const artifactPath = `podman-sync-artifacts/${branch}.md`;
|
||||
const body = [
|
||||
'# PodMan Sync Artifact',
|
||||
'',
|
||||
`- File: \`${input.file || 'unknown'}\``,
|
||||
`- Source branch hint: \`${input.headBranch || 'not provided'}\``,
|
||||
`- Created: ${new Date().toISOString()}`,
|
||||
'',
|
||||
'## Coordination Summary',
|
||||
'',
|
||||
input.summary || 'PodMan detected a coordination risk before the relevant work was pushed.',
|
||||
'',
|
||||
'## Suggested Next Step',
|
||||
'',
|
||||
'Coordinate ownership before pushing or merging overlapping local work.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await gh.rest.repos.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
path: artifactPath,
|
||||
message: `PodMan sync artifact for ${input.file || 'active work'}`,
|
||||
content: Buffer.from(body).toString('base64'),
|
||||
});
|
||||
|
||||
const { data: pr } = await gh.rest.pulls.create({
|
||||
owner,
|
||||
repo,
|
||||
title: `PodMan: sync ${input.file} before collision`,
|
||||
head: branch,
|
||||
base: 'main',
|
||||
body: [
|
||||
input.summary,
|
||||
'',
|
||||
`PodMan created a visible sync artifact at \`${artifactPath}\` so the team can coordinate before pushing overlapping work.`,
|
||||
].join('\n'),
|
||||
body: input.summary,
|
||||
});
|
||||
return pr;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@ export async function initMemory(): Promise<void> {
|
||||
'collisions.memorySignature',
|
||||
() => c.collisions.createIndex({ podId: 1, memorySignature: 1 }),
|
||||
],
|
||||
['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })],
|
||||
['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })],
|
||||
['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })],
|
||||
];
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
import type { Collision, SuggestedActionKind } from '@podman/shared';
|
||||
import type { RecalledCollision } from './vectors.js';
|
||||
|
||||
const lastNudgeByPod = new Map<string, number>();
|
||||
|
||||
function cooldownMs(): number {
|
||||
return Number(process.env.NUDGE_COOLDOWN_MS ?? '180000');
|
||||
/**
|
||||
* Policy gate: decides whether PodMan should intervene.
|
||||
* Stub: always intervene on warn/critical.
|
||||
*/
|
||||
export function shouldIntervene(collision: Collision, _prior: unknown): boolean {
|
||||
return collision.severity !== 'info';
|
||||
}
|
||||
|
||||
/** Policy gate: combines severity, exact recall outcomes, and per-pod cooldown. */
|
||||
export function shouldIntervene(collision: Collision, prior: RecalledCollision | null): boolean {
|
||||
if (collision.severity === 'info') return false;
|
||||
|
||||
const priorOutcome = prior?.priorOutcome;
|
||||
if (priorOutcome && !priorOutcome.accepted && !priorOutcome.wasRealCollision) return false;
|
||||
|
||||
const cooldown = cooldownMs();
|
||||
const last = lastNudgeByPod.get(collision.podId) ?? 0;
|
||||
if (cooldown > 0 && Date.now() - last < cooldown && collision.severity !== 'critical') {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastNudgeByPod.set(collision.podId, Date.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Preferred action selection based on collision severity and prior accepted actions. */
|
||||
export function preferredAction(
|
||||
collision: Collision,
|
||||
prior: RecalledCollision | null,
|
||||
): SuggestedActionKind {
|
||||
const acceptedKind = prior?.priorOutcome?.accepted
|
||||
? prior.priorIntervention?.suggestedAction.kind
|
||||
: undefined;
|
||||
if (acceptedKind && acceptedKind !== 'none') return acceptedKind;
|
||||
/**
|
||||
* Preferred action selection based on collision + prior history.
|
||||
* Stub: open sync PR for critical, ping teammate otherwise.
|
||||
*/
|
||||
export function preferredAction(collision: Collision, _prior: unknown): SuggestedActionKind {
|
||||
return collision.severity === 'critical' ? 'open_sync_pr' : 'ping_teammate';
|
||||
}
|
||||
|
||||
@@ -34,14 +34,7 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
|
||||
}
|
||||
|
||||
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||
await persist('outcome', async () => {
|
||||
const c = await collections();
|
||||
await c.outcomes.insertOne({ ...outcome });
|
||||
await c.interventions.updateOne(
|
||||
{ id: outcome.interventionId },
|
||||
{ $set: { status: outcome.accepted ? 'accepted' : 'dismissed' } },
|
||||
);
|
||||
});
|
||||
await persist('outcome', async () => (await collections()).outcomes.insertOne({ ...outcome }));
|
||||
}
|
||||
|
||||
/** Document counts per collection — used by the /api/memory/stats endpoint. */
|
||||
|
||||
+17
-149
@@ -1,4 +1,4 @@
|
||||
import type { Collision, Intervention, InterventionOutcome } from '@podman/shared';
|
||||
import type { Collision } from '@podman/shared';
|
||||
import { env } from '../env.js';
|
||||
import { getDb } from './db.js';
|
||||
|
||||
@@ -6,35 +6,18 @@ type StoredCollision = Collision & {
|
||||
memorySignature?: string;
|
||||
memoryText?: string;
|
||||
embedding?: number[];
|
||||
embeddingProvider?: string;
|
||||
};
|
||||
|
||||
export type RecalledCollision = Collision & {
|
||||
priorIntervention?: Intervention;
|
||||
priorOutcome?: InterventionOutcome;
|
||||
};
|
||||
|
||||
interface VoyageEmbeddingResponse {
|
||||
data?: Array<{ embedding?: number[] }>;
|
||||
}
|
||||
|
||||
interface GeminiEmbeddingResponse {
|
||||
embedding?: { values?: number[] };
|
||||
}
|
||||
|
||||
function normalize(value: string | undefined): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function signature(collision: Collision): string {
|
||||
return [
|
||||
normalize(collision.file),
|
||||
normalize(collision.symbol),
|
||||
[...collision.engineers].sort().map(normalize).join('+'),
|
||||
'collision',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('#');
|
||||
return [normalize(collision.file), normalize(collision.symbol)].filter(Boolean).join('#');
|
||||
}
|
||||
|
||||
function memoryText(collision: Collision): string {
|
||||
@@ -49,30 +32,7 @@ function memoryText(collision: Collision): string {
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function cosine(a: number[], b: number[]): number {
|
||||
const n = Math.min(a.length, b.length);
|
||||
let dot = 0;
|
||||
let aNorm = 0;
|
||||
let bNorm = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const av = a[i] ?? 0;
|
||||
const bv = b[i] ?? 0;
|
||||
dot += av * bv;
|
||||
aNorm += av * av;
|
||||
bNorm += bv * bv;
|
||||
}
|
||||
if (!aNorm || !bNorm) return -1;
|
||||
return dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm));
|
||||
}
|
||||
|
||||
async function embed(text: string, inputType: 'document' | 'query'): Promise<number[] | null> {
|
||||
return (await embedWithVoyage(text, inputType)) ?? embedWithGemini(text, inputType);
|
||||
}
|
||||
|
||||
async function embedWithVoyage(
|
||||
text: string,
|
||||
inputType: 'document' | 'query',
|
||||
): Promise<number[] | null> {
|
||||
if (!env.VOYAGE_API_KEY) return null;
|
||||
try {
|
||||
const res = await fetch('https://api.voyageai.com/v1/embeddings', {
|
||||
@@ -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> {
|
||||
const text = memoryText(collision);
|
||||
const embedding = await embed(text, 'document');
|
||||
@@ -138,45 +66,16 @@ export async function enrichCollisionMemory(collision: Collision): Promise<Store
|
||||
...collision,
|
||||
memorySignature: signature(collision),
|
||||
memoryText: text,
|
||||
...(embedding
|
||||
? { embedding, embeddingProvider: env.VOYAGE_API_KEY ? 'voyage' : 'gemini' }
|
||||
: {}),
|
||||
...(embedding ? { embedding } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function attachOutcome(match: StoredCollision): Promise<RecalledCollision> {
|
||||
const db = await getDb();
|
||||
const intervention = await db
|
||||
.collection<Intervention>('interventions')
|
||||
.findOne({ collisionId: match.id }, { sort: { createdAt: -1 }, projection: { _id: 0 } });
|
||||
const outcome = intervention
|
||||
? await db
|
||||
.collection<InterventionOutcome>('outcomes')
|
||||
.findOne(
|
||||
{ interventionId: intervention.id },
|
||||
{ sort: { recordedAt: -1 }, projection: { _id: 0 } },
|
||||
)
|
||||
: null;
|
||||
const {
|
||||
memorySignature: _memorySignature,
|
||||
memoryText: _memoryText,
|
||||
embedding: _embedding,
|
||||
embeddingProvider: _embeddingProvider,
|
||||
...collision
|
||||
} = match;
|
||||
return {
|
||||
...collision,
|
||||
...(intervention ? { priorIntervention: intervention } : {}),
|
||||
...(outcome ? { priorOutcome: outcome } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function recallByVector(collision: Collision): Promise<RecalledCollision | null> {
|
||||
async function recallByVector(collision: Collision): Promise<Collision | null> {
|
||||
const queryVector = await embed(memoryText(collision), 'query');
|
||||
if (!queryVector) return null;
|
||||
|
||||
const db = await getDb();
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [match] = await db
|
||||
.collection<StoredCollision>('collisions')
|
||||
.aggregate<StoredCollision>([
|
||||
@@ -194,62 +93,31 @@ async function recallByVector(collision: Collision): Promise<RecalledCollision |
|
||||
{ $project: { _id: 0, embedding: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
return match ? attachOutcome(match) : null;
|
||||
return match ?? null;
|
||||
} 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 sig = signature(collision);
|
||||
const matches = await db
|
||||
.collection<StoredCollision>('collisions')
|
||||
.find(
|
||||
const match = await db.collection<StoredCollision>('collisions').findOne(
|
||||
{
|
||||
podId: collision.podId,
|
||||
id: { $ne: collision.id },
|
||||
$or: [{ memorySignature: sig }, { file: collision.file }],
|
||||
},
|
||||
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 }, limit: 10 },
|
||||
)
|
||||
.toArray();
|
||||
|
||||
let fallback: RecalledCollision | null = null;
|
||||
for (const match of matches) {
|
||||
const recalled = await attachOutcome(match);
|
||||
if (!fallback) fallback = recalled;
|
||||
if (recalled.priorOutcome?.accepted && recalled.priorOutcome.wasRealCollision) {
|
||||
return recalled;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
{ sort: { detectedAt: -1 }, projection: { _id: 0, embedding: 0 } },
|
||||
);
|
||||
return match ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recall prior collision patterns. Atlas Vector Search is preferred when
|
||||
* available; standalone MongoDB falls back to app-side cosine search over
|
||||
* stored embeddings, then exact signature/file matching.
|
||||
* Recall prior collision patterns. Exact Mongo recall is always available;
|
||||
* Voyage + Atlas Vector Search is used first when configured.
|
||||
*/
|
||||
export async function recallSimilar(collision: Collision): Promise<RecalledCollision | null> {
|
||||
export async function recallSimilar(collision: Collision): Promise<Collision | null> {
|
||||
return (await recallByVector(collision)) ?? recallBySignature(collision);
|
||||
}
|
||||
|
||||
+19
-57
@@ -1,4 +1,3 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import {
|
||||
AudioFrame,
|
||||
AudioSource,
|
||||
@@ -13,7 +12,6 @@ import { env } from '../env.js';
|
||||
|
||||
const SAMPLE_RATE = 24_000;
|
||||
const CHANNELS = 1;
|
||||
const FRAME_SAMPLES = SAMPLE_RATE / 10;
|
||||
const encoder = new TextEncoder();
|
||||
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
|
||||
|
||||
@@ -46,53 +44,37 @@ function audioFrames(message: LiveServerMessage): AudioFrame[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function framesFromPcmBase64(data: string, mimeType?: string): AudioFrame[] {
|
||||
const frame = audioFrameFromBase64(data, mimeType);
|
||||
if (!frame) return [];
|
||||
/**
|
||||
* 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 samples = frame.data;
|
||||
const frames: AudioFrame[] = [];
|
||||
for (let offset = 0; offset < samples.length; offset += FRAME_SAMPLES) {
|
||||
const chunk = samples.subarray(offset, Math.min(offset + FRAME_SAMPLES, samples.length));
|
||||
frames.push(new AudioFrame(chunk, SAMPLE_RATE, CHANNELS, chunk.length / CHANNELS));
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
const source = new AudioSource(SAMPLE_RATE, CHANNELS);
|
||||
const track = LocalAudioTrack.createAudioTrack('podman-hermes-voice', source);
|
||||
const options = new TrackPublishOptions();
|
||||
options.source = TrackSource.SOURCE_MICROPHONE;
|
||||
|
||||
async function generateTtsFrames(message: string): Promise<AudioFrame[]> {
|
||||
const res = await ai.models.generateContent({
|
||||
model: env.GEMINI_LIVE_MODEL,
|
||||
contents: [{ parts: [{ text: message }] }],
|
||||
config: {
|
||||
responseModalities: [Modality.AUDIO],
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
|
||||
},
|
||||
});
|
||||
const parts = res.candidates?.[0]?.content?.parts ?? [];
|
||||
return parts.flatMap((part) =>
|
||||
framesFromPcmBase64(part.inlineData?.data ?? '', part.inlineData?.mimeType),
|
||||
);
|
||||
}
|
||||
|
||||
async function speakWithTts(source: AudioSource, message: string): Promise<void> {
|
||||
for (const frame of await generateTtsFrames(message)) {
|
||||
await source.captureFrame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
async function speakWithLive(source: AudioSource, message: string): Promise<void> {
|
||||
try {
|
||||
const publication = await room.localParticipant.publishTrack(track, options);
|
||||
let done: () => void = () => {};
|
||||
const donePromise = new Promise<void>((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,
|
||||
config: { responseModalities: [Modality.AUDIO] },
|
||||
callbacks: {
|
||||
onmessage: (event) => {
|
||||
void (async () => {
|
||||
for (const frame of audioFrames(event)) await source.captureFrame(frame);
|
||||
if (event.serverContent?.turnComplete || event.serverContent?.generationComplete) done();
|
||||
if (event.serverContent?.turnComplete || event.serverContent?.generationComplete)
|
||||
done();
|
||||
})();
|
||||
},
|
||||
onerror: (event) => {
|
||||
@@ -110,26 +92,6 @@ async function speakWithLive(source: AudioSource, message: string): Promise<void
|
||||
|
||||
await Promise.race([donePromise, new Promise((resolve) => setTimeout(resolve, 15_000))]);
|
||||
session.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak a message into the LiveKit room using Gemini Live audio. A data-channel
|
||||
* VOICE_CUE is sent first so clients still get the cue if audio generation or
|
||||
* publishing fails.
|
||||
*/
|
||||
export async function speak(room: Room, message: string): Promise<void> {
|
||||
await publishVoiceCue(room, message);
|
||||
if (!room.localParticipant) return;
|
||||
|
||||
const source = new AudioSource(SAMPLE_RATE, CHANNELS);
|
||||
const track = LocalAudioTrack.createAudioTrack('podman-hermes-voice', source);
|
||||
const options = new TrackPublishOptions();
|
||||
options.source = TrackSource.SOURCE_MICROPHONE;
|
||||
|
||||
try {
|
||||
const publication = await room.localParticipant.publishTrack(track, options);
|
||||
if (env.GEMINI_LIVE_MODEL.includes('tts')) await speakWithTts(source, message);
|
||||
else await speakWithLive(source, message);
|
||||
if (publication.sid) await room.localParticipant.unpublishTrack(publication.sid, true);
|
||||
await source.close();
|
||||
} catch (err) {
|
||||
|
||||
+25
-31
@@ -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
|
||||
demo.
|
||||
|
||||
### Partial / completed since the original audit
|
||||
### Partial / stubbed
|
||||
|
||||
- `backend/src/voice/live.ts` now publishes a `VOICE_CUE` fallback and attempts
|
||||
Gemini audio publication into LiveKit. The agent only calls it for critical
|
||||
interventions so voice remains an urgent escalation path.
|
||||
- Hermes now has a data-channel teammate message path via `HERMES_MESSAGE` on
|
||||
the existing `podman.intervention` topic. This is the MVP notification bridge,
|
||||
not a Slack/Discord integration.
|
||||
- `backend/src/memory/vectors.ts` implements exact-signature recall first and
|
||||
can use Voyage/Gemini embeddings with Atlas Vector Search when configured.
|
||||
- Exact-signature recall now attaches prior interventions/outcomes and prefers
|
||||
accepted real collisions, giving the learning beat deterministic MongoDB
|
||||
proof before vector search.
|
||||
- `backend/src/memory/policy.ts` now uses severity, per-pod cooldown, and prior
|
||||
outcome history. It is still a simple policy, not a trained threshold model.
|
||||
- `POST /api/sync-pr` now creates a visible Markdown sync artifact commit before
|
||||
opening the PR.
|
||||
- Frontend `PodView` renders intervention cards, Hermes messages, voice cues,
|
||||
and the accepted sync PR artifact link.
|
||||
- `backend/src/voice/live.ts` logs only; it does not publish real voice/audio
|
||||
into LiveKit yet.
|
||||
- Hermes is a product/action/messaging layer in the plan, but the current repo
|
||||
does not yet implement a complete Hermes notification bridge.
|
||||
- `backend/src/memory/vectors.ts` is not a real Voyage/Atlas Vector Search
|
||||
implementation yet.
|
||||
- Exact-signature recall is the required MVP fallback before vectors.
|
||||
- `backend/src/memory/policy.ts` is a simple gate; it does not learn thresholds
|
||||
from outcomes yet.
|
||||
- `POST /api/sync-pr` creates a PR artifact path but does not yet build a
|
||||
meaningful sync diff.
|
||||
- Frontend `PodView` has only a placeholder intervention area unless/until live
|
||||
intervention rendering is wired.
|
||||
- Browser screen publishing exists, but the active join path must be proven to
|
||||
tag tracks as screen share so the backend agent can filter them correctly. The
|
||||
`origin/main` screen-share button appears to address this; local code remains
|
||||
behind until that commit is merged.
|
||||
- `GIT_REPORT` exists in shared types and agent handling. `scripts/podman-agent.mjs`
|
||||
is the finished per-laptop git sidecar — polls every 15 s, upserts git fields
|
||||
to `engineer_states` collection. The backend agent now fuses those Mongo
|
||||
git-state fields into live contexts before collision detection; direct
|
||||
LiveKit `GIT_REPORT` publication from the sidecar remains optional.
|
||||
to `engineer_states` collection. Not yet wired to publish a `GIT_REPORT` data
|
||||
channel message into the LiveKit room (agent fusion step still needed).
|
||||
- Background research recommendations are a product requirement and demo goal,
|
||||
not an implemented research agent yet.
|
||||
- Deployment reliability is partial; API health is reachable, but API/static
|
||||
site/worker together must still be reverified before demo.
|
||||
- Env docs now align on `gemini-3.5-flash` for vision and
|
||||
`gemini-3.1-flash-tts-preview` for voice. The backend still preserves a Gemini
|
||||
Live path for future available Live models.
|
||||
- Env docs are inconsistent: backend defaults are `gemini-3.5-flash` and
|
||||
`gemini-3.1-flash-live-preview`, while `.env.example` still lists older
|
||||
Gemini model names.
|
||||
|
||||
### Not yet proven
|
||||
|
||||
@@ -672,16 +667,15 @@ Before saying PodMan is demo-ready:
|
||||
- [ ] Backend agent subscribes to the screen-share track.
|
||||
- [ ] Agent logs at least one parsed Gemini context from a real IDE screen.
|
||||
- [x] Local git report supplies dirty/unpushed truth on a schedule (`scripts/podman-agent.mjs` — 15 s poll → MongoDB `engineer_states`). Agent fusion still needed.
|
||||
- [x] Frontend renders a real intervention card.
|
||||
- [x] Hermes notification path works for teammate messages over the LiveKit data
|
||||
channel.
|
||||
- [ ] Frontend renders a real intervention card.
|
||||
- [ ] Hermes notification path works for teammate messages.
|
||||
- [ ] Voice is heard only for urgent escalation or a fallback is declared.
|
||||
- [x] Outcome ACK writes to MongoDB and updates intervention status.
|
||||
- [x] `/api/memory/stats` shows counts increasing.
|
||||
- [x] Second similar situation uses prior exact memory in the message.
|
||||
- [ ] Outcome ACK writes to MongoDB.
|
||||
- [ ] `/api/memory/stats` shows counts increasing.
|
||||
- [ ] Second similar situation uses prior memory in the message.
|
||||
- [ ] Research recommendation card is evidence-backed, or fallback collision demo
|
||||
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.
|
||||
- [ ] Backup recording is ready on a separate device.
|
||||
|
||||
|
||||
+2
-39
@@ -69,7 +69,7 @@ LIVEKIT_API_SECRET=...
|
||||
|
||||
GEMINI_API_KEY=...
|
||||
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_REPO=karti-ai/podman
|
||||
@@ -170,51 +170,14 @@ The droplet production fallback uses systemd units from `infra/systemd/`:
|
||||
```bash
|
||||
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-hermes-*.service infra/systemd/podman-hermes-*.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent
|
||||
```
|
||||
|
||||
Expected runtime proof:
|
||||
|
||||
```bash
|
||||
systemctl is-active podman-platform-api podman-platform-agent
|
||||
systemctl is-active podman-hermes-watchdog.timer
|
||||
systemctl is-active podman-hermes-sync-deploy.timer
|
||||
curl http://127.0.0.1:8787/health
|
||||
journalctl -u podman-platform-agent -n 20 --no-pager
|
||||
journalctl -u podman-hermes-watchdog -n 40 --no-pager
|
||||
```
|
||||
|
||||
## Hermes-managed operations layer
|
||||
|
||||
The app processes are still supervised by systemd, but Hermes now owns the
|
||||
operations loop around them:
|
||||
|
||||
- `pnpm hermes:watchdog` checks systemd services, public routes, `/health`,
|
||||
`/api/pods`, and `pnpm deploy:doctor`.
|
||||
- `podman-hermes-watchdog.timer` runs that watchdog every five minutes.
|
||||
- `podman-hermes-sync-deploy.timer` polls `origin/main` every two minutes. If
|
||||
the tree is clean and the remote moved, it fast-forwards, installs, builds,
|
||||
publishes `frontend/dist` to `/var/www/podman`, restarts the API/agent/Caddy,
|
||||
and runs the strict watchdog.
|
||||
- Failed URL checks trigger restarts of the PodMan API, PodMan agent, and Caddy.
|
||||
- Failed service checks restart only the unhealthy service.
|
||||
- Caddy is validated and reloaded after public route failures.
|
||||
- Reports are written to `/var/log/podman/hermes-watchdog-latest.json`.
|
||||
- Set `PODMAN_ALERT_WEBHOOK_URL` to send failed reports to Discord, Slack, or a
|
||||
generic webhook receiver.
|
||||
- `pnpm hermes:install` installs the timer units and a local pre-push hook that
|
||||
gates major pushes with typecheck, lint, and a non-remediating watchdog check.
|
||||
|
||||
The strict gate for production readiness is:
|
||||
|
||||
```bash
|
||||
pnpm hermes:watchdog:strict
|
||||
```
|
||||
|
||||
Manual deploy-sync run:
|
||||
|
||||
```bash
|
||||
pnpm hermes:sync-deploy
|
||||
```
|
||||
|
||||
+3
-3
@@ -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:**
|
||||
|
||||
|
||||
+1
-11
@@ -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/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
|
||||
- `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
|
||||
|
||||
1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path).
|
||||
2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock.
|
||||
3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it.
|
||||
|
||||
## Component convention
|
||||
|
||||
UI is built from the shared **shadcn / ruixen** registry — add primitives with
|
||||
`npx shadcn@latest add "https://ruixen.com/r/[component]"` and compose from
|
||||
`@/components/ui/*` (`Button`, `Badge`, `Card`, …) using the design tokens
|
||||
(`var(--card)` / `--foreground` / `--border` / …). Only the SVG node-link **canvas**
|
||||
in `GraphView.tsx` is bespoke (3 SVG-only CSS rules); the chrome (header, toggles,
|
||||
metric cards, detail panel, legend) is composed from the primitives + the app's
|
||||
Tailwind utility patterns. No hand-rolled component stylesheets.
|
||||
|
||||
@@ -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
@@ -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`
|
||||
- Hermes generates Gemini TTS audio and publishes it as a LiveKit audio track.
|
||||
- The backend keeps a Gemini Live path for future model availability, but the verified deployment path uses TTS.
|
||||
- Model ID: `gemini-live-2.5-flash` — confirm exact ID from LiveKit Agents + Gemini docs at build time
|
||||
- LiveKit Agents has native Gemini Live integration — no manual audio encoding needed
|
||||
- Hermes passes text string → Agents handles streaming audio publication
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ PodMan is a real-time AI team coordination agent for software teams. Engineers j
|
||||
- **Vision:** `gemini-2.0-flash` — screen → `{ currentFile, inferredTask, terminalVisible, recentTerminalOutput, confidence }`
|
||||
- **Event detection:** `gemini-2.0-flash` — all engineer states → `{ event, involvedEngineers, file, reason }`
|
||||
- **Nudge generation:** `gemini-2.0-flash` — event → spoken message text
|
||||
- **Voice:** `gemini-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)
|
||||
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
|
||||
import { fetchPodGraph } from '../lib/graph.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
type Mode = 'risk' | 'learn' | 'all';
|
||||
|
||||
// Fixed, light-readable hues for the node/edge encoding (kept stable across
|
||||
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens).
|
||||
const BLUE = '#2563eb';
|
||||
const SLATE = '#475569';
|
||||
const SLATE_EDGE = '#94a3b8';
|
||||
const SLATE_FAINT = '#cbd5e1';
|
||||
const AMBER = '#d97706';
|
||||
const RED = '#dc2626';
|
||||
const VIOLET = '#7c3aed';
|
||||
|
||||
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||
engineer: BLUE,
|
||||
file: SLATE,
|
||||
feature: AMBER,
|
||||
collision: RED,
|
||||
intervention: VIOLET,
|
||||
engineer: '#3B5BFF',
|
||||
file: '#ECE7DA',
|
||||
feature: '#F6C445',
|
||||
collision: '#E2403A',
|
||||
intervention: '#8b6cff',
|
||||
};
|
||||
|
||||
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
|
||||
owns: { c: BLUE, w: 2.6 },
|
||||
editing: { c: SLATE_EDGE, w: 2 },
|
||||
touches: { c: SLATE_FAINT, w: 1.6 },
|
||||
collides: { c: RED, w: 3.2 },
|
||||
warns: { c: AMBER, w: 3.2 },
|
||||
learned_from: { c: VIOLET, w: 2.4, dash: true },
|
||||
owns: { c: '#3B5BFF', w: 2.6 },
|
||||
editing: { c: '#ECE7DA', w: 2 },
|
||||
touches: { c: '#5d5d66', w: 1.6 },
|
||||
collides: { c: '#E2403A', w: 3.2 },
|
||||
warns: { c: '#F6C445', w: 3.2 },
|
||||
learned_from: { c: '#8b6cff', w: 2.4, dash: true },
|
||||
};
|
||||
|
||||
function NodeShape({ node }: { node: PodGraphNode }) {
|
||||
@@ -38,7 +26,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
|
||||
const { x, y } = node;
|
||||
switch (node.kind) {
|
||||
case 'engineer':
|
||||
return <rect x={x - 15} y={y - 15} width={30} height={30} rx={4} fill={c} />;
|
||||
return <rect x={x - 15} y={y - 15} width={30} height={30} fill={c} />;
|
||||
case 'file':
|
||||
return (
|
||||
<rect
|
||||
@@ -46,7 +34,6 @@ function NodeShape({ node }: { node: PodGraphNode }) {
|
||||
y={y - 15}
|
||||
width={30}
|
||||
height={30}
|
||||
rx={4}
|
||||
fill="none"
|
||||
stroke={c}
|
||||
strokeWidth={2.6}
|
||||
@@ -94,19 +81,16 @@ function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Hig
|
||||
}
|
||||
|
||||
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||
{ label: 'engineer', swatch: { background: BLUE } },
|
||||
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
|
||||
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
|
||||
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
|
||||
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
|
||||
{ label: 'engineer', swatch: { background: '#3B5BFF' } },
|
||||
{ label: 'file', swatch: { border: '2px solid #ECE7DA' } },
|
||||
{ label: 'feature', swatch: { background: '#F6C445', borderRadius: '50%' } },
|
||||
{
|
||||
label: 'collision',
|
||||
swatch: { background: '#E2403A', clipPath: 'polygon(50% 0,100% 100%,0 100%)' },
|
||||
},
|
||||
{ label: 'intervention', swatch: { background: '#8b6cff', transform: 'rotate(45deg)' } },
|
||||
];
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'risk') return RED;
|
||||
if (status === 'learned') return VIOLET;
|
||||
return 'var(--foreground)';
|
||||
}
|
||||
|
||||
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
|
||||
const [graph, setGraph] = useState<PodGraph | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -144,70 +128,99 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
||||
setSelected(null);
|
||||
}
|
||||
|
||||
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="pm-graph">
|
||||
<style>{`
|
||||
.pm-graph{--bg:#0c0c0e;--panel:#141417;--line:#2a2a31;--paper:#ECE7DA;--mut:#8d897e;--red:#E2403A;--yel:#F6C445;--vio:#8b6cff;
|
||||
font-family:'Space Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--paper);border:1px solid var(--line);border-radius:14px;overflow:hidden}
|
||||
.pm-graph *{box-sizing:border-box}
|
||||
.pm-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:3px solid var(--paper)}
|
||||
.pm-ttl{font-weight:800;font-size:18px;letter-spacing:.14em;text-transform:uppercase;font-family:Archivo,'Space Grotesk',sans-serif}
|
||||
.pm-sub{font-size:10px;letter-spacing:.3em;color:var(--mut);text-transform:uppercase;margin-top:5px}
|
||||
.pm-x{background:transparent;border:1px solid var(--line);color:var(--paper);font-size:11px;letter-spacing:.1em;text-transform:uppercase;padding:7px 12px;border-radius:2px;cursor:pointer}
|
||||
.pm-x:hover{border-color:var(--paper)}
|
||||
.pm-bar{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
||||
.pm-btn{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--paper);background:transparent;border:1px solid var(--line);padding:7px 12px;cursor:pointer;border-radius:2px}
|
||||
.pm-btn:hover{border-color:var(--paper)}
|
||||
.pm-btn.on{background:var(--red);border-color:var(--red);color:#fff}
|
||||
.pm-grid{display:grid;grid-template-columns:180px 1fr 240px}
|
||||
.pm-col{padding:14px}
|
||||
.pm-railR{border-left:1px solid var(--line);background:#17171b}
|
||||
.pm-st{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin:2px 0 12px}
|
||||
.pm-kpi{border:1px solid var(--line);border-left:5px solid var(--vio);padding:10px 11px;margin-bottom:10px}
|
||||
.pm-num{font-weight:800;font-size:26px;line-height:.9;font-variant-numeric:tabular-nums;font-family:Archivo,sans-serif}
|
||||
.pm-klab{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut);margin-top:6px}
|
||||
.pm-kdet{font-size:10px;color:var(--mut);margin-top:5px;line-height:1.4}
|
||||
.pm-canvas{background:var(--panel);border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:472px}
|
||||
.pm-canvas svg{width:100%;height:auto;display:block}
|
||||
.pm-node{cursor:pointer}
|
||||
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
|
||||
.pm-dim{opacity:.18;transition:opacity .25s}
|
||||
.pm-lbl{font-weight:500;font-size:11px;letter-spacing:.06em;fill:var(--paper);text-transform:uppercase}
|
||||
.pm-dim{opacity:.12;transition:opacity .25s}
|
||||
.pm-dkind{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut)}
|
||||
.pm-dname{font-weight:800;font-size:20px;margin:5px 0 8px;font-family:Archivo,sans-serif}
|
||||
.pm-drow{display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--line);color:var(--mut)}
|
||||
.pm-drow b{color:var(--paper);font-weight:500}
|
||||
.pm-note{font-size:12px;color:var(--mut);line-height:1.5;margin-top:10px}
|
||||
.pm-legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 16px;border-top:1px solid var(--line);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut)}
|
||||
.pm-lg{display:flex;align-items:center;gap:6px}
|
||||
.pm-sw{width:13px;height:13px;display:inline-block}
|
||||
@media(max-width:760px){.pm-grid{grid-template-columns:1fr}.pm-railR{border-left:0;border-top:1px solid var(--line)}.pm-canvas{border:0;border-top:1px solid var(--line)}}
|
||||
`}</style>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
|
||||
<div className="flex items-center justify-between border-b px-5 py-4">
|
||||
<div className="pm-hd">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">Team memory</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
|
||||
<div className="pm-ttl">Team memory</div>
|
||||
<div className="pm-sub">What PodMan learned · {podId}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<button className="pm-x" onClick={onClose}>
|
||||
← Pods
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
|
||||
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
|
||||
<div className="pm-bar">
|
||||
<button
|
||||
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`}
|
||||
onClick={() => pick('risk')}
|
||||
>
|
||||
Risk path
|
||||
</Button>
|
||||
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
|
||||
</button>
|
||||
<button
|
||||
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`}
|
||||
onClick={() => pick('learn')}
|
||||
>
|
||||
Learning edges
|
||||
</Button>
|
||||
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
|
||||
</button>
|
||||
<button
|
||||
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
|
||||
onClick={() => pick('all')}
|
||||
>
|
||||
Whole graph
|
||||
</Button>
|
||||
</button>
|
||||
</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 && (
|
||||
<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 && (
|
||||
<>
|
||||
<div className="grid lg:grid-cols-[190px_1fr_250px]">
|
||||
<div className="space-y-3 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Workflow metrics
|
||||
</p>
|
||||
<div className="pm-grid">
|
||||
<div className="pm-col">
|
||||
<div className="pm-st">Workflow metrics</div>
|
||||
{graph.metrics.map((m) => (
|
||||
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
|
||||
<p className="text-2xl font-medium tabular-nums">{m.value}</p>
|
||||
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
|
||||
{m.label}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
|
||||
<div className="pm-kpi" key={m.label}>
|
||||
<div className="pm-num">{m.value}</div>
|
||||
<div className="pm-klab">{m.label}</div>
|
||||
<div className="pm-kdet">{m.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
|
||||
<svg
|
||||
viewBox="0 0 720 472"
|
||||
role="img"
|
||||
aria-label="PodMan team-memory graph"
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<div className="pm-canvas">
|
||||
<svg viewBox="0 0 720 472" role="img" aria-label="PodMan team-memory graph">
|
||||
{graph.edges.map((e) => {
|
||||
const a = nodeById.get(e.source);
|
||||
const b = nodeById.get(e.target);
|
||||
@@ -245,73 +258,71 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
||||
>
|
||||
<NodeShape node={n} />
|
||||
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
|
||||
{n.label}
|
||||
{n.label.toUpperCase()}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
|
||||
<div className="pm-col pm-railR">
|
||||
{sel ? (
|
||||
<>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{sel.kind}
|
||||
</p>
|
||||
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3>
|
||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||
<div className="pm-dkind">{sel.kind}</div>
|
||||
<div className="pm-dname">{sel.label}</div>
|
||||
<div className="pm-drow">
|
||||
<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}
|
||||
</Badge>
|
||||
</b>
|
||||
</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 className="font-medium text-foreground">{relCount}</span>
|
||||
<b>{relCount}</b>
|
||||
</div>
|
||||
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
|
||||
{sel.summary}
|
||||
</p>
|
||||
<div className="pm-note">{sel.summary}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Continual learning
|
||||
</p>
|
||||
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
The violet{' '}
|
||||
<span className="font-medium" style={{ color: VIOLET }}>
|
||||
learned_from
|
||||
</span>{' '}
|
||||
edges are ownership PodMan retained from accepted interventions — the graph
|
||||
gets sharper every session. Click any node to trace its relationships.
|
||||
</p>
|
||||
<div className="pm-dkind">Continual learning</div>
|
||||
<div className="pm-dname">It learned</div>
|
||||
<div className="pm-note">
|
||||
Violet <b style={{ color: '#b7a4ff' }}>learned_from</b> edges are ownership
|
||||
PodMan retained from accepted interventions — the graph gets sharper every
|
||||
session. Click any node to trace its relationships.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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) => (
|
||||
<span key={l.label} className="flex items-center gap-1.5">
|
||||
<span className="inline-block size-3" style={l.swatch} />
|
||||
<span className="pm-lg" key={l.label}>
|
||||
<span className="pm-sw" style={l.swatch} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-[3px] w-3" style={{ background: RED }} />
|
||||
<span className="pm-lg">
|
||||
<span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
|
||||
collides
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
|
||||
<span className="pm-lg">
|
||||
<span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
|
||||
learned_from
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
CircleDotIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageSquareIcon,
|
||||
MonitorUpIcon,
|
||||
RadioTowerIcon,
|
||||
SparklesIcon,
|
||||
@@ -82,7 +80,7 @@ export function PodView({
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [playingBeat, setPlayingBeat] = useState(false);
|
||||
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 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');
|
||||
|
||||
return (
|
||||
@@ -310,24 +299,6 @@ export function PodView({
|
||||
{active.suggestedAction.kind.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{hermes?.interventionId === active.id && (
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<MessageSquareIcon className="size-3.5" />
|
||||
Hermes message
|
||||
</div>
|
||||
<p className="text-sm leading-6">{hermes.text}</p>
|
||||
</div>
|
||||
)}
|
||||
{voiceCue && (
|
||||
<div className="rounded-lg border border-dashed p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<Volume2Icon className="size-3.5" />
|
||||
Voice cue
|
||||
</div>
|
||||
<p className="text-sm leading-6">{voiceCue}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Empty className="min-h-72 border-0 p-0">
|
||||
@@ -343,28 +314,14 @@ export function PodView({
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
{actionUrl && (
|
||||
<a
|
||||
href={actionUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-4 flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-3 py-2 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Sync PR artifact opened
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</CardContent>
|
||||
{active && (
|
||||
<CardFooter className="justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void answerIntervention('dismissed', false)}
|
||||
>
|
||||
<Button variant="outline" onClick={() => void respond('dismissed', false)}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button onClick={() => void answerIntervention('accepted', true)}>
|
||||
<Button onClick={() => void respond('accepted', true)}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
Accept
|
||||
</Button>
|
||||
|
||||
@@ -46,20 +46,6 @@ export async function postOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||
if (!res.ok) throw new Error(`outcome post failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function createSyncPr(input: {
|
||||
headBranch?: string;
|
||||
file?: string;
|
||||
summary?: string;
|
||||
}): Promise<{ url: string; number: number }> {
|
||||
return json(
|
||||
await fetch(`${BACKEND_URL}/api/sync-pr`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Pods CRUD ---
|
||||
|
||||
export async function listPods(): Promise<Pod[]> {
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
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 { createSyncPr, postOutcome } from '../lib/api';
|
||||
import { postOutcome } from '../lib/api';
|
||||
|
||||
export function useInterventions(room: Room | null) {
|
||||
const [active, setActive] = useState<Intervention | null>(null);
|
||||
const [hermes, setHermes] = useState<HermesMessage | null>(null);
|
||||
const [voiceCue, setVoiceCue] = useState<string | null>(null);
|
||||
const [actionUrl, setActionUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return;
|
||||
const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => {
|
||||
if (topic !== DATA_TOPIC) return;
|
||||
const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;
|
||||
if (msg.type === 'COLLISION') {
|
||||
setActive(msg.intervention);
|
||||
setActionUrl(null);
|
||||
}
|
||||
if (msg.type === 'HERMES_MESSAGE') setHermes(msg.message);
|
||||
if (msg.type === 'VOICE_CUE') setVoiceCue(msg.text);
|
||||
if (msg.type === 'COLLISION') setActive(msg.intervention);
|
||||
};
|
||||
room.on(RoomEvent.DataReceived, onData);
|
||||
return () => {
|
||||
@@ -31,13 +23,6 @@ export function useInterventions(room: Room | null) {
|
||||
const respond = useCallback(
|
||||
async (status: InterventionStatus, accepted: boolean) => {
|
||||
if (!active) return;
|
||||
if (accepted && active.suggestedAction.kind === 'open_sync_pr') {
|
||||
const pr = await createSyncPr({
|
||||
file: String(active.suggestedAction.params?.file ?? ''),
|
||||
summary: String(active.suggestedAction.params?.summary ?? active.message),
|
||||
});
|
||||
setActionUrl(pr.url);
|
||||
}
|
||||
await postOutcome({
|
||||
interventionId: active.id,
|
||||
collisionId: active.collisionId,
|
||||
@@ -52,5 +37,5 @@ export function useInterventions(room: Room | null) {
|
||||
[active],
|
||||
);
|
||||
|
||||
return { active, hermes, voiceCue, actionUrl, respond };
|
||||
return { active, respond };
|
||||
}
|
||||
|
||||
+5
-32
@@ -4,7 +4,7 @@ Deploy targets for PodMan on DigitalOcean.
|
||||
|
||||
- `Dockerfile` — builds the backend runtime image from the monorepo root
|
||||
- `app.yaml` — DigitalOcean App Platform spec: static site, API service, agent worker
|
||||
- `systemd/` — local droplet service/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).
|
||||
|
||||
@@ -31,10 +31,9 @@ On the demo droplet, serve the API and worker with systemd instead of tmux:
|
||||
```bash
|
||||
sudo install -m 0644 infra/systemd/podman-platform-api.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-platform-agent.service /etc/systemd/system/
|
||||
sudo install -m 0644 infra/systemd/podman-hermes-*.service infra/systemd/podman-hermes-*.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
|
||||
sudo systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer podman-hermes-sync-deploy.timer
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent
|
||||
sudo systemctl status podman-platform-api podman-platform-agent
|
||||
```
|
||||
|
||||
The services expect:
|
||||
@@ -48,7 +47,6 @@ Useful checks:
|
||||
```bash
|
||||
curl http://127.0.0.1:8787/health
|
||||
journalctl -u podman-platform-api -u podman-platform-agent -f
|
||||
journalctl -u podman-hermes-watchdog -f
|
||||
```
|
||||
|
||||
## DigitalOcean deploy
|
||||
@@ -75,34 +73,9 @@ local LiveKit host.
|
||||
|
||||
```bash
|
||||
sudo cp infra/systemd/podman-platform-*.service /etc/systemd/system/
|
||||
sudo cp infra/systemd/podman-hermes-watchdog.* /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent podman-hermes-watchdog.timer
|
||||
systemctl status podman-platform-api podman-platform-agent podman-hermes-watchdog.timer
|
||||
```
|
||||
|
||||
## Hermes operations layer
|
||||
|
||||
Hermes is the operations copilot for the droplet. The durable layer is:
|
||||
|
||||
- `podman-hermes-watchdog.timer` runs `pnpm hermes:watchdog` every five minutes.
|
||||
- `podman-hermes-sync-deploy.timer` polls `origin/main` every two minutes and deploys clean fast-forward changes.
|
||||
- `podman-public-healthcheck.timer` keeps the fast public URL restart loop.
|
||||
- `/var/log/podman/hermes-watchdog-latest.json` records the latest watchdog report.
|
||||
- `.git/hooks/pre-push`, installed by `pnpm hermes:install`, gates major pushes with typecheck, lint, and a non-remediating watchdog check.
|
||||
|
||||
Install or refresh all local ops wiring:
|
||||
|
||||
```bash
|
||||
pnpm hermes:install
|
||||
```
|
||||
|
||||
Manual one-shot checks:
|
||||
|
||||
```bash
|
||||
pnpm hermes:watchdog
|
||||
pnpm hermes:watchdog:strict
|
||||
pnpm hermes:sync-deploy
|
||||
sudo systemctl enable --now podman-platform-api podman-platform-agent
|
||||
systemctl status podman-platform-api podman-platform-agent
|
||||
```
|
||||
|
||||
## Fallback (demo safety)
|
||||
|
||||
+2
-4
@@ -48,8 +48,7 @@ services:
|
||||
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
|
||||
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
|
||||
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||
@@ -74,8 +73,7 @@ workers:
|
||||
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
|
||||
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
|
||||
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||
|
||||
@@ -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
|
||||
@@ -20,10 +20,6 @@
|
||||
"doctor": "node scripts/deploy-doctor.mjs",
|
||||
"doctor:strict": "node scripts/deploy-doctor.mjs --strict",
|
||||
"deploy:static:local": "node scripts/deploy-static-local.mjs",
|
||||
"hermes:watchdog": "node scripts/hermes-watchdog.mjs",
|
||||
"hermes:watchdog:strict": "node scripts/hermes-watchdog.mjs --strict",
|
||||
"hermes:sync-deploy": "node scripts/hermes-sync-deploy.mjs",
|
||||
"hermes:install": "node scripts/install-hermes-ops.mjs",
|
||||
"healthcheck:public": "node scripts/healthcheck-public.mjs",
|
||||
"verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend",
|
||||
"verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers",
|
||||
|
||||
+11
-79
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { MongoClient } from 'mongodb';
|
||||
@@ -16,6 +15,7 @@ const requiredEnv = [
|
||||
'LIVEKIT_URL',
|
||||
'LIVEKIT_API_KEY',
|
||||
'LIVEKIT_API_SECRET',
|
||||
'GEMINI_API_KEY',
|
||||
'GITHUB_TOKEN',
|
||||
'GITHUB_REPO',
|
||||
'MONGODB_URI',
|
||||
@@ -33,19 +33,6 @@ function isSet(name) {
|
||||
return !!process.env[name]?.trim();
|
||||
}
|
||||
|
||||
function configuredGeminiKey() {
|
||||
const candidates = ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY'];
|
||||
for (const name of candidates) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) continue;
|
||||
if (/replace|todo|example|your|xxx/i.test(value) || value.length < 20) {
|
||||
throw new Error(`${name} looks like a placeholder or truncated key`);
|
||||
}
|
||||
return { name, value };
|
||||
}
|
||||
throw new Error('GEMINI_API_KEY is not set');
|
||||
}
|
||||
|
||||
async function check(name, fn) {
|
||||
try {
|
||||
const detail = await fn();
|
||||
@@ -218,12 +205,12 @@ async function checkGitHub() {
|
||||
}
|
||||
|
||||
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 res = await doFetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||
model,
|
||||
)}:generateContent?key=${encodeURIComponent(key.value)}`,
|
||||
)}:generateContent?key=${encodeURIComponent(process.env.GEMINI_API_KEY)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -237,63 +224,19 @@ async function checkGeminiVision() {
|
||||
return model;
|
||||
}
|
||||
|
||||
async function checkGeminiVoiceModel() {
|
||||
const key = configuredGeminiKey();
|
||||
const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-tts-preview';
|
||||
async function checkGeminiLiveListed() {
|
||||
if (!isSet('GEMINI_API_KEY')) throw new Error('GEMINI_API_KEY is not set');
|
||||
const model = process.env.GEMINI_LIVE_MODEL ?? 'gemini-live-2.5-flash';
|
||||
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));
|
||||
const body = await res.json();
|
||||
const names = (body.models ?? []).map((m) => m.name?.replace(/^models\//, ''));
|
||||
if (!names.includes(model)) throw new Error(`${model} not present in Gemini model list`);
|
||||
if (!model.includes('tts')) return model;
|
||||
|
||||
const tts = await doFetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||
model,
|
||||
)}:generateContent?key=${encodeURIComponent(key.value)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: 'Say clearly: PodMan voice check.' }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ['AUDIO'],
|
||||
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!tts.ok) throw new Error(await responseError('Gemini voice check', tts));
|
||||
const ttsBody = await tts.json();
|
||||
const audio = ttsBody.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
|
||||
if (!audio) throw new Error('Gemini voice response had no audio');
|
||||
return `${model}, generated ${Buffer.from(audio, 'base64').byteLength} audio bytes`;
|
||||
}
|
||||
|
||||
async function checkGeminiEmbeddings() {
|
||||
const key = configuredGeminiKey();
|
||||
const model = process.env.GEMINI_EMBEDDING_MODEL ?? 'gemini-embedding-001';
|
||||
const res = await doFetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||
model,
|
||||
)}:embedContent?key=${encodeURIComponent(key.value)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: { parts: [{ text: 'PodMan vector memory check' }] },
|
||||
taskType: 'RETRIEVAL_DOCUMENT',
|
||||
outputDimensionality: 768,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(await responseError('Gemini embedding check', res));
|
||||
const body = await res.json();
|
||||
const dims = body.embedding?.values?.length;
|
||||
if (!dims) throw new Error('Gemini embedding response had no vector');
|
||||
return `${model}, ${dims} dimensions`;
|
||||
return model;
|
||||
}
|
||||
|
||||
async function checkVoyage() {
|
||||
@@ -319,16 +262,6 @@ await check('workspace', checkWorkspace);
|
||||
for (const name of requiredEnv) {
|
||||
add(`env:${name}`, isSet(name) ? 'ok' : 'fail', isSet(name) ? 'set' : 'missing');
|
||||
}
|
||||
try {
|
||||
const key = configuredGeminiKey();
|
||||
add(
|
||||
'env:GEMINI_API_KEY',
|
||||
'ok',
|
||||
key.name === 'GEMINI_API_KEY' ? 'set' : `using ${key.name} alias`,
|
||||
);
|
||||
} catch (err) {
|
||||
add('env:GEMINI_API_KEY', 'fail', summarizeError(err));
|
||||
}
|
||||
for (const name of optionalEnv) {
|
||||
add(`env:${name}`, isSet(name) ? 'ok' : 'warn', isSet(name) ? 'set' : 'optional');
|
||||
}
|
||||
@@ -348,8 +281,7 @@ await check('livekit room service', checkLiveKitApi);
|
||||
await check('mongo ping', checkMongo);
|
||||
await check('github repo access', checkGitHub);
|
||||
await check('gemini vision model', checkGeminiVision);
|
||||
await check('gemini voice model', checkGeminiVoiceModel);
|
||||
await check('gemini embeddings', checkGeminiEmbeddings);
|
||||
await check('gemini live model listed', checkGeminiLiveListed);
|
||||
|
||||
if (isSet('VOYAGE_API_KEY')) {
|
||||
await check('voyage embeddings', checkVoyage);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -155,21 +155,8 @@ async function verifyMemoryRecall() {
|
||||
detectedAt: new Date().toISOString(),
|
||||
};
|
||||
await recordCollision(seed);
|
||||
const { getDb } = await import('../backend/dist/memory/db.js');
|
||||
const db = await getDb();
|
||||
const stored = await db.collection('collisions').findOne({ id: seed.id });
|
||||
if (!Array.isArray(stored?.embedding) || stored.embedding.length < 1) {
|
||||
fail('memory collision was not enriched with an embedding');
|
||||
}
|
||||
const recalled = await recallSimilar({ ...seed, id: `${seed.id}_query` });
|
||||
if (!recalled) fail('memory recall did not find seeded collision');
|
||||
const vectorRecalled = await recallSimilar({
|
||||
...seed,
|
||||
id: `${seed.id}_vector_query`,
|
||||
file: 'src/nearby-memory.ts',
|
||||
symbol: 'nearbyMemory',
|
||||
});
|
||||
if (!vectorRecalled) fail('vector memory recall did not find semantically similar collision');
|
||||
}
|
||||
|
||||
async function verifyGraph() {
|
||||
|
||||
@@ -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;
|
||||
if (shouldStartPreview) {
|
||||
preview = spawn(
|
||||
@@ -138,27 +124,6 @@ if (shouldStartPreview) {
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...(globalThis.navigator.mediaDevices ?? {}),
|
||||
async getDisplayMedia() {
|
||||
const canvas = globalThis.document.createElement('canvas');
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas context unavailable');
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.font = '28px sans-serif';
|
||||
ctx.fillText('PodMan verification screen', 32, 72);
|
||||
return canvas.captureStream(5);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
@@ -191,17 +156,11 @@ try {
|
||||
await page.getByRole('button', { name: 'Team memory' }).click();
|
||||
await page.getByText('Workflow metrics').waitFor({ timeout: 15_000 });
|
||||
await page.getByText('Learning edges').waitFor({ timeout: 15_000 });
|
||||
await page.getByRole('img', { name: 'PodMan team-memory graph' }).waitFor({ timeout: 15_000 });
|
||||
await page.getByRole('button', { name: 'engineer: Karti' }).click();
|
||||
await page.getByText('Learned owner of auth; backend + DB wiring.').waitFor({ timeout: 15_000 });
|
||||
await page.getByRole('button', { name: 'Whole graph' }).click();
|
||||
await page.getByRole('button', { name: /Pods/i }).click();
|
||||
await page.getByPlaceholder('Your name').first().waitFor({ timeout: 15_000 });
|
||||
|
||||
const frontendPodCard = page
|
||||
.getByText('Frontend Pod', { exact: true })
|
||||
.locator('xpath=ancestor::*[.//input[@placeholder="Your name"]][1]');
|
||||
await frontendPodCard.getByPlaceholder('Your name').fill(verifyMember);
|
||||
await frontendPodCard.getByRole('button', { name: 'Add and join' }).click();
|
||||
await page.getByPlaceholder('Your name').first().fill(verifyMember);
|
||||
await page.getByRole('button', { name: 'Add and join' }).first().click();
|
||||
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||
|
||||
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)}`);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Share screen' }).click();
|
||||
await page.getByRole('button', { name: 'Stop sharing' }).waitFor({ timeout: 15_000 });
|
||||
await page.getByText(/Screen\s*published/i).waitFor({ timeout: 15_000 });
|
||||
await page.getByRole('button', { name: 'Stop sharing' }).click();
|
||||
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||
|
||||
const publisher = await connectPublisher('frontend-pod');
|
||||
try {
|
||||
await 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.getByText('No collision detected').waitFor({ timeout: 15_000 });
|
||||
} finally {
|
||||
@@ -243,7 +199,6 @@ try {
|
||||
bodyLength: bodyText.length,
|
||||
graph: true,
|
||||
joined: true,
|
||||
screenShare: true,
|
||||
intervention: true,
|
||||
member: verifyMember,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ export type {
|
||||
SuggestedActionKind,
|
||||
} from './intervention.js';
|
||||
export * from './messages.js';
|
||||
export type { HermesMessage } from './messages.js';
|
||||
export type {
|
||||
PodGraph,
|
||||
PodGraphNode,
|
||||
|
||||
@@ -7,22 +7,10 @@ export const DATA_TOPIC = 'podman.intervention' as const;
|
||||
/** Wire messages exchanged between the PodMan agent and engineer PWAs. */
|
||||
export type DataMessage =
|
||||
| { type: 'COLLISION'; collision: Collision; intervention: Intervention }
|
||||
| { type: 'HERMES_MESSAGE'; message: HermesMessage }
|
||||
| { type: 'VOICE_CUE'; text: string }
|
||||
| { type: 'ACK'; interventionId: string; status: InterventionStatus; note?: string }
|
||||
| { type: 'GIT_REPORT'; report: LocalGitReport };
|
||||
|
||||
/** A targeted teammate/project-channel notification from the Hermes action layer. */
|
||||
export interface HermesMessage {
|
||||
id: string;
|
||||
podId: string;
|
||||
interventionId: string;
|
||||
recipients: string[];
|
||||
text: string;
|
||||
urgency: 'normal' | 'urgent';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Outcome of an intervention — the supervision signal for policy learning. */
|
||||
export interface InterventionOutcome {
|
||||
interventionId: string;
|
||||
|
||||
Reference in New Issue
Block a user