Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f11d7792a | |||
| 9870a1c710 | |||
| 5e9929f8da | |||
| c97863e05e | |||
| 03a8af6da0 | |||
| 62f5d26f83 | |||
| ad73659389 | |||
| a21a2895c4 | |||
| 2006096cda | |||
| 412d3f7df6 | |||
| 85ab8da598 | |||
| 745f0032f7 | |||
| 6a6fbca9e0 | |||
| fe37f35522 | |||
| 924552a025 | |||
| 7497318cde | |||
| 702026fbd4 | |||
| 4639db11b1 |
@@ -14,6 +14,7 @@ import sharp from 'sharp';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { env } from './env.js';
|
||||
import { PodMan } from './agent/podman.js';
|
||||
import { initMemory } from './memory/db.js';
|
||||
|
||||
const POD_ROOM = process.env.POD_ROOM ?? 'demo-pod';
|
||||
const HERMES_IDENTITY = 'podman-hermes';
|
||||
@@ -30,6 +31,10 @@ async function agentToken(room: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// MongoDB is mandatory. Verify the connection before joining the room so bad
|
||||
// creds / unreachable Atlas fail loudly at boot, not silently mid-demo.
|
||||
await initMemory();
|
||||
|
||||
const room = new Room();
|
||||
const podman = new PodMan(room, POD_ROOM);
|
||||
await room.connect(env.LIVEKIT_URL, await agentToken(POD_ROOM), {
|
||||
|
||||
@@ -4,7 +4,12 @@ import { DATA_TOPIC } from '@podman/shared';
|
||||
import { analyzeFrame } from '../vision/gemini.js';
|
||||
import { detectCollisions } from '../collision/detector.js';
|
||||
import { getGithubState } from '../github/client.js';
|
||||
import { recordObservation, recordCollision, recordIntervention } from '../memory/store.js';
|
||||
import {
|
||||
recordObservation,
|
||||
recordCollision,
|
||||
recordIntervention,
|
||||
updateInterventionStatus,
|
||||
} from '../memory/store.js';
|
||||
import { getGitStates } from '../memory/db.js';
|
||||
import { recallSimilar } from '../memory/vectors.js';
|
||||
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
||||
@@ -30,6 +35,7 @@ export class PodMan {
|
||||
if (c)
|
||||
c.hasUnpushedChanges = msg.report.unpushedCount > 0 || msg.report.dirtyFiles.length > 0;
|
||||
}
|
||||
if (msg.type === 'ACK') void updateInterventionStatus(msg.interventionId, msg.status);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
|
||||
@@ -8,24 +8,62 @@ import type { PodGraph } from '@podman/shared';
|
||||
* auth* — is the continual-learning story the demo lights up.
|
||||
*/
|
||||
export function createDemoPodGraph(podId: string): PodGraph {
|
||||
const base = Date.now();
|
||||
const at = (secAgo: number): string => new Date(base - secAgo * 1000).toISOString();
|
||||
return {
|
||||
podId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
loop: [
|
||||
{ key: 'observe', title: 'OBSERVE', value: '5', detail: '~5/s vision contexts', active: false },
|
||||
{ key: 'store', title: 'STORE', value: '124', detail: 'memory vectors · Atlas', active: false },
|
||||
{ key: 'predict', title: 'PREDICT', value: '1', detail: 'open risk path', active: true },
|
||||
{ key: 'outcome', title: 'OUTCOME', value: '1/0', detail: 'accepted · dismissed', active: false },
|
||||
{ key: 'adapt', title: 'ADAPT', value: '3', detail: 'learned owners', active: false },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: 'demo-learn',
|
||||
at: at(20),
|
||||
kind: 'learned_from',
|
||||
text: 'Memory updated: Karti owns auth.ts (confidence ↑)',
|
||||
},
|
||||
{ id: 'demo-out', at: at(24), kind: 'outcome', text: 'Intervention accepted by the pod' },
|
||||
{
|
||||
id: 'demo-warn',
|
||||
at: at(40),
|
||||
kind: 'warns',
|
||||
text: 'PodMan: "Karti & Yahya are both in auth.ts — open a sync PR?" → card sent',
|
||||
},
|
||||
{
|
||||
id: 'demo-col',
|
||||
at: at(58),
|
||||
kind: 'collision',
|
||||
text: 'Critical overlap on auth.ts · Karti + Yahya',
|
||||
},
|
||||
{
|
||||
id: 'demo-edit',
|
||||
at: at(72),
|
||||
kind: 'editing',
|
||||
text: 'Yahya opened auth.ts — unpushed changes',
|
||||
},
|
||||
],
|
||||
// Kept consistent with the graph below (3 owner engineers, 1 collision file,
|
||||
// 1 of 1 interventions accepted) so the numbers never contradict the picture.
|
||||
metrics: [
|
||||
{
|
||||
label: 'Learned owners',
|
||||
value: '5',
|
||||
detail: 'Ownership edges retained from accepted interventions.',
|
||||
value: '3',
|
||||
detail: 'Distinct owners retained from accepted interventions.',
|
||||
},
|
||||
{
|
||||
label: 'Open risk paths',
|
||||
value: '2',
|
||||
detail: 'auth.ts and the memory API have converging editors.',
|
||||
value: '1',
|
||||
detail: 'File with two or more converging editors.',
|
||||
},
|
||||
{
|
||||
label: 'Accept rate',
|
||||
value: '86%',
|
||||
detail: 'Interventions accepted this session (+14%).',
|
||||
value: '100%',
|
||||
detail: 'Interventions accepted vs total this session.',
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
import type {
|
||||
PodGraph,
|
||||
PodGraphNode,
|
||||
PodGraphEdge,
|
||||
PodGraphMetric,
|
||||
PodGraphNodeKind,
|
||||
PodGraphEdgeKind,
|
||||
PodGraphNodeStatus,
|
||||
LearningStage,
|
||||
LearningStageKey,
|
||||
ActivityEvent,
|
||||
EngineerContext,
|
||||
Collision,
|
||||
Intervention,
|
||||
InterventionOutcome,
|
||||
} from '@podman/shared';
|
||||
import { collections, getGitStates, getDb } from '../memory/db.js';
|
||||
|
||||
/**
|
||||
* Live materializer: build a pod's continual-learning graph from the real
|
||||
* collections the agent writes (pods, engineer_states, observations, collisions,
|
||||
* interventions, outcomes) — NOT the hardcoded demo. See docs/live-ui-spec.md §1.
|
||||
*
|
||||
* Pure-read and best-effort. Returns `null` when there is no real activity yet
|
||||
* (only bare roster), so `loadPodGraph` can fall back to the demo graph.
|
||||
*/
|
||||
|
||||
const ACTIVE_WINDOW_MS = 90_000;
|
||||
const MAX_OBSERVATIONS = 250;
|
||||
|
||||
/** Strip a `git status --short` XY code (and rename `old -> new`) to a clean path. */
|
||||
export function parseGitStatusPath(line: string): string {
|
||||
let s = line.trim();
|
||||
const arrow = s.indexOf(' -> ');
|
||||
if (arrow !== -1) s = s.slice(arrow + 4);
|
||||
else s = s.replace(/^[ACDMRTU?!]{1,2}\s+/, '');
|
||||
return normalizeFile(s);
|
||||
}
|
||||
|
||||
/** Normalize a file path so vision (`collisions.file`) and git paths match. */
|
||||
export function normalizeFile(f: string): string {
|
||||
return f
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/^[ACDMRTU?!]{1,2}\s+/, '')
|
||||
.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
const MAX_COLLISIONS = 8;
|
||||
|
||||
/** Reject "file" values that aren't real source paths — vision/git noise such as
|
||||
* URLs, env vars, browser/app names, and scratch/test artifacts. */
|
||||
const FILE_NOISE =
|
||||
/(:\/\/|^[#~]|\s|\.env\b|\btett\b|test-change|demo-scratch|podman-test|scratch|sslip)/i;
|
||||
export function isFilePath(f: string): boolean {
|
||||
if (!f || FILE_NOISE.test(f)) return false;
|
||||
return /\.[a-z0-9]{1,6}$/i.test(f); // must end in a real file extension
|
||||
}
|
||||
|
||||
/** Engineer names that are test/verification artifacts, not real teammates. */
|
||||
const ENGINEER_NOISE = /(^verify\b|^.$|testrepo|-?check\b|\d{4,})/i;
|
||||
|
||||
const MAX_FILES = 9;
|
||||
|
||||
/** Short, readable node label — last two path segments (full path goes in summary). */
|
||||
function shortLabel(file: string): string {
|
||||
const parts = file.split('/').filter(Boolean);
|
||||
return parts.slice(-2).join('/') || file;
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<PodGraphNodeStatus, number> = {
|
||||
stable: 0,
|
||||
active: 1,
|
||||
learned: 2,
|
||||
risk: 3,
|
||||
};
|
||||
|
||||
interface Builder {
|
||||
nodes: Map<string, PodGraphNode>;
|
||||
edges: Map<string, PodGraphEdge>;
|
||||
}
|
||||
|
||||
function nodeKey(kind: PodGraphNodeKind, key: string): string {
|
||||
return `${kind}:${key}`;
|
||||
}
|
||||
|
||||
function upsertNode(
|
||||
b: Builder,
|
||||
kind: PodGraphNodeKind,
|
||||
key: string,
|
||||
patch: Partial<Omit<PodGraphNode, 'id' | 'kind' | 'x' | 'y'>>,
|
||||
): string {
|
||||
const id = nodeKey(kind, kind === 'engineer' ? key.toLowerCase() : key);
|
||||
const cur = b.nodes.get(id);
|
||||
if (!cur) {
|
||||
b.nodes.set(id, {
|
||||
id,
|
||||
kind,
|
||||
label: patch.label ?? key,
|
||||
summary: patch.summary ?? '',
|
||||
weight: patch.weight ?? 0.6,
|
||||
status: patch.status ?? 'stable',
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
if (patch.label) cur.label = patch.label;
|
||||
if (patch.summary) cur.summary = patch.summary;
|
||||
if (patch.weight && patch.weight > cur.weight) cur.weight = patch.weight;
|
||||
if (patch.status && STATUS_RANK[patch.status] > STATUS_RANK[cur.status])
|
||||
cur.status = patch.status;
|
||||
return id;
|
||||
}
|
||||
|
||||
function upsertEdge(
|
||||
b: Builder,
|
||||
source: string,
|
||||
target: string,
|
||||
kind: PodGraphEdgeKind,
|
||||
label: string,
|
||||
strength: number,
|
||||
): void {
|
||||
const id = `${kind}:${source}->${target}`;
|
||||
const cur = b.edges.get(id);
|
||||
if (!cur) b.edges.set(id, { id, source, target, kind, label, strength });
|
||||
else if (strength > cur.strength) cur.strength = strength;
|
||||
}
|
||||
|
||||
const COLUMN_X: Record<PodGraphNodeKind, number> = {
|
||||
engineer: 78,
|
||||
file: 300,
|
||||
feature: 360,
|
||||
collision: 470,
|
||||
intervention: 622,
|
||||
};
|
||||
|
||||
/** Deterministic column layout so the SVG renders stably across refreshes. */
|
||||
function layout(nodes: PodGraphNode[]): void {
|
||||
const byKind = new Map<PodGraphNodeKind, PodGraphNode[]>();
|
||||
for (const n of nodes) {
|
||||
const list = byKind.get(n.kind) ?? [];
|
||||
list.push(n);
|
||||
byKind.set(n.kind, list);
|
||||
}
|
||||
for (const [kind, list] of byKind) {
|
||||
list.sort((a, b) => a.id.localeCompare(b.id));
|
||||
const n = list.length;
|
||||
list.forEach((node, i) => {
|
||||
node.x = COLUMN_X[kind];
|
||||
node.y = Math.round(((i + 1) / (n + 1)) * 452) + 10;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const SEVERITY_WEIGHT: Record<string, number> = { info: 0.4, warn: 0.7, critical: 1 };
|
||||
|
||||
/** Parse any timestamp-ish value to epoch ms (0 when missing/unparseable). */
|
||||
function ms(t: string | Date | null | undefined): number {
|
||||
if (!t) return 0;
|
||||
const v = new Date(t).getTime();
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
const OBSERVE_WINDOW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Live counts for the learning-loop rail (observe→store→predict→outcome→adapt).
|
||||
* The "active" stage is the one whose latest underlying event is most recent —
|
||||
* with deeper stages winning ties so the rail lights up at the furthest point
|
||||
* the pod reached this session. Additive: derived from already-fetched docs.
|
||||
*/
|
||||
function buildLoop(opts: {
|
||||
now: number;
|
||||
observations: EngineerContext[];
|
||||
collisions: Collision[];
|
||||
outcomes: InterventionOutcome[];
|
||||
riskPaths: number;
|
||||
vectorCount: number;
|
||||
learnedOwners: number;
|
||||
}): LearningStage[] {
|
||||
const { now, observations, collisions, outcomes, riskPaths, vectorCount, learnedOwners } = opts;
|
||||
|
||||
const recentObs = observations.filter((o) => now - ms(o.observedAt) < OBSERVE_WINDOW_MS).length;
|
||||
const rate = (recentObs / 60).toFixed(1);
|
||||
const accepted = outcomes.filter((o) => o.accepted).length;
|
||||
const dismissed = outcomes.filter((o) => !o.accepted).length;
|
||||
|
||||
// Latest event time per stage; `store` sits just behind `predict` so a shared
|
||||
// collision timestamp resolves to PREDICT rather than STORE.
|
||||
const latestObs = Math.max(0, ...observations.map((o) => ms(o.observedAt)));
|
||||
const latestCol = Math.max(0, ...collisions.map((c) => ms(c.detectedAt)));
|
||||
const latestOut = Math.max(0, ...outcomes.map((o) => ms(o.recordedAt)));
|
||||
const latestAdapt = Math.max(
|
||||
0,
|
||||
...outcomes.filter((o) => o.accepted && o.wasRealCollision).map((o) => ms(o.recordedAt)),
|
||||
);
|
||||
|
||||
const refs: Array<[LearningStageKey, number]> = [
|
||||
['observe', latestObs],
|
||||
['store', latestCol ? latestCol - 1 : 0],
|
||||
['predict', latestCol],
|
||||
['outcome', latestOut],
|
||||
['adapt', latestAdapt],
|
||||
];
|
||||
let activeKey: LearningStageKey = 'observe';
|
||||
let best = 0;
|
||||
for (const [k, t] of refs) {
|
||||
if (t > 0 && t >= best) {
|
||||
best = t;
|
||||
activeKey = k;
|
||||
}
|
||||
}
|
||||
|
||||
const stages: Array<Omit<LearningStage, 'active'>> = [
|
||||
{ key: 'observe', title: 'OBSERVE', value: String(recentObs), detail: `~${rate}/s vision contexts` },
|
||||
{ key: 'store', title: 'STORE', value: String(vectorCount), detail: 'memory vectors · Atlas' },
|
||||
{
|
||||
key: 'predict',
|
||||
title: 'PREDICT',
|
||||
value: String(riskPaths),
|
||||
detail: `open risk path${riskPaths === 1 ? '' : 's'}`,
|
||||
},
|
||||
{ key: 'outcome', title: 'OUTCOME', value: `${accepted}/${dismissed}`, detail: 'accepted · dismissed' },
|
||||
{
|
||||
key: 'adapt',
|
||||
title: 'ADAPT',
|
||||
value: String(learnedOwners),
|
||||
detail: `learned owner${learnedOwners === 1 ? '' : 's'}`,
|
||||
},
|
||||
];
|
||||
return stages.map((s) => ({ ...s, active: s.key === activeKey }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge + time-sort recent events into the activity stream feed. Reuses the same
|
||||
* de-noise (isFilePath / ENGINEER_NOISE / signature collapse) as the graph so
|
||||
* the feed never shows junk paths or test-artifact engineers. Capped to 8.
|
||||
*/
|
||||
function buildActivity(opts: {
|
||||
observations: EngineerContext[];
|
||||
collisions: Collision[];
|
||||
interventions: Intervention[];
|
||||
outcomes: InterventionOutcome[];
|
||||
ownership: Record<string, string>;
|
||||
}): ActivityEvent[] {
|
||||
const { observations, collisions, interventions, outcomes, ownership } = opts;
|
||||
const cleanEng = (n: string): boolean => Boolean(n) && !ENGINEER_NOISE.test(n);
|
||||
const out: ActivityEvent[] = [];
|
||||
|
||||
// editing — newest observation per (engineer, file); observations arrive desc.
|
||||
const seenEdit = new Set<string>();
|
||||
for (const o of observations) {
|
||||
if (!o.engineerId || !cleanEng(o.engineerId)) continue;
|
||||
const file = o.currentFile ? normalizeFile(o.currentFile) : '';
|
||||
if (!isFilePath(file)) continue;
|
||||
const key = `${o.engineerId.toLowerCase()}|${file}`;
|
||||
if (seenEdit.has(key)) continue;
|
||||
seenEdit.add(key);
|
||||
out.push({
|
||||
id: `edit:${o.engineerId}:${file}`,
|
||||
at: o.observedAt,
|
||||
kind: 'editing',
|
||||
text: `${o.engineerId} opened ${shortLabel(file)}${
|
||||
o.hasUnpushedChanges ? ' — unpushed changes' : ''
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
// collision — collapse by signature, newest first.
|
||||
const seenCol = new Set<string>();
|
||||
for (const c of collisions) {
|
||||
const file = normalizeFile(c.file);
|
||||
if (!isFilePath(file)) continue;
|
||||
const sig = (c as { memorySignature?: string }).memorySignature ?? `${file}#${c.symbol ?? ''}`;
|
||||
if (seenCol.has(sig)) continue;
|
||||
seenCol.add(sig);
|
||||
const engs = c.engineers.filter(cleanEng);
|
||||
if (!engs.length) continue;
|
||||
out.push({
|
||||
id: `col:${c.id}`,
|
||||
at: c.detectedAt,
|
||||
kind: 'collision',
|
||||
text: `${c.severity === 'critical' ? 'Critical overlap' : 'Overlap'} on ${shortLabel(
|
||||
file,
|
||||
)} · ${engs.join(' + ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// warns — interventions PodMan raised.
|
||||
for (const iv of interventions) {
|
||||
if (!iv.message) continue;
|
||||
const msg = iv.message.length > 64 ? `${iv.message.slice(0, 61)}…` : iv.message;
|
||||
out.push({
|
||||
id: `warn:${iv.id}`,
|
||||
at: iv.createdAt,
|
||||
kind: 'warns',
|
||||
text: `PodMan: "${msg}" → card sent`,
|
||||
});
|
||||
}
|
||||
|
||||
// outcome + learned_from — the supervised learning beat.
|
||||
const colById = new Map(collisions.map((c) => [c.id, c]));
|
||||
const ivById = new Map(interventions.map((i) => [i.id, i]));
|
||||
for (const o of outcomes) {
|
||||
if (!o.accepted) continue;
|
||||
out.push({
|
||||
id: `out:${o.interventionId}`,
|
||||
at: o.recordedAt,
|
||||
kind: 'outcome',
|
||||
text: 'Intervention accepted by the pod',
|
||||
});
|
||||
if (!o.wasRealCollision) continue;
|
||||
const iv = ivById.get(o.interventionId);
|
||||
const col = iv ? colById.get(iv.collisionId) : colById.get(o.collisionId);
|
||||
if (!col) continue;
|
||||
const file = normalizeFile(col.file);
|
||||
if (!isFilePath(file)) continue;
|
||||
const owner =
|
||||
(o as { learnedOwner?: string }).learnedOwner ??
|
||||
ownership[file] ??
|
||||
col.engineers.find(cleanEng) ??
|
||||
col.engineers[0];
|
||||
if (!owner) continue;
|
||||
out.push({
|
||||
id: `learn:${o.interventionId}`,
|
||||
at: o.recordedAt,
|
||||
kind: 'learned_from',
|
||||
text: `Memory updated: ${owner} owns ${shortLabel(file)} (confidence ↑)`,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => ms(b.at) - ms(a.at));
|
||||
return out.slice(0, 8);
|
||||
}
|
||||
|
||||
export async function materializePodGraph(podId: string): Promise<PodGraph | null> {
|
||||
const c = await collections();
|
||||
const db = await getDb();
|
||||
|
||||
const [pod, observations, collisionDocs, interventionDocs, outcomeDocs, gitStates] =
|
||||
await Promise.all([
|
||||
c.pods.findOne({ id: podId }),
|
||||
c.observations.find({ podId }).sort({ observedAt: -1 }).limit(MAX_OBSERVATIONS).toArray(),
|
||||
c.collisions.find({ podId }).sort({ detectedAt: -1 }).limit(100).toArray(),
|
||||
c.interventions.find({ podId }).toArray(),
|
||||
c.outcomes.find({ podId }).toArray(),
|
||||
getGitStates(podId),
|
||||
]);
|
||||
|
||||
// Optional supervised ownership map (team_model.ownership: file -> engineer).
|
||||
let ownership: Record<string, string> = {};
|
||||
try {
|
||||
const tm = await db
|
||||
.collection<{ podId: string; ownership?: Record<string, string> }>('team_model')
|
||||
.findOne({ podId });
|
||||
ownership = tm?.ownership ?? {};
|
||||
} catch {
|
||||
/* ownership is optional */
|
||||
}
|
||||
|
||||
const b: Builder = { nodes: new Map(), edges: new Map() };
|
||||
const now = Date.now();
|
||||
|
||||
// 1. Baseline engineer nodes from the roster.
|
||||
for (const name of pod?.members ?? []) {
|
||||
upsertNode(b, 'engineer', name, { label: name });
|
||||
}
|
||||
|
||||
// 2. Vision (observations): who is active and on which file, with confidence.
|
||||
for (const o of observations) {
|
||||
if (!o.engineerId) continue;
|
||||
const recent = o.observedAt && now - new Date(o.observedAt).getTime() < ACTIVE_WINDOW_MS;
|
||||
const eng = upsertNode(b, 'engineer', o.engineerId, {
|
||||
label: o.engineerId,
|
||||
status: recent ? 'active' : undefined,
|
||||
});
|
||||
const file = o.currentFile ? normalizeFile(o.currentFile) : '';
|
||||
if (isFilePath(file)) {
|
||||
const f = upsertNode(b, 'file', file, { label: shortLabel(file), summary: file });
|
||||
upsertEdge(b, eng, f, 'editing', o.activity ?? 'edits', Math.max(0.4, o.confidence ?? 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
// Collisions referenced by accepted outcomes are the "learned" money path — they
|
||||
// always survive the cap so the learned_from beat is never dropped.
|
||||
const priorityCol = new Set<string>();
|
||||
for (const out of outcomeDocs) {
|
||||
if (!out.accepted || !out.wasRealCollision) continue;
|
||||
if (out.collisionId) priorityCol.add(out.collisionId);
|
||||
const iv = interventionDocs.find((i) => i.id === out.interventionId);
|
||||
if (iv?.collisionId) priorityCol.add(iv.collisionId);
|
||||
}
|
||||
|
||||
// 3. Collisions: collapse repeats by signature, keep the most recent, cap to
|
||||
// MAX_COLLISIONS, skip junk-file collisions. `collisionById` keeps every doc
|
||||
// (for the outcome join); `colNodeFor` maps each collisionId to its surviving
|
||||
// collision node (or null when collapsed / capped / filtered out).
|
||||
const collisionById = new Map<string, (typeof collisionDocs)[number]>();
|
||||
const colNodeFor = new Map<string, string | null>();
|
||||
const sigToNode = new Map<string, string>();
|
||||
let distinctCollisions = 0;
|
||||
for (const col of collisionDocs) {
|
||||
collisionById.set(col.id, col);
|
||||
const file = normalizeFile(col.file);
|
||||
const sig =
|
||||
(col as { memorySignature?: string }).memorySignature ?? `${file}#${col.symbol ?? ''}`;
|
||||
const existing = sigToNode.get(sig);
|
||||
if (existing) {
|
||||
colNodeFor.set(col.id, existing);
|
||||
continue;
|
||||
}
|
||||
if (!isFilePath(file)) {
|
||||
colNodeFor.set(col.id, null);
|
||||
continue;
|
||||
}
|
||||
const isPriority = priorityCol.has(col.id);
|
||||
if (!isPriority && distinctCollisions >= MAX_COLLISIONS) {
|
||||
colNodeFor.set(col.id, null);
|
||||
continue;
|
||||
}
|
||||
const cNode = upsertNode(b, 'collision', col.id, {
|
||||
label: shortLabel(file),
|
||||
status: 'risk',
|
||||
weight: SEVERITY_WEIGHT[col.severity] ?? 0.7,
|
||||
summary: `${col.engineers.join(' + ')} on ${file}${
|
||||
(col as { memorySignature?: string }).memorySignature ? ' · seen before' : ''
|
||||
}`,
|
||||
});
|
||||
const fNode = upsertNode(b, 'file', file, {
|
||||
label: shortLabel(file),
|
||||
summary: file,
|
||||
status: 'risk',
|
||||
});
|
||||
upsertEdge(b, fNode, cNode, 'touches', 'hot', 0.6);
|
||||
for (const name of col.engineers) {
|
||||
const eng = upsertNode(b, 'engineer', name, { label: name });
|
||||
upsertEdge(b, eng, cNode, 'collides', 'in', SEVERITY_WEIGHT[col.severity] ?? 0.7);
|
||||
}
|
||||
sigToNode.set(sig, cNode);
|
||||
colNodeFor.set(col.id, cNode);
|
||||
if (!isPriority) distinctCollisions++;
|
||||
}
|
||||
|
||||
// 4. Git truth (engineer_states): mark unpushed work and confirm editing on
|
||||
// files vision/collisions already surfaced — not the whole repo diff.
|
||||
for (const [name, git] of gitStates) {
|
||||
const files = git.changedFiles.map(parseGitStatusPath).filter(Boolean);
|
||||
const eng = upsertNode(b, 'engineer', name, {
|
||||
label: name,
|
||||
status: files.length > 0 ? 'risk' : 'active',
|
||||
summary: files.length
|
||||
? `${files.length} changed file(s) on ${git.branch ?? 'detached'}`
|
||||
: `on ${git.branch ?? 'detached'}`,
|
||||
weight: 0.7,
|
||||
});
|
||||
for (const file of files) {
|
||||
const fid = nodeKey('file', file);
|
||||
if (b.nodes.has(fid)) upsertEdge(b, eng, fid, 'editing', 'edits', 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Interventions: collapse to one (most recent) per surviving collision.
|
||||
const interventionById = new Map<string, (typeof interventionDocs)[number]>();
|
||||
const ivNodeForCol = new Map<string, string>();
|
||||
const sortedIvs = [...interventionDocs].sort((a, b) =>
|
||||
String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')),
|
||||
);
|
||||
for (const iv of sortedIvs) {
|
||||
interventionById.set(iv.id, iv);
|
||||
const colNode = colNodeFor.get(iv.collisionId);
|
||||
if (!colNode || ivNodeForCol.has(colNode)) continue;
|
||||
const ivNode = upsertNode(b, 'intervention', iv.id, {
|
||||
label:
|
||||
iv.suggestedAction?.kind === 'open_sync_pr'
|
||||
? 'sync PR'
|
||||
: iv.suggestedAction?.kind === 'ping_teammate'
|
||||
? 'ping'
|
||||
: 'watch',
|
||||
summary: iv.message,
|
||||
});
|
||||
upsertEdge(b, colNode, ivNode, 'warns', 'nudges', 0.85);
|
||||
ivNodeForCol.set(colNode, ivNode);
|
||||
}
|
||||
|
||||
// 6. Outcomes: the supervised learning signal -> learned_from edges + owns.
|
||||
for (const out of outcomeDocs) {
|
||||
if (!out.accepted || !out.wasRealCollision) continue;
|
||||
const iv = interventionById.get(out.interventionId);
|
||||
const col = iv ? collisionById.get(iv.collisionId) : collisionById.get(out.collisionId);
|
||||
if (!col) continue;
|
||||
const file = normalizeFile(col.file);
|
||||
if (!isFilePath(file)) continue;
|
||||
const owner =
|
||||
(out as { learnedOwner?: string }).learnedOwner ?? ownership[file] ?? col.engineers[0];
|
||||
if (!owner) continue;
|
||||
const engNode = upsertNode(b, 'engineer', owner, { label: owner, status: 'learned' });
|
||||
const fNode = upsertNode(b, 'file', file, { label: file });
|
||||
upsertEdge(b, engNode, fNode, 'owns', 'owns', 0.85);
|
||||
const cNode = colNodeFor.get(col.id);
|
||||
const ivNode = cNode ? ivNodeForCol.get(cNode) : undefined;
|
||||
if (ivNode) {
|
||||
const ivObj = b.nodes.get(ivNode);
|
||||
if (ivObj) ivObj.status = 'learned';
|
||||
upsertEdge(b, ivNode, engNode, 'learned_from', `learned: owns ${file}`, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune test-artifact engineers, then anything left orphaned by that.
|
||||
const dropNode = (id: string) => {
|
||||
b.nodes.delete(id);
|
||||
for (const [eid, e] of [...b.edges])
|
||||
if (e.source === id || e.target === id) b.edges.delete(eid);
|
||||
};
|
||||
for (const [id, n] of [...b.nodes]) {
|
||||
if (n.kind === 'engineer' && ENGINEER_NOISE.test(n.label)) dropNode(id);
|
||||
}
|
||||
// Collisions with no remaining engineer = test/orphan -> drop.
|
||||
for (const [id, n] of [...b.nodes]) {
|
||||
if (n.kind !== 'collision') continue;
|
||||
if (![...b.edges.values()].some((e) => e.kind === 'collides' && e.target === id)) dropNode(id);
|
||||
}
|
||||
// Cap file nodes to the most-connected (collision files first).
|
||||
const fileNodes = [...b.nodes.values()].filter((n) => n.kind === 'file');
|
||||
if (fileNodes.length > MAX_FILES) {
|
||||
const inCollision = (id: string) =>
|
||||
[...b.edges.values()].some((e) => e.kind === 'touches' && e.source === id);
|
||||
const degree = (id: string) =>
|
||||
[...b.edges.values()].filter((e) => e.source === id || e.target === id).length;
|
||||
fileNodes.sort(
|
||||
(a, z) =>
|
||||
Number(inCollision(z.id)) - Number(inCollision(a.id)) || degree(z.id) - degree(a.id),
|
||||
);
|
||||
for (const n of fileNodes.slice(MAX_FILES)) dropNode(n.id);
|
||||
}
|
||||
|
||||
// Files / interventions left with no edges -> drop.
|
||||
for (const [id, n] of [...b.nodes]) {
|
||||
if (n.kind === 'file' || n.kind === 'intervention') {
|
||||
if (![...b.edges.values()].some((e) => e.source === id || e.target === id))
|
||||
b.nodes.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = [...b.nodes.values()];
|
||||
// No real activity beyond the bare roster -> let the caller fall back to demo.
|
||||
const hasActivity = nodes.some((n) => n.kind !== 'engineer');
|
||||
if (!hasActivity) return null;
|
||||
|
||||
layout(nodes);
|
||||
|
||||
// Metrics are derived from the FINAL de-noised graph (not raw docs) so the
|
||||
// numbers match what's actually on screen. Counting raw collision signatures /
|
||||
// accepted-outcome rows inflates them with test churn (e.g. 50 "risk paths" for
|
||||
// 2 files), which reads as fake — these count distinct visible entities instead.
|
||||
const finalEdges = [...b.edges.values()];
|
||||
|
||||
// Open risk paths = distinct files carrying a surviving collision (the triangles).
|
||||
const riskFiles = new Set<string>();
|
||||
for (const e of finalEdges) {
|
||||
if (e.kind === 'touches' && b.nodes.get(e.source)?.kind === 'file') riskFiles.add(e.source);
|
||||
}
|
||||
const collisionNodeCount = nodes.filter((n) => n.kind === 'collision').length;
|
||||
const riskPaths = riskFiles.size || collisionNodeCount;
|
||||
|
||||
// Learned owners = distinct engineers PodMan retained as owners from accepted
|
||||
// interventions (the owns / learned_from edges actually drawn).
|
||||
const ownerSet = new Set<string>();
|
||||
for (const e of finalEdges) {
|
||||
if (e.kind === 'learned_from') ownerSet.add(e.target);
|
||||
if (e.kind === 'owns') ownerSet.add(e.source);
|
||||
}
|
||||
const learnedOwners = [...ownerSet].filter((id) => b.nodes.get(id)?.kind === 'engineer').length;
|
||||
|
||||
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
|
||||
const totalOutcomes = outcomeDocs.length;
|
||||
const acceptRate = totalOutcomes ? Math.round((acceptedReal / totalOutcomes) * 100) : null;
|
||||
|
||||
const metrics: PodGraphMetric[] = [
|
||||
{
|
||||
label: 'Learned owners',
|
||||
value: String(learnedOwners),
|
||||
detail: 'Distinct owners retained from accepted interventions.',
|
||||
},
|
||||
{
|
||||
label: 'Open risk paths',
|
||||
value: String(riskPaths),
|
||||
detail: `${riskPaths === 1 ? 'File' : 'Files'} with two or more converging editors.`,
|
||||
},
|
||||
{
|
||||
label: 'Accept rate',
|
||||
value: acceptRate == null ? '—' : `${acceptRate}%`,
|
||||
detail: 'Interventions accepted vs total this session.',
|
||||
},
|
||||
];
|
||||
|
||||
// Stored vectors for the STORE stage: prefer a real memory_vectors count,
|
||||
// fall back to collisions carrying an embedding, then to collision count.
|
||||
let vectorCount = 0;
|
||||
try {
|
||||
vectorCount = await db.collection('memory_vectors').countDocuments({ podId });
|
||||
} catch {
|
||||
/* memory_vectors is optional */
|
||||
}
|
||||
if (!vectorCount)
|
||||
vectorCount = collisionDocs.filter(
|
||||
(c) => (c as { embedding?: number[] }).embedding?.length,
|
||||
).length;
|
||||
if (!vectorCount) vectorCount = collisionDocs.length;
|
||||
|
||||
const loop = buildLoop({
|
||||
now,
|
||||
observations,
|
||||
collisions: collisionDocs,
|
||||
outcomes: outcomeDocs,
|
||||
riskPaths,
|
||||
vectorCount,
|
||||
learnedOwners,
|
||||
});
|
||||
const activity = buildActivity({
|
||||
observations,
|
||||
collisions: collisionDocs,
|
||||
interventions: interventionDocs,
|
||||
outcomes: outcomeDocs,
|
||||
ownership,
|
||||
});
|
||||
|
||||
return {
|
||||
podId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
nodes,
|
||||
edges: [...b.edges.values()],
|
||||
metrics,
|
||||
loop,
|
||||
activity,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PodGraph, GraphNodeDoc, GraphEdgeDoc } from '@podman/shared';
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { createDemoPodGraph } from './demo.js';
|
||||
import { materializePodGraph } from './live.js';
|
||||
|
||||
interface TeamModelDoc {
|
||||
podId: string;
|
||||
@@ -14,6 +15,14 @@ interface TeamModelDoc {
|
||||
* unreachable — so the demo path never depends on a populated DB.
|
||||
*/
|
||||
export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
||||
// 1. Live: materialize from the real collections (observations/collisions/…).
|
||||
try {
|
||||
const live = await materializePodGraph(podId);
|
||||
if (live) return live;
|
||||
} catch (err) {
|
||||
console.warn(`[graph] live materialize failed, falling back: ${(err as Error).message}`);
|
||||
}
|
||||
// 2. Seeded snapshot embedded in team_model.
|
||||
try {
|
||||
const db = await getDb();
|
||||
const doc = await db.collection<TeamModelDoc>('team_model').findOne({ podId });
|
||||
@@ -21,6 +30,7 @@ export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
||||
} catch (err) {
|
||||
console.warn(`[graph] loadPodGraph fell back to demo: ${(err as Error).message}`);
|
||||
}
|
||||
// 3. Demo (stage safety — never an empty canvas).
|
||||
return createDemoPodGraph(podId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import type { EngineerContext, Collision, Intervention, InterventionOutcome } from '@podman/shared';
|
||||
import type {
|
||||
EngineerContext,
|
||||
Collision,
|
||||
Intervention,
|
||||
InterventionOutcome,
|
||||
InterventionStatus,
|
||||
} from '@podman/shared';
|
||||
import { collections } from './db.js';
|
||||
import { enrichCollisionMemory } from './vectors.js';
|
||||
|
||||
/**
|
||||
* Continual-learning memory: persist observations, collisions, interventions,
|
||||
* and outcomes to MongoDB so later sessions get sharper. Writes are best-effort
|
||||
* — a Mongo hiccup logs a warning rather than crashing the agent/server.
|
||||
* and outcomes to MongoDB so later sessions get sharper. MongoDB is mandatory —
|
||||
* a failed write is surfaced loudly and rethrown, never silently swallowed, so
|
||||
* a broken memory layer can never masquerade as a working one.
|
||||
*/
|
||||
async function persist(name: string, fn: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.warn(`[memory] ${name} persist failed: ${(err as Error).message}`);
|
||||
console.error(`[memory] ${name} persist FAILED: ${(err as Error).message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +41,15 @@ export async function recordIntervention(intervention: Intervention): Promise<vo
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateInterventionStatus(
|
||||
interventionId: string,
|
||||
status: InterventionStatus,
|
||||
): Promise<void> {
|
||||
await persist('intervention ack', async () =>
|
||||
(await collections()).interventions.updateOne({ id: interventionId }, { $set: { status } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||
await persist('outcome', async () => {
|
||||
const c = await collections();
|
||||
|
||||
@@ -177,7 +177,11 @@ http.listen(env.PORT, '0.0.0.0', () => {
|
||||
console.log(`[server] :${env.PORT}`);
|
||||
initMemory()
|
||||
.then(() => seedDefaultPods())
|
||||
.catch((e) => console.warn(`[memory] init failed: ${(e as Error).message}`));
|
||||
.catch((e) => {
|
||||
// MongoDB is mandatory — do not run a half-dead API against a broken DB.
|
||||
console.error(`[memory] init FAILED, exiting: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# Team Memory Graph — Redesign Brief (fresh-session handoff)
|
||||
|
||||
> **You are a fresh Claude Code session with no prior context. Read this whole file first.**
|
||||
> Your job: rebuild the **live "Team memory" graph view** so the **light/real-data** version is as
|
||||
> polished and functional as the original **dark Bauhaus mock**, and make the graph **dynamic**
|
||||
> (force-directed + animated), not the current dead static-column layout.
|
||||
> **Do not rewrite the backend materializer — it is good.** The problem is 100% the frontend rendering.
|
||||
|
||||
---
|
||||
|
||||
## 0. Mission (one paragraph)
|
||||
|
||||
PodMan's "Team memory" is a per-pod graph of who owns/edits which files, where work collides, and what
|
||||
PodMan learned from accepted interventions — the continual-learning loop made legible in 10 seconds.
|
||||
A **dark Bauhaus mock** of this view looks great (clean 3-panel layout, a learning-loop rail, an activity
|
||||
stream, a readable graph). The **shipped light version on real data looks terrible** (a hairball of red
|
||||
edges, overlapping labels, a static lifeless layout, and it's missing the learning-loop rail + activity
|
||||
stream entirely). Make the light version match the mock's structure/polish/functionality, in the app's
|
||||
**light shadcn theme**, and make the **graph dynamic** (organic force-directed layout, draggable,
|
||||
animated transitions). Keep using **real data** from the existing materializer.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two reference points
|
||||
|
||||
### A. The dark Bauhaus mock = what "good" looks like (target structure)
|
||||
|
||||
A single dark card titled **"PODMAN — CONTINUAL-LEARNING OBSERVATORY"** with a `LIVE · POD demo-pod`
|
||||
status. Layout:
|
||||
|
||||
- **Left rail — WORKFLOW METRICS**: a vertical stack of bordered cards, each a big numeral + an
|
||||
UPPERCASE tracked label + a one-line detail, with a colored left-accent bar:
|
||||
`03 PODS WATCHED`, `05 ENGINEERS LIVE`, `02 COLLISIONS OPEN (▲ auth.ts critical)`,
|
||||
`01 INTERVENTION SENT`, `86% ACCEPT RATE (▲ +14% this session)`, `124 MEMORY VECTORS`.
|
||||
- **Center — the GRAPH**: sparse, geometric, readable. Node shapes encode kind
|
||||
(engineer = filled square, file = outlined square, feature = circle, collision = triangle,
|
||||
intervention = diamond). One **risk path is lit** (Karti+Yahya → auth.ts → collision → sync PR →
|
||||
`learned_from`), everything else dimmed. Edges color-coded (`collides` red, `warns` amber/orange,
|
||||
`learned_from` dashed violet, `owns` blue, `editing` paper, `touches` grey).
|
||||
- **Right rail — LEARNING LOOP**: a vertical 5-step stepper with the active step highlighted/pulsing:
|
||||
`01 OBSERVE (vision → 5 contexts/s)` → `02 STORE (124 vectors · Atlas)` →
|
||||
`03 PREDICT (2 collisions flagged)` [active] → `04 OUTCOME (1 accepted · 0 dismissed)` →
|
||||
`05 ADAPT (Karti→auth ownership +)`. Arrows between steps.
|
||||
- **Bottom-left — ACTIVITY STREAM**: a time-stamped feed with colored kind-tags:
|
||||
`15:48 EDITING Yahya opened auth.ts — unpushed changes detected`,
|
||||
`15:48 COLLISION Critical overlap on auth.ts · Karti + Yahya`,
|
||||
`15:49 WARNS PodMan spoke: "open a sync PR?" → card sent`,
|
||||
`15:49 OUTCOME Sync PR accepted by the pod`,
|
||||
`15:49 LEARNED_FROM Memory updated: Karti owns auth (confidence ↑)`.
|
||||
- **Bottom-right — SELECTED NODE**: click a node → kind / name / relationships count / severity / a
|
||||
one-line "why" (`Two engineers editing the same file before push — the signal git can't see.`).
|
||||
- **Legend**: engineer / file / feature / collision / intervention · collides / warns / learned_from.
|
||||
|
||||
It reads in 10 seconds because it is **sparse, color-coded, and tells the loop story** with the rails +
|
||||
stream, not just a node blob. (The full mock HTML/CSS is reproduced in **Appendix A** — port its
|
||||
structure to light shadcn.)
|
||||
|
||||
### B. The shipped light version = what's wrong (the thing to fix)
|
||||
|
||||
Same data, but: a **hairball** — every engineer fans red `collides` edges to ~6 collision triangles
|
||||
(all labeled `sync PR`); **file labels overlap** in a dim middle column; the graph uses a **static
|
||||
deterministic column layout** (`x` by kind, `y` evenly spread) so it looks dead/lifeless; and it is
|
||||
**missing the LEARNING LOOP rail and the ACTIVITY STREAM** entirely — it's just a metrics rail + the bare
|
||||
graph + a selected-node panel. (Two already-fixed-on-branch items: garbage collision labels
|
||||
`infra/README.md### Running the git watcher` and full-path bleed — see PR #7 / commit `a21a289`,
|
||||
`shortLabel` in `live.ts`. Build on top of that, don't redo it.)
|
||||
|
||||
---
|
||||
|
||||
## 2. The gap to close (light vs mock)
|
||||
|
||||
| Mock has | Light version | Action |
|
||||
| ---------------------------------------- | ------------------------------- | ---------------------------------- |
|
||||
| Workflow metrics rail | ✅ has it (3 metrics) | keep; restyle to match |
|
||||
| **Learning loop rail (observe→…→adapt)** | ❌ missing | **build it** (needs live counts) |
|
||||
| **Activity stream feed** | ❌ missing | **build it** (needs an event feed) |
|
||||
| Selected-node panel | ✅ has it | keep |
|
||||
| **Dynamic / animated graph** | ❌ static columns | **replace the layout** |
|
||||
| Sparse, lit "risk path" | partial (Risk-path mode exists) | improve emphasis + spacing |
|
||||
| Legend | ✅ | keep |
|
||||
|
||||
---
|
||||
|
||||
## 3. Current architecture (build on this — do NOT rewrite the materializer)
|
||||
|
||||
**Backend (good, keep):**
|
||||
|
||||
- `backend/src/graph/live.ts` — `materializePodGraph(podId)`: builds the graph from the real Mongo
|
||||
collections (`pods`, `engineer_states`, `observations`, `collisions`, `interventions`, `outcomes`).
|
||||
It already de-noises hard: collapses collisions by `memorySignature`, caps to 8, collapses
|
||||
interventions to one per collision, filters junk files (`isFilePath`), prunes test-artifact engineers
|
||||
(`ENGINEER_NOISE`), caps files to 9, short labels (`shortLabel`). Output is ~20 clean nodes for
|
||||
`demo-pod`. **This is solid — extend it, don't replace it.**
|
||||
- `backend/src/graph/store.ts` — `loadPodGraph(podId)`: live materializer → seeded `team_model.graph`
|
||||
→ demo fallback (`createDemoPodGraph`). Plus `reachFrom` (`$graphLookup`).
|
||||
- Route: `GET /api/pods/:id/graph` returns `PodGraph` (also `/graph/reach/:node`).
|
||||
- `backend/src/memory/db.ts` — `collections()`, `getGitStates(podId)`, `getDb()`.
|
||||
- WS bus: `backend/src/server.ts` hosts `ws /api/events` (the agent + `/api/outcome` broadcast here).
|
||||
|
||||
**Frontend (this is where the work is):**
|
||||
|
||||
- `frontend/src/components/GraphView.tsx` — **the thing you redesign** (~90% of the work). Currently:
|
||||
fetches `/api/pods/:id/graph`, renders a bespoke SVG with the static column layout, Risk/Learning/Whole
|
||||
toggles, a metrics rail, a selected-node panel, a legend. Composed from shadcn primitives (`Button`,
|
||||
`Badge`) + Tailwind utilities. Theme-aware via shadcn tokens.
|
||||
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`.
|
||||
- Opened from each `PodCard`'s `⋯` menu → "Team memory" (`onOpenGraph(pod.id)` in
|
||||
`frontend/src/App.tsx`). It is a conditional render (no route).
|
||||
|
||||
**Data contract** (`shared/src/graph.ts`):
|
||||
|
||||
```ts
|
||||
PodGraph = { podId, generatedAt, nodes: PodGraphNode[], edges: PodGraphEdge[], metrics: PodGraphMetric[] }
|
||||
PodGraphNode = { id, kind, label, summary, weight 0..1, status: 'stable'|'active'|'risk'|'learned', x, y }
|
||||
// kind: 'engineer'|'feature'|'file'|'collision'|'intervention'
|
||||
PodGraphEdge = { id, source, target, kind, label, strength 0..1 }
|
||||
// kind: 'owns'|'editing'|'touches'|'collides'|'warns'|'learned_from'
|
||||
PodGraphMetric = { label, value, detail }
|
||||
```
|
||||
|
||||
**Theme / components (HARD RULE):** the app is **light shadcn**, built from the **ruixen registry** —
|
||||
add primitives with `npx shadcn@latest add "https://ruixen.com/r/[component]"` and compose from
|
||||
`@/components/ui/*` (`Button`, `Badge`, `Card`, `Tabs`, `ToggleGroup`, etc.) using the design tokens
|
||||
(`var(--card)` / `--foreground` / `--muted-foreground` / `--border`, `--chart-1..5`). Only the SVG/canvas
|
||||
graph is bespoke. Match `frontend/src/App.tsx`'s `StatPill`/`BriefLine` utility patterns.
|
||||
|
||||
---
|
||||
|
||||
## 4. Target design (build this)
|
||||
|
||||
A light shadcn page with the **mock's structure**:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ Header: "Team memory · What PodMan learned · <pod>" [← Pods] │
|
||||
├───────────────────────────────────────────────────────────────────────┤
|
||||
│ Toggles: Risk path | Learning edges | Whole graph (keep) │
|
||||
├──────────────┬──────────────────────────────────┬─────────────────────┤
|
||||
│ WORKFLOW │ │ LEARNING LOOP │
|
||||
│ METRICS │ DYNAMIC GRAPH CANVAS │ observe→store→ │
|
||||
│ (cards) │ (force-directed + animated) │ predict→outcome→ │
|
||||
│ │ │ adapt (active pulses)│
|
||||
├──────────────┴──────────────────────────────────┴─────────────────────┤
|
||||
│ ACTIVITY STREAM (time-tagged feed) │ SELECTED NODE (detail) │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Light shadcn** throughout (theme-aware; follows dark mode if the app ever toggles). Keep the
|
||||
geometric **node-shape + color encoding** (it's the legible part) but on light surfaces with the
|
||||
app's hues (engineer blue `#2563eb`, file slate outline `#475569`, feature amber `#d97706`,
|
||||
collision red `#dc2626`, intervention violet `#7c3aed`; edges: collides red, warns amber,
|
||||
learned_from dashed violet, owns blue, editing slate, touches faint slate).
|
||||
- **Default to "Risk path"**: light the collision→intervention→`learned_from` chain; dim the rest.
|
||||
|
||||
---
|
||||
|
||||
## 5. Make the graph DYNAMIC (the headline new requirement)
|
||||
|
||||
The static column layout (`live.ts` `layout()` sets `x`/`y` by kind) looks dead. Replace the frontend
|
||||
rendering with a **dynamic** graph. Pick one (recommended order):
|
||||
|
||||
1. **`d3-force` force-directed (recommended).** Add `d3-force` (small). Run a force simulation on the
|
||||
`PodGraph` nodes/edges: link force (by `edge.strength`), charge/repulsion, center, collision radius
|
||||
(by `node.weight`). Render nodes/edges as SVG, update positions per tick. Make nodes **draggable**
|
||||
(pin on drag). Animate new nodes/edges fading in on data refresh, and the `learned_from` dashed
|
||||
stroke animating. Ignore the server's `x`/`y` (or use them as initial positions). Keep node shapes.
|
||||
2. `react-force-graph` / `force-graph` (canvas) — heavier, faster for big graphs; overkill at ~20 nodes
|
||||
but fine.
|
||||
3. A custom animated **layered** layout (engineers → files → collisions → interventions columns, but
|
||||
with curved edges, eased position transitions on refresh, and gentle idle motion). Lighter-weight
|
||||
than d3-force; still feels alive if you animate transitions.
|
||||
|
||||
**Realtime/dynamic data:** poll `GET /api/pods/:id/graph` every ~5s and **animate the diff** between
|
||||
snapshots (don't hard-replace). Optionally subscribe to `ws /api/events` for instant nudges. New
|
||||
collisions/interventions should visibly animate in; the `learned_from` edge + gold node should pop on a
|
||||
new accepted outcome.
|
||||
|
||||
**De-hairball:** even force-directed, ~6 collisions × 3 engineers = many `collides` edges. Mitigate:
|
||||
bundle/curve edges, lower non-risk edge opacity, default to Risk-path emphasis, size nodes by `weight`,
|
||||
and keep label collision-avoidance (offset labels, hide on overlap, show on hover/select).
|
||||
|
||||
---
|
||||
|
||||
## 6. Data for the new panels (extend the materializer or add endpoints)
|
||||
|
||||
The mock's **Learning Loop** and **Activity Stream** need data the current `PodGraph` doesn't carry. Two
|
||||
options: (a) extend `materializePodGraph` to also return `loop` + `activity`, or (b) add small endpoints.
|
||||
Recommended: extend the return type (additive to `shared/src/graph.ts`).
|
||||
|
||||
- **Learning loop counts** (`observe→store→predict→outcome→adapt`):
|
||||
- observe = recent `observations` count (e.g. last 60s) / rate
|
||||
- store = `memory_vectors` or `collisions.embedding` count (Voyage vectors)
|
||||
- predict = open `collisions` (distinct signatures) count
|
||||
- outcome = `outcomes` accepted vs dismissed counts
|
||||
- adapt = `team_model.ownership` entries / learned owners count
|
||||
- mark the "active" stage = the most recent activity.
|
||||
- **Activity stream**: merge + time-sort recent events from `collisions.detectedAt`,
|
||||
`interventions.createdAt`, `outcomes.recordedAt`, `engineer_states.gitUpdatedAt` → a typed feed
|
||||
`{ at, kind: 'editing'|'collision'|'warns'|'outcome'|'learned_from', text }`. Cap to ~8 most recent.
|
||||
|
||||
(`backend/src/memory/db.ts` `collections()` gives you `observations/collisions/interventions/outcomes`;
|
||||
`getGitStates` gives engineer_states; `team_model` is `db.collection('team_model')`.)
|
||||
|
||||
---
|
||||
|
||||
## 7. Files to touch
|
||||
|
||||
- **`frontend/src/components/GraphView.tsx`** — the redesign (force-directed graph + 3-panel layout +
|
||||
learning-loop rail + activity stream). May split into `GraphCanvas.tsx`, `LearningLoop.tsx`,
|
||||
`ActivityStream.tsx`, `MetricsRail.tsx`.
|
||||
- **`frontend/src/lib/graph.ts`** — add fetches for loop/activity if you add endpoints.
|
||||
- **`backend/src/graph/live.ts`** (extend, don't rewrite) — emit `loop` + `activity` in the result;
|
||||
keep all the de-noise.
|
||||
- **`shared/src/graph.ts`** — add `loop`/`activity` types to `PodGraph` (additive).
|
||||
- **deps** — `d3-force` (+ `@types/d3-force`) via pnpm in `frontend`.
|
||||
- Possibly add a ruixen primitive (e.g. `timeline`, `stepper`) via the shadcn CLI if one fits.
|
||||
|
||||
---
|
||||
|
||||
## 8. Constraints & gotchas (READ — these will bite you)
|
||||
|
||||
- **The materializer is good — do not rewrite it.** It already de-noises (caps, collapse-by-signature,
|
||||
engineer/file filters, short labels). The bad UI is the **frontend layout/render**, not the data.
|
||||
- **PWA service worker caches aggressively** — after any deploy, hard-refresh (Cmd-Shift-R) or test in a
|
||||
private window, or you'll think nothing changed.
|
||||
- **Deploy = merge to `main`** (DO `deploy_on_push: true`). `main` is shared by ~4 engineers and moves
|
||||
fast. Work on a branch, open a PR, merge. Don't push to `main` directly.
|
||||
- **`learned_from` "money" edge won't render on `demo-pod`** right now — its one accepted outcome is
|
||||
orphaned (points at a collision deleted by test churn). It needs **one intact accept flow** (real
|
||||
collision → intervention → someone clicks Accept) to draw. To demo, seed a clean chain or clear test
|
||||
docs (writes to shared Atlas — confirm scope first).
|
||||
- **Atlas creds rotate frequently** — `podman/.env`'s `MONGODB_URI` may be stale; the **deployed env**
|
||||
has the working one. If local Mongo auth fails, that's why.
|
||||
- **Build verification**: some sandboxes can't run `pnpm`/`vite`/shadcn deps (`lucide-react`,
|
||||
`@radix-ui`). Verify the frontend with `pnpm build` in a real env or CI before merging. Backend
|
||||
typecheck excludes uninstalled `ws`/`sharp`/`@livekit/rtc-node` noise.
|
||||
- **Compose from ruixen/shadcn primitives** (`npx shadcn add ruixen.com/r/[component]`,
|
||||
`@/components/ui/*`); only the SVG/canvas graph is bespoke. Match `StatPill`/`BriefLine` in `App.tsx`.
|
||||
- **Light theme + tokens** — never hardcode dark colors for chrome; use `var(--card)/--foreground/…`.
|
||||
Keep fixed semantic hues only for the node/edge kind encoding.
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- Light Team-memory page matches the mock's structure: **metrics rail + dynamic graph + learning-loop
|
||||
rail + activity stream + selected-node panel + legend**.
|
||||
- **Graph is dynamic**: force-directed (or animated layered), **draggable**, **animates** new
|
||||
nodes/edges and refresh transitions; **no overlapping labels, no hairball**.
|
||||
- Reads in 10s; the **risk/money path is obvious** by default.
|
||||
- **Light shadcn** theme, theme-aware; composed from ruixen primitives.
|
||||
- Uses **real data** from `materializePodGraph`; graceful demo fallback when empty.
|
||||
- `pnpm build` + typechecks pass; deploys; verified after a hard-refresh.
|
||||
|
||||
---
|
||||
|
||||
## 10. Suggested first moves for the new session
|
||||
|
||||
1. Read this file + `docs/graph.md` + `docs/live-ui-spec.md` (R1/R2 sections) + `CLAUDE.md`.
|
||||
2. `git fetch`; branch off `main` (or `feat/live-graph-glue`, which has the latest graph work).
|
||||
3. Hit the live data once: `curl https://165-22-129-249.sslip.io/api/pods/demo-pod/graph` — that's the
|
||||
real `PodGraph` you'll render.
|
||||
4. Build a `d3-force` `GraphCanvas` first (replace the static layout), get it draggable + animated.
|
||||
5. Add `LearningLoop` + `ActivityStream` (extend the materializer to feed them).
|
||||
6. Polish to the mock; `pnpm build`; PR → main → redeploy → hard-refresh.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — the dark mock (reference structure to port to light)
|
||||
|
||||
The mock is a single dark card. Structure + the exact content to reproduce (in light shadcn):
|
||||
|
||||
- Header: brand glyph (blue square + amber circle + red triangle + outlined square) + `PODMAN /
|
||||
CONTINUAL-LEARNING OBSERVATORY` + `● LIVE · POD demo-pod`.
|
||||
- Grid `180px 1fr 196px`: **metrics rail** | **graph** | **learning-loop rail**.
|
||||
- Metrics cards: big `Archivo`-weight numeral, uppercase tracked label, muted detail, colored
|
||||
left-accent (blue/red/yellow/violet/green).
|
||||
- Graph: SVG, geometric node shapes by kind, color-coded edges, one lit risk path, dim others; click a
|
||||
node → highlight its incident edges + neighbors, fill the selected-node panel.
|
||||
- Learning-loop rail: 5 bordered steps with number + UPPERCASE title + muted sub; the active step has a
|
||||
pulsing left bar; `↓` arrows between.
|
||||
- Legend row (node kinds + edge kinds).
|
||||
- Activity stream (time + colored tag + text) and selected-node panel below.
|
||||
|
||||
Palette used (port to shadcn tokens for chrome; keep these as node/edge hues):
|
||||
`bg #0c0c0e`, `panel #141417`, `line #2a2a31`, `paper/text #ECE7DA`, `muted #8d897e`,
|
||||
`blue #3B5BFF`, `red #E2403A`, `amber #F6C445`, `violet #8b6cff`, `green #46c07a`.
|
||||
For light: chrome → `var(--card)/--foreground/--border/--muted-foreground`; node/edge hues →
|
||||
blue `#2563eb`, slate `#475569`, amber `#d97706`, red `#dc2626`, violet `#7c3aed` (light-readable).
|
||||
|
||||
> The dark mock was a `show_widget` demo (not a saved file). If you want the literal HTML/CSS, ask the
|
||||
> user to paste it, or reconstruct from this appendix — the **structure + content above is the spec**.
|
||||
> Goal: same structure, same legibility, **light theme + dynamic graph**.
|
||||
@@ -0,0 +1,483 @@
|
||||
# Team Memory Graph - Codex Redesign Brief
|
||||
|
||||
> **Read this whole file before touching code.**
|
||||
> This is a fresh-session handoff for rebuilding PodMan's live **Team memory** graph UI.
|
||||
> The goal is not to tweak labels or add another filter. The goal is to make the
|
||||
> real-data light UI as polished, legible, and functional as the original dark
|
||||
> Bauhaus mock, while keeping the graph backed by live MongoDB data.
|
||||
|
||||
## 0. Mission
|
||||
|
||||
PodMan's Team memory view should make the recursive self-improvement loop visible:
|
||||
who is working, which files overlap, where collisions happen, which intervention was
|
||||
sent, and what PodMan learned from accepted outcomes.
|
||||
|
||||
The current light real-data implementation proves the backend can materialize a graph,
|
||||
but the UI does not yet tell the story. It still reads as a static node-link diagram:
|
||||
edges dominate, labels collide, the layout feels fixed, and the important loop
|
||||
`observe -> store -> predict -> outcome -> adapt` is not visible.
|
||||
|
||||
Rebuild the Team memory experience so it has the narrative clarity of the dark
|
||||
Bauhaus mock, in the app's light shadcn/ruixen visual system, with a dynamic graph
|
||||
that animates and responds to live data changes.
|
||||
|
||||
## 1. Current State
|
||||
|
||||
Branch context:
|
||||
|
||||
- Work is on `feat/live-graph-glue`.
|
||||
- The live graph backend exists and should be reused.
|
||||
- A PR for the live graph glue already exists, and later commits have continued
|
||||
refining readability.
|
||||
- The current file requested by the user is this document:
|
||||
`codex_team_memory_redisgn.md`.
|
||||
|
||||
Important existing files:
|
||||
|
||||
- `backend/src/graph/live.ts`
|
||||
Builds `PodGraph` from real Mongo collections:
|
||||
`pods`, `engineer_states`, `observations`, `collisions`, `interventions`,
|
||||
and `outcomes`.
|
||||
- `backend/src/graph/store.ts`
|
||||
Loads live graph first, then seeded `team_model.graph`, then demo fallback.
|
||||
- `shared/src/graph.ts`
|
||||
Defines the graph contract.
|
||||
- `frontend/src/components/GraphView.tsx`
|
||||
Current frontend graph rendering. This is the main file to redesign.
|
||||
- `frontend/src/lib/graph.ts`
|
||||
Fetches the graph.
|
||||
- `frontend/src/App.tsx` and `frontend/src/components/PodCard.tsx`
|
||||
Open Team memory per pod.
|
||||
|
||||
Do **not** start by rewriting the backend materializer. It already does the most
|
||||
important real-data work: filtering noisy files, collapsing repeated collisions,
|
||||
capping graph size, shortening labels, and pruning test engineers. The redesign is
|
||||
primarily a frontend information-architecture and interaction problem.
|
||||
|
||||
## 2. Reference Screens
|
||||
|
||||
### Current Light Real-Data UI
|
||||
|
||||
The light UI is technically real and connected to live data, but it fails visually.
|
||||
|
||||
Observed problems:
|
||||
|
||||
- The graph is too static and column-like.
|
||||
- Red collision edges dominate the canvas.
|
||||
- Labels overlap and fight for attention.
|
||||
- Interventions repeat as a row of identical diamonds.
|
||||
- The right panel says "It learned" but does not explain the actual workflow state.
|
||||
- The screen lacks an activity stream.
|
||||
- The screen lacks the explicit learning-loop rail from the dark mock.
|
||||
- The viewer cannot quickly answer:
|
||||
- What happened?
|
||||
- Who collided?
|
||||
- What did PodMan do?
|
||||
- Did the team accept it?
|
||||
- What changed in memory?
|
||||
|
||||
The current light version proves data plumbing. It does not yet work as a demo
|
||||
surface.
|
||||
|
||||
### Dark Bauhaus Mock
|
||||
|
||||
The dark mock is the quality target. Do not copy the dark palette wholesale, but
|
||||
copy the structure, density, and storytelling.
|
||||
|
||||
The mock has:
|
||||
|
||||
- A strong title bar:
|
||||
`PODMAN / CONTINUAL-LEARNING OBSERVATORY`
|
||||
- A live status indicator:
|
||||
`LIVE - POD demo-pod`
|
||||
- A left metrics rail:
|
||||
workflow metrics as compact, high-contrast cards.
|
||||
- A center graph:
|
||||
sparse, geometric, readable, with one primary path emphasized.
|
||||
- A right learning-loop rail:
|
||||
`Observe -> Store -> Predict -> Outcome -> Adapt`
|
||||
- A bottom activity stream:
|
||||
timestamped events with type badges.
|
||||
- A selected-node detail panel:
|
||||
kind, relationships, severity, explanation.
|
||||
- A legend:
|
||||
node shapes and edge colors.
|
||||
|
||||
The mock works because it is not just a graph. It is an observatory. It tells the
|
||||
loop story.
|
||||
|
||||
## 3. Product Goal
|
||||
|
||||
Team memory should be the "it learned" surface.
|
||||
|
||||
In a 10-second demo, a viewer should understand:
|
||||
|
||||
1. Two engineers are converging on the same file.
|
||||
2. PodMan detected the risk before a push.
|
||||
3. PodMan suggested an intervention.
|
||||
4. The team accepted or dismissed the intervention.
|
||||
5. PodMan retained that outcome as memory.
|
||||
6. Future collisions become more informed.
|
||||
|
||||
The graph should support that story, not overwhelm it.
|
||||
|
||||
## 4. Target Layout
|
||||
|
||||
Build a light shadcn page with the same conceptual structure as the dark mock.
|
||||
|
||||
```text
|
||||
+--------------------------------------------------------------------------+
|
||||
| Header: Team memory - What PodMan learned - <pod> [<- Pods] |
|
||||
+--------------------------------------------------------------------------+
|
||||
| Mode controls: Risk path | Learning edges | Whole graph |
|
||||
+---------------+--------------------------------------+-------------------+
|
||||
| Workflow | | Learning loop |
|
||||
| metrics | Dynamic graph canvas | Observe |
|
||||
| cards | | Store |
|
||||
| | | Predict |
|
||||
| | | Outcome |
|
||||
| | | Adapt |
|
||||
+---------------+--------------------------------------+-------------------+
|
||||
| Activity stream | Selected node details |
|
||||
+--------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
Required panels:
|
||||
|
||||
- **Header**
|
||||
- Pod name / id.
|
||||
- Live/generated timestamp.
|
||||
- Back to pods action.
|
||||
- **Mode controls**
|
||||
- Risk path.
|
||||
- Learning edges.
|
||||
- Whole graph.
|
||||
- **Workflow metrics rail**
|
||||
- Learned owners.
|
||||
- Open risk paths.
|
||||
- Accept rate.
|
||||
- Optional: observations, interventions, memory vectors if available.
|
||||
- **Dynamic graph canvas**
|
||||
- Force-directed or animated layered graph.
|
||||
- Geometric node shapes.
|
||||
- Curved or bundled edges.
|
||||
- Labels should not overlap by default.
|
||||
- Hover/select reveals full details.
|
||||
- **Learning loop rail**
|
||||
- Observe.
|
||||
- Store.
|
||||
- Predict.
|
||||
- Outcome.
|
||||
- Adapt.
|
||||
- Active/current step should pulse or be highlighted.
|
||||
- **Activity stream**
|
||||
- Recent editing, collision, warning, outcome, learned events.
|
||||
- Compact rows with timestamp + colored type badge.
|
||||
- **Selected node**
|
||||
- Default state explains the loop.
|
||||
- Selected state shows node kind, name, relationships, severity/status, and
|
||||
why this node matters.
|
||||
|
||||
## 5. Visual Direction
|
||||
|
||||
Use the app's light shadcn/ruixen design system for chrome.
|
||||
|
||||
Hard rules:
|
||||
|
||||
- Use `@/components/ui/*` primitives where possible.
|
||||
- If a primitive is missing, add it through:
|
||||
`npx shadcn@latest add "https://ruixen.com/r/[component]"`
|
||||
- Do not make the entire UI a bespoke CSS island.
|
||||
- The graph canvas itself may be bespoke SVG/canvas.
|
||||
- The rest should be composed from cards, badges, buttons, tabs/toggles, and
|
||||
utility classes consistent with `App.tsx`.
|
||||
|
||||
Keep semantic graph colors:
|
||||
|
||||
- Engineer: blue.
|
||||
- File: slate outline.
|
||||
- Feature: amber circle.
|
||||
- Collision: red triangle.
|
||||
- Intervention: violet diamond.
|
||||
- `collides`: red edge.
|
||||
- `warns`: amber/orange edge.
|
||||
- `learned_from`: dashed violet edge.
|
||||
- `owns`: blue edge.
|
||||
- `editing` / `touches`: muted slate.
|
||||
|
||||
Use light surfaces:
|
||||
|
||||
- Background: app background token.
|
||||
- Panels: `card`.
|
||||
- Borders: `border`.
|
||||
- Text: `foreground`.
|
||||
- Supporting copy: `muted-foreground`.
|
||||
|
||||
The result should feel like the dark mock translated into the app's light command
|
||||
center, not a random analytics dashboard.
|
||||
|
||||
## 6. Dynamic Graph Requirement
|
||||
|
||||
The current graph is too static. Replace or augment the static column layout.
|
||||
|
||||
Preferred implementation:
|
||||
|
||||
- Use `d3-force` in the frontend.
|
||||
- Initialize nodes from server `x/y` when useful, but let the simulation settle.
|
||||
- Use:
|
||||
- link force by edge strength.
|
||||
- charge force for separation.
|
||||
- center force.
|
||||
- collision force based on node radius.
|
||||
- optional x/y bias by kind to preserve rough story flow.
|
||||
- Make nodes draggable.
|
||||
- Preserve node shape encoding.
|
||||
- Animate:
|
||||
- new nodes fading/scaling in.
|
||||
- new edges drawing/fading in.
|
||||
- `learned_from` dashed edge flowing or pulsing.
|
||||
- active collision/intervention pulse.
|
||||
|
||||
If `d3-force` is too much for the current branch, use an animated layered layout:
|
||||
|
||||
- Engineers left.
|
||||
- Files mid-left.
|
||||
- Collisions center/right.
|
||||
- Interventions right.
|
||||
- Curved edges.
|
||||
- Smooth transitions between graph snapshots.
|
||||
- Gentle idle motion only if it helps.
|
||||
|
||||
Do not leave the final version as static fixed columns.
|
||||
|
||||
## 7. De-Hairball Rules
|
||||
|
||||
Default screen should show the risk path, not every possible relationship.
|
||||
|
||||
Rules:
|
||||
|
||||
- Default mode: `Risk path`.
|
||||
- Whole graph can exist, but it is not the demo default.
|
||||
- Dim non-selected/non-risk edges aggressively.
|
||||
- Use curved edges or edge bundling.
|
||||
- Hide low-priority labels until hover/select.
|
||||
- Prefer file basename/short path on canvas.
|
||||
- Put full path in selected-node panel.
|
||||
- Group repeated collisions by signature.
|
||||
- Cap visible collisions/interventions for demo readability.
|
||||
- Preserve all data in the payload; choose a readable default projection.
|
||||
|
||||
The graph is not an exhaustive database browser. It is a story-first visualization.
|
||||
|
||||
## 8. Data Model To Use
|
||||
|
||||
Current `PodGraph` contract:
|
||||
|
||||
```ts
|
||||
interface PodGraph {
|
||||
podId: string;
|
||||
generatedAt: string;
|
||||
nodes: PodGraphNode[];
|
||||
edges: PodGraphEdge[];
|
||||
metrics: PodGraphMetric[];
|
||||
}
|
||||
```
|
||||
|
||||
Node kinds:
|
||||
|
||||
- `engineer`
|
||||
- `file`
|
||||
- `feature`
|
||||
- `collision`
|
||||
- `intervention`
|
||||
|
||||
Edge kinds:
|
||||
|
||||
- `owns`
|
||||
- `editing`
|
||||
- `touches`
|
||||
- `collides`
|
||||
- `warns`
|
||||
- `learned_from`
|
||||
|
||||
Statuses:
|
||||
|
||||
- `stable`
|
||||
- `active`
|
||||
- `risk`
|
||||
- `learned`
|
||||
|
||||
Existing collections behind the materializer:
|
||||
|
||||
- `pods`
|
||||
- `engineer_states`
|
||||
- `observations`
|
||||
- `collisions`
|
||||
- `interventions`
|
||||
- `outcomes`
|
||||
|
||||
Important caveat:
|
||||
|
||||
The current `demo-pod` accepted outcome chain may be orphaned from test churn.
|
||||
If `learned_from` does not show, confirm whether there is an intact:
|
||||
|
||||
```text
|
||||
collision -> intervention -> accepted outcome
|
||||
```
|
||||
|
||||
Do not assume the UI is broken until this data chain is verified.
|
||||
|
||||
## 9. Extend Data For Missing Panels
|
||||
|
||||
The current graph contract does not fully support the dark mock's learning-loop
|
||||
rail or activity stream.
|
||||
|
||||
Recommended additive extension:
|
||||
|
||||
```ts
|
||||
interface PodGraphLoopStep {
|
||||
id: 'observe' | 'store' | 'predict' | 'outcome' | 'adapt';
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
status: 'idle' | 'active' | 'complete';
|
||||
}
|
||||
|
||||
interface PodGraphActivity {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: 'editing' | 'collision' | 'warns' | 'outcome' | 'learned_from';
|
||||
label: string;
|
||||
detail: string;
|
||||
nodeId?: string;
|
||||
edgeId?: string;
|
||||
}
|
||||
|
||||
interface PodGraph {
|
||||
...
|
||||
loop?: PodGraphLoopStep[];
|
||||
activity?: PodGraphActivity[];
|
||||
}
|
||||
```
|
||||
|
||||
Possible data mappings:
|
||||
|
||||
- Observe:
|
||||
recent `observations`.
|
||||
- Store:
|
||||
stored observations / vectorized collisions / memory documents.
|
||||
- Predict:
|
||||
distinct live collisions.
|
||||
- Outcome:
|
||||
accepted vs dismissed outcomes.
|
||||
- Adapt:
|
||||
learned owners / `learned_from` edges / `team_model.ownership`.
|
||||
|
||||
Activity stream source:
|
||||
|
||||
- `engineer_states.gitUpdatedAt` -> editing/git state.
|
||||
- `collisions.detectedAt` -> collision.
|
||||
- `interventions.createdAt` -> warns/intervention.
|
||||
- `outcomes.recordedAt` -> outcome.
|
||||
- accepted real outcome -> learned_from/adapt event.
|
||||
|
||||
Cap activity rows to 8-10.
|
||||
|
||||
## 10. Suggested Implementation Plan
|
||||
|
||||
1. Create a new branch from the current graph branch or latest `main`.
|
||||
2. Read:
|
||||
- this file.
|
||||
- `claude_team_memory_redesign.md`.
|
||||
- `docs/graph.md`.
|
||||
- `docs/live-ui-spec.md` if present.
|
||||
- `frontend/src/components/GraphView.tsx`.
|
||||
- `backend/src/graph/live.ts`.
|
||||
3. Add graph UI subcomponents:
|
||||
- `MetricsRail`.
|
||||
- `GraphCanvas`.
|
||||
- `LearningLoopRail`.
|
||||
- `ActivityStream`.
|
||||
- `SelectedNodePanel`.
|
||||
4. Implement the dynamic graph canvas first.
|
||||
5. Add the learning-loop rail and activity stream.
|
||||
6. Polish interaction states:
|
||||
- hover.
|
||||
- selected node.
|
||||
- selected edge/path.
|
||||
- empty/live-loading/offline.
|
||||
7. Verify with local live data.
|
||||
8. Capture screenshots at desktop and narrow widths.
|
||||
9. Run:
|
||||
- `pnpm build` or local `vite build`.
|
||||
- `tsc` for shared/backend/frontend.
|
||||
10. Open a PR. Do not push directly to `main`.
|
||||
|
||||
## 11. Acceptance Criteria
|
||||
|
||||
The redesign is acceptable only when:
|
||||
|
||||
- The default view is readable in 10 seconds.
|
||||
- The graph is dynamic, not static columns.
|
||||
- It includes metrics, graph, learning-loop rail, activity stream, selected-node
|
||||
panel, and legend.
|
||||
- The primary risk path is obvious.
|
||||
- Labels do not overlap in the default view.
|
||||
- Whole graph mode exists but can be visually denser.
|
||||
- It uses real data from the materializer.
|
||||
- It remains composed from light shadcn/ruixen primitives where possible.
|
||||
- It builds successfully.
|
||||
- It is verified after a hard refresh because the PWA can cache stale bundles.
|
||||
|
||||
## 12. What Not To Do
|
||||
|
||||
- Do not make a marketing page.
|
||||
- Do not make a generic dashboard.
|
||||
- Do not rewrite the backend materializer unless the UI needs a small additive
|
||||
field.
|
||||
- Do not return to the dark UI wholesale.
|
||||
- Do not keep the static column layout as the final answer.
|
||||
- Do not show raw full paths as always-on canvas labels.
|
||||
- Do not show every edge at equal opacity.
|
||||
- Do not hide the learning loop in copy only; it needs a visible rail or panel.
|
||||
|
||||
## 13. Demo Script The UI Should Support
|
||||
|
||||
The final UI should support this story:
|
||||
|
||||
1. Engineer A and Engineer B work in the same repo.
|
||||
2. One has unpushed changes.
|
||||
3. PodMan observes the overlap.
|
||||
4. A collision node appears and pulses.
|
||||
5. PodMan sends a sync PR / warning intervention.
|
||||
6. The intervention diamond appears.
|
||||
7. The team accepts.
|
||||
8. The outcome appears in the activity stream.
|
||||
9. A `learned_from` edge appears or pulses.
|
||||
10. The learning-loop rail advances to Adapt.
|
||||
|
||||
That is the recursive self-improvement moment. Everything else is supporting
|
||||
evidence.
|
||||
|
||||
## 14. Open Questions For The Implementer
|
||||
|
||||
- Should dynamic layout be `d3-force` or animated layered SVG?
|
||||
- Should loop/activity be added to `PodGraph` or exposed as separate endpoints?
|
||||
- Should the demo seed one intact accepted outcome chain?
|
||||
- Should `Whole graph` be hidden behind an explicit "inspect full graph" affordance?
|
||||
- Should mobile show a simplified activity-first version instead of the full graph?
|
||||
|
||||
Answer these in code comments or PR notes when implementing.
|
||||
|
||||
## 15. Final Reminder
|
||||
|
||||
The backend now has real graph glue. The UI needs to become a **live learning
|
||||
observatory**, not a static graph dump.
|
||||
|
||||
Make the light version earn the same reaction as the dark Bauhaus mock:
|
||||
|
||||
```text
|
||||
I can see what happened.
|
||||
I can see what PodMan did.
|
||||
I can see what it learned.
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# Agent Learning Plan
|
||||
|
||||
Status: draft
|
||||
Goal: ship a visible recursive self-improvement loop without overbuilding
|
||||
|
||||
## Must-Have
|
||||
|
||||
1. Store agent runs.
|
||||
2. Store trace summaries.
|
||||
3. Store active and candidate strategy versions.
|
||||
4. Attach verifier or outcome evidence.
|
||||
5. Show one strategy improvement in the demo narrative.
|
||||
|
||||
## Build Order
|
||||
|
||||
### R1: Trace the run
|
||||
|
||||
Write one `agent_runs` record for an important coordination decision and append
|
||||
trace events for:
|
||||
|
||||
- observation
|
||||
- recall
|
||||
- prediction
|
||||
- intervention
|
||||
- outcome
|
||||
- adaptation
|
||||
|
||||
### R2: Version the strategy
|
||||
|
||||
Create an active strategy version for one of:
|
||||
|
||||
- collision detector threshold
|
||||
- intervention routing
|
||||
- graph discovery filter
|
||||
- card wording prompt
|
||||
|
||||
### R3: Score the outcome
|
||||
|
||||
Use the simplest verifier:
|
||||
|
||||
- accepted real collision = useful
|
||||
- dismissed = noisy
|
||||
- no response after cooldown = uncertain
|
||||
|
||||
### R4: Propose a narrow change
|
||||
|
||||
Examples:
|
||||
|
||||
- "For this exact signature, prefer sync PR card."
|
||||
- "For dismissed docs-only overlaps, suppress voice escalation."
|
||||
- "For repeated auth.ts collisions, raise severity."
|
||||
|
||||
### R5: Promote or reject
|
||||
|
||||
Promote only when evidence is strong enough. Otherwise keep the candidate as
|
||||
rejected or open.
|
||||
|
||||
## Demo Path
|
||||
|
||||
1. Show baseline strategy.
|
||||
2. Trigger a collision.
|
||||
3. Accept or dismiss the intervention.
|
||||
4. Store outcome.
|
||||
5. Show a candidate strategy update.
|
||||
6. Promote it.
|
||||
7. Trigger a similar event.
|
||||
8. Show changed behavior.
|
||||
|
||||
## Nice-to-Have
|
||||
|
||||
- Strategy comparison panel.
|
||||
- Model-generated prompt patch with verifier.
|
||||
- Vector recall over strategy history.
|
||||
- Rollback UI.
|
||||
|
||||
## Cut
|
||||
|
||||
- Full autonomous code rewriting.
|
||||
- Multi-agent strategy debates.
|
||||
- Long-term benchmark suite.
|
||||
- Training a model.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- The demo can point to a MongoDB record proving the agent changed behavior.
|
||||
- The changed behavior is visible.
|
||||
- The strategy has a parent and evidence.
|
||||
- Rejected or failed changes are not deleted.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Agent Learning Policy
|
||||
|
||||
Status: draft
|
||||
Scope: guardrails for recursive self-improvement
|
||||
|
||||
## Prime Rule
|
||||
|
||||
PodMan may improve its agent behavior only when the improvement is narrow,
|
||||
evidence-backed, versioned, and reversible.
|
||||
|
||||
## Allowed Learning
|
||||
|
||||
PodMan may learn:
|
||||
|
||||
- Which prompt version produces clearer interventions.
|
||||
- Which detector threshold reduces false positives.
|
||||
- Which routing channel gets accepted without being intrusive.
|
||||
- Which verifier best predicts user acceptance.
|
||||
- Which graph-discovery rule produces cleaner risk paths.
|
||||
|
||||
## Disallowed Learning
|
||||
|
||||
PodMan must not:
|
||||
|
||||
- Promote a strategy because the model says it is better.
|
||||
- Rewrite broad system behavior from one example.
|
||||
- Hide failures, dismissals, or rejected candidates.
|
||||
- Learn from raw screenshots, secrets, or private terminal content.
|
||||
- Turn voice into the default route.
|
||||
- Create irreversible actions without human approval.
|
||||
|
||||
## Promotion Rules
|
||||
|
||||
A candidate strategy can become active only when all are true:
|
||||
|
||||
1. It has a parent strategy version.
|
||||
2. It describes one concrete behavior change.
|
||||
3. It has a verifier plan.
|
||||
4. It has evidence from a run, outcome, or test.
|
||||
5. It improves or fixes the target metric.
|
||||
6. It does not increase user interruption without payoff.
|
||||
|
||||
## Rejection Rules
|
||||
|
||||
Reject and retain the candidate when:
|
||||
|
||||
- The verifier regresses.
|
||||
- The change is too broad.
|
||||
- The evidence is missing.
|
||||
- The candidate conflicts with privacy rules.
|
||||
- The candidate makes the demo less stable.
|
||||
|
||||
## Evidence Strength
|
||||
|
||||
| Evidence | Strength | Use |
|
||||
| --- | --- | --- |
|
||||
| Model opinion | Weak | Proposal only |
|
||||
| Trace observation | Medium | Candidate rationale |
|
||||
| Human accepted outcome | Strong | Promotion candidate |
|
||||
| Human dismissed outcome | Strong | Suppression or rejection |
|
||||
| Automated verifier | Strong | Promotion or rejection |
|
||||
| Repeated accepted exact signature | Strong | Policy confidence increase |
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- Strategy versions are immutable after promotion or rejection.
|
||||
- There is one active version per `podId + kind`.
|
||||
- A rollback activates the previous version; it does not edit history.
|
||||
- Parent-child lineage must be preserved.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Store summaries, not raw sensitive content.
|
||||
- Prefer deterministic checks over model judgment.
|
||||
- Use exact MongoDB recall before vector recall.
|
||||
- Ask for approval before changing code or data with external effects.
|
||||
- Treat hackathon demo stability as a hard constraint.
|
||||
|
||||
## Demo Honesty
|
||||
|
||||
Seeded strategy versions are acceptable when labeled as demo-backed. Do not claim
|
||||
a strategy was learned live unless a run and outcome actually created the
|
||||
promotion evidence.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Agent Learning Prompt
|
||||
|
||||
Use this prompt for an agent responsible for improving PodMan's own behavior.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are PodMan's agent-learning evaluator.
|
||||
|
||||
Your job is to inspect a completed agent run, identify one narrow improvement,
|
||||
define how to verify it, and decide whether to propose, promote, or reject a
|
||||
strategy change.
|
||||
|
||||
You must not claim improvement without evidence. You must not propose broad
|
||||
rewrites. Keep every change small, reversible, and tied to a run or outcome.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Current active strategy version.
|
||||
- Agent run summary.
|
||||
- Trace events.
|
||||
- Intervention outcome.
|
||||
- Verifier result.
|
||||
- Recent false positives or accepted events.
|
||||
- Current demo constraints.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Identify the target behavior.
|
||||
2. Identify the failure or success evidence.
|
||||
3. Decide whether a strategy change is warranted.
|
||||
4. Propose one narrow change.
|
||||
5. Define the verifier.
|
||||
6. Decide status: no change, candidate, promote, reject.
|
||||
7. Write a short explanation suitable for the Team memory activity stream.
|
||||
|
||||
## Output Format
|
||||
|
||||
```text
|
||||
Target
|
||||
- Strategy kind:
|
||||
- Active version:
|
||||
- Behavior under review:
|
||||
|
||||
Evidence
|
||||
- Run:
|
||||
- Outcome:
|
||||
- Verifier:
|
||||
- Confidence:
|
||||
|
||||
Decision
|
||||
- Status:
|
||||
- Proposed change:
|
||||
- Why this is narrow:
|
||||
- Risk:
|
||||
|
||||
Verifier
|
||||
- Metric:
|
||||
- Passing condition:
|
||||
- Failing condition:
|
||||
|
||||
Memory Write
|
||||
- Collection:
|
||||
- Record summary:
|
||||
- Graph/activity summary:
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Exact outcomes beat model opinion.
|
||||
- Rejected candidates stay in memory.
|
||||
- No raw screenshots or secrets.
|
||||
- No broad policy change from one weak signal.
|
||||
- No voice-first behavior.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Agent Learning Spec
|
||||
|
||||
Status: draft
|
||||
Scope: how PodMan agents improve their own prompts, policies, detectors, and routing behavior
|
||||
Owner: agent learning / recursive self-improvement
|
||||
|
||||
## Purpose
|
||||
|
||||
Agent learning is the recursive self-improvement layer. It is not the same as
|
||||
team memory. Team memory learns about engineers and work. Agent learning learns
|
||||
which agent strategies produce better outcomes.
|
||||
|
||||
The demo claim:
|
||||
|
||||
1. PodMan tries a coordination strategy.
|
||||
2. The run is traced in MongoDB.
|
||||
3. A verifier or human outcome scores it.
|
||||
4. Gemini or another agent proposes a narrow strategy change.
|
||||
5. The new strategy is versioned.
|
||||
6. A later run uses the improved strategy and shows a better result.
|
||||
|
||||
## Core Objects
|
||||
|
||||
### Agent run
|
||||
|
||||
One attempt to execute a goal.
|
||||
|
||||
```text
|
||||
agent_runs
|
||||
runId
|
||||
podId
|
||||
goal
|
||||
trigger
|
||||
strategyVersionId
|
||||
status
|
||||
startedAt
|
||||
completedAt
|
||||
score
|
||||
verifierSummary
|
||||
inputRefs
|
||||
outputRefs
|
||||
```
|
||||
|
||||
Allowed `status` values:
|
||||
|
||||
```text
|
||||
running, succeeded, failed, improved, regressed, abandoned
|
||||
```
|
||||
|
||||
### Trace event
|
||||
|
||||
Append-only event log for a run.
|
||||
|
||||
```text
|
||||
agent_trace_events
|
||||
runId
|
||||
podId
|
||||
step
|
||||
phase
|
||||
eventType
|
||||
inputSummary
|
||||
outputSummary
|
||||
toolName
|
||||
error
|
||||
metrics
|
||||
createdAt
|
||||
```
|
||||
|
||||
### Strategy version
|
||||
|
||||
Versioned prompt, detector rule, policy, verifier, or routing strategy.
|
||||
|
||||
```text
|
||||
strategy_versions
|
||||
strategyVersionId
|
||||
podId
|
||||
kind
|
||||
name
|
||||
parentVersionId
|
||||
status
|
||||
summary
|
||||
promptText
|
||||
policy
|
||||
verifier
|
||||
metrics
|
||||
createdAt
|
||||
promotedAt
|
||||
```
|
||||
|
||||
Allowed `kind` values:
|
||||
|
||||
```text
|
||||
prompt, policy, detector, verifier, routing
|
||||
```
|
||||
|
||||
Allowed `status` values:
|
||||
|
||||
```text
|
||||
candidate, active, retired, rejected
|
||||
```
|
||||
|
||||
### Learning proposal
|
||||
|
||||
A candidate change before promotion.
|
||||
|
||||
```text
|
||||
learning_proposals
|
||||
proposalId
|
||||
podId
|
||||
sourceRunId
|
||||
targetKind
|
||||
parentVersionId
|
||||
proposedChange
|
||||
rationale
|
||||
verifierPlan
|
||||
status
|
||||
createdAt
|
||||
resolvedAt
|
||||
```
|
||||
|
||||
Allowed `status` values:
|
||||
|
||||
```text
|
||||
open, accepted, rejected, superseded
|
||||
```
|
||||
|
||||
## MongoDB Indexes
|
||||
|
||||
| Collection | Index | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `agent_runs` | `{ podId: 1, startedAt: -1 }` | Recent run history |
|
||||
| `agent_runs` | `{ podId: 1, strategyVersionId: 1 }` | Compare strategy performance |
|
||||
| `agent_trace_events` | `{ runId: 1, step: 1 }` | Reconstruct run |
|
||||
| `strategy_versions` | `{ podId: 1, kind: 1, status: 1 }` | Find active strategy |
|
||||
| `strategy_versions` | `{ podId: 1, createdAt: -1 }` | Version history |
|
||||
| `learning_proposals` | `{ podId: 1, status: 1 }` | Open candidate changes |
|
||||
|
||||
## Learning Loop
|
||||
|
||||
```text
|
||||
observe run -> score run -> propose change -> test candidate -> promote or reject
|
||||
```
|
||||
|
||||
Agent learning must always connect these records:
|
||||
|
||||
```text
|
||||
agent_run -> trace_events -> verifier result -> learning_proposal -> strategy_version
|
||||
```
|
||||
|
||||
## Verifier Contract
|
||||
|
||||
Every promoted strategy needs a verifier signal.
|
||||
|
||||
Allowed verifier types:
|
||||
|
||||
- Human accepted or dismissed outcome.
|
||||
- Test pass or fail result.
|
||||
- Reduced false positive rate.
|
||||
- Reduced intervention count with same or better accepted outcomes.
|
||||
- Faster successful run.
|
||||
- Better graph discovery precision.
|
||||
- Explicit demo operator approval.
|
||||
|
||||
Self-evaluation alone is not enough to promote a strategy.
|
||||
|
||||
## Relationship to Team Graph
|
||||
|
||||
Agent learning can appear in the Team memory graph as activity and loop status,
|
||||
but it should not clutter the main risk graph by default.
|
||||
|
||||
Graph discovery may show:
|
||||
|
||||
- `agent_run` activity in the stream.
|
||||
- `strategy_versions` count in the learning loop.
|
||||
- A selected-node detail saying a policy changed because a prior outcome was
|
||||
dismissed or accepted.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Every strategy change has a parent.
|
||||
- Every promoted strategy cites evidence.
|
||||
- Rejected strategies are retained with a reason.
|
||||
- Agent traces are append-only.
|
||||
- The system can answer: "What changed, why, and did it help?"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Continual Learning Plan
|
||||
|
||||
Status: draft
|
||||
Goal: prove PodMan learns from outcomes in the hackathon demo
|
||||
|
||||
## Must-Have Demo Loop
|
||||
|
||||
1. Observe two engineers touching the same file.
|
||||
2. Store the observation and git state in MongoDB.
|
||||
3. Predict a collision.
|
||||
4. Send a card or Hermes message.
|
||||
5. Record accept or dismiss outcome.
|
||||
6. Adapt `team_model`.
|
||||
7. Show the learned graph edge or changed future behavior.
|
||||
|
||||
## Build Order
|
||||
|
||||
### R1: Make exact recall reliable
|
||||
|
||||
- Normalize file paths.
|
||||
- Build stable memory signatures.
|
||||
- Look up prior accepted and dismissed outcomes.
|
||||
- Prefer exact recall over vector recall.
|
||||
|
||||
### R2: Make outcomes update memory
|
||||
|
||||
- Accepted real collision creates or strengthens ownership.
|
||||
- Accepted real collision creates `learned_from`.
|
||||
- Dismissed outcome lowers confidence or suppresses route.
|
||||
|
||||
### R3: Expose loop data to the graph
|
||||
|
||||
- Add optional loop snapshot.
|
||||
- Add optional activity stream.
|
||||
- Keep existing `PodGraph` fields stable.
|
||||
|
||||
### R4: Show the observatory
|
||||
|
||||
- Render observe/store/predict/outcome/adapt.
|
||||
- Show recent activity.
|
||||
- Make selected-node detail explain why memory changed.
|
||||
|
||||
### R5: Prepare a clean demo chain
|
||||
|
||||
- Ensure one collision -> intervention -> accepted outcome exists.
|
||||
- Ensure repeated signature recalls prior memory.
|
||||
- Verify graph shows learned ownership.
|
||||
|
||||
## Nice-to-Have
|
||||
|
||||
- Atlas Vector Search over memory summaries.
|
||||
- Confidence scoring per ownership edge.
|
||||
- Per-file memory timeline.
|
||||
- Strategy promotion tied to outcomes.
|
||||
|
||||
## Cut
|
||||
|
||||
- Raw screenshot storage.
|
||||
- Full autonomous training.
|
||||
- Broad dashboard metrics.
|
||||
- Multi-pod learning generalization.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A judge can see what changed in memory.
|
||||
- The second similar event behaves differently.
|
||||
- Exact MongoDB records prove the loop.
|
||||
- The graph remains legible with real data.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Continual Learning Policy
|
||||
|
||||
Status: draft
|
||||
Scope: what PodMan may learn about a team
|
||||
|
||||
## Prime Rule
|
||||
|
||||
PodMan learns coordination patterns, not personal surveillance profiles.
|
||||
|
||||
## Allowed Memory
|
||||
|
||||
PodMan may store:
|
||||
|
||||
- File and symbol ownership.
|
||||
- Active file overlap.
|
||||
- Repeated collision signatures.
|
||||
- Intervention history.
|
||||
- Accepted and dismissed outcomes.
|
||||
- Routing preferences by event type and severity.
|
||||
- Summaries of decisions relevant to future coordination.
|
||||
|
||||
## Forbidden Memory
|
||||
|
||||
PodMan must not store:
|
||||
|
||||
- Raw screenshots.
|
||||
- Screen recordings.
|
||||
- Secrets or credentials.
|
||||
- Full terminal logs.
|
||||
- Personal performance judgments.
|
||||
- Private content unrelated to the coding task.
|
||||
|
||||
## Evidence Policy
|
||||
|
||||
| Evidence | Can predict? | Can adapt memory? |
|
||||
| --- | --- | --- |
|
||||
| Vision only | Yes, low confidence | No |
|
||||
| Git watcher | Yes | No, unless repeated |
|
||||
| GitHub state | Yes | No, unless verified |
|
||||
| Accepted real outcome | Yes | Yes |
|
||||
| Dismissed outcome | Yes, for suppression | Yes, as negative signal |
|
||||
| Verifier result | Yes | Yes |
|
||||
|
||||
## Intervention Policy
|
||||
|
||||
Use the least intrusive channel:
|
||||
|
||||
1. Watch quietly.
|
||||
2. Card.
|
||||
3. Hermes message.
|
||||
4. Voice.
|
||||
|
||||
Voice is only for urgent, high-confidence, time-sensitive risks.
|
||||
|
||||
## Adaptation Policy
|
||||
|
||||
Allowed adaptations:
|
||||
|
||||
- Add learned ownership after accepted real outcome.
|
||||
- Raise confidence for repeated accepted signatures.
|
||||
- Lower confidence for dismissed signatures.
|
||||
- Prefer the previously accepted intervention kind.
|
||||
- Suppress repeated low-value warnings.
|
||||
|
||||
Disallowed adaptations:
|
||||
|
||||
- Broad threshold changes from one example.
|
||||
- Treating vector similarity as proof.
|
||||
- Hiding dismissals.
|
||||
- Making interruption more aggressive without evidence.
|
||||
|
||||
## Retention Policy
|
||||
|
||||
Keep:
|
||||
|
||||
- Outcomes.
|
||||
- Signatures.
|
||||
- Team model memory.
|
||||
- Strategy metrics.
|
||||
|
||||
Summarize or expire:
|
||||
|
||||
- Old observations.
|
||||
- Low-confidence vision-only events.
|
||||
- Detailed trace text.
|
||||
|
||||
Delete immediately:
|
||||
|
||||
- Secrets.
|
||||
- Accidental raw sensitive captures.
|
||||
|
||||
## Demo Policy
|
||||
|
||||
Seeded data is acceptable only if the demo script is honest about it. Live
|
||||
learning requires a live or staged outcome write that visibly updates the graph
|
||||
or future decision.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Continual Learning Prompt
|
||||
|
||||
Use this prompt for the agent that decides what PodMan should remember from a
|
||||
coordination event.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are PodMan's continual-learning memory agent.
|
||||
|
||||
Your job is to inspect observations, collisions, interventions, and outcomes,
|
||||
then decide what team memory should be updated. You must separate observed
|
||||
facts, inferred risks, human outcomes, and durable learned memory.
|
||||
|
||||
Do not claim something was learned unless an accepted real outcome, verifier, or
|
||||
human label supports it.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Pod id.
|
||||
- Recent engineer states.
|
||||
- Recent observations.
|
||||
- Candidate collision.
|
||||
- Prior exact-signature memory.
|
||||
- Intervention record.
|
||||
- Outcome record.
|
||||
- Current team model.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Normalize file and symbol.
|
||||
2. Build exact signature.
|
||||
3. Check prior accepted and dismissed outcomes.
|
||||
4. Classify the current event.
|
||||
5. Decide whether memory should change.
|
||||
6. Emit the graph impact.
|
||||
7. Write a short explanation.
|
||||
|
||||
## Output Format
|
||||
|
||||
```text
|
||||
Event
|
||||
- Signature:
|
||||
- Engineers:
|
||||
- File:
|
||||
- Symbol:
|
||||
- Evidence:
|
||||
|
||||
Prior Memory
|
||||
- Accepted matches:
|
||||
- Dismissed matches:
|
||||
- Ownership:
|
||||
|
||||
Decision
|
||||
- Memory action:
|
||||
- Confidence:
|
||||
- Reason:
|
||||
|
||||
Graph Impact
|
||||
- Nodes:
|
||||
- Edges:
|
||||
- Activity text:
|
||||
|
||||
Safety
|
||||
- Sensitive data present:
|
||||
- Redaction needed:
|
||||
```
|
||||
|
||||
## Memory Actions
|
||||
|
||||
Allowed actions:
|
||||
|
||||
- no_change
|
||||
- strengthen_signature
|
||||
- weaken_signature
|
||||
- create_learned_owner
|
||||
- update_route_preference
|
||||
- suppress_signature
|
||||
- request_human_label
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Exact recall before vector recall.
|
||||
- Dismissals are learning signals.
|
||||
- `learned_from` requires accepted real outcome.
|
||||
- Store summaries, not raw screen content.
|
||||
- Prefer less intrusive future behavior when uncertain.
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# Continual Learning Spec
|
||||
|
||||
Status: draft
|
||||
Scope: how PodMan learns team memory from live work and outcomes
|
||||
Owner: continual learning / Team memory
|
||||
|
||||
## Purpose
|
||||
|
||||
Continual learning is the product proof that PodMan gets more useful from use.
|
||||
It learns team-level coordination memory: ownership, repeated collisions,
|
||||
accepted interventions, dismissed noise, and preferred routing.
|
||||
|
||||
The visible loop:
|
||||
|
||||
```text
|
||||
observe -> store -> predict -> outcome -> adapt
|
||||
```
|
||||
|
||||
## Source Collections
|
||||
|
||||
### `engineer_states`
|
||||
|
||||
Latest per-engineer state from vision and local git.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `name`
|
||||
- `currentFile`
|
||||
- `changedFiles`
|
||||
- `branch`
|
||||
- `confidence`
|
||||
- `visionUpdatedAt`
|
||||
- `gitUpdatedAt`
|
||||
- `updatedAt`
|
||||
|
||||
### `observations`
|
||||
|
||||
Structured perception events.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `engineerId`
|
||||
- `currentFile`
|
||||
- `symbol`
|
||||
- `activity`
|
||||
- `confidence`
|
||||
- `observedAt`
|
||||
|
||||
### `collisions`
|
||||
|
||||
Predicted risk events.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `id`
|
||||
- `podId`
|
||||
- `file`
|
||||
- `symbol`
|
||||
- `engineers`
|
||||
- `severity`
|
||||
- `status`
|
||||
- `memorySignature`
|
||||
- `detectedAt`
|
||||
|
||||
### `interventions`
|
||||
|
||||
Actions PodMan sent or suggested.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `id`
|
||||
- `podId`
|
||||
- `collisionId`
|
||||
- `kind`
|
||||
- `channel`
|
||||
- `message`
|
||||
- `suggestedAction`
|
||||
- `createdAt`
|
||||
|
||||
### `outcomes`
|
||||
|
||||
Human or verifier supervision.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `id`
|
||||
- `podId`
|
||||
- `interventionId`
|
||||
- `collisionId`
|
||||
- `accepted`
|
||||
- `wasRealCollision`
|
||||
- `learnedOwner`
|
||||
- `recordedAt`
|
||||
|
||||
### `team_model`
|
||||
|
||||
Durable pod memory.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `graph`
|
||||
- `ownership`
|
||||
- `collisionSignatures`
|
||||
- `interventionPolicy`
|
||||
- `updatedAt`
|
||||
|
||||
### `memory_vectors`
|
||||
|
||||
Optional semantic recall. Exact recall comes first.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `sourceKind`
|
||||
- `sourceId`
|
||||
- `text`
|
||||
- `embedding`
|
||||
- `embeddingModel`
|
||||
- `tags`
|
||||
|
||||
## Learning Rules
|
||||
|
||||
### Observe
|
||||
|
||||
Write structured evidence from vision, git, GitHub, and agent traces.
|
||||
|
||||
### Store
|
||||
|
||||
Persist source records and materialized summaries. Do not store raw screenshots
|
||||
or recordings.
|
||||
|
||||
### Predict
|
||||
|
||||
Create a collision when multiple engineers converge on the same normalized file
|
||||
or symbol and at least one signal shows active or unpushed work.
|
||||
|
||||
### Outcome
|
||||
|
||||
Record whether the intervention was accepted, dismissed, real, or false.
|
||||
|
||||
### Adapt
|
||||
|
||||
Only accepted real outcomes can create `learned_from` graph edges. Dismissals
|
||||
adapt suppression, routing, or confidence.
|
||||
|
||||
## Exact Signature
|
||||
|
||||
Use deterministic signatures:
|
||||
|
||||
```text
|
||||
podId:eventType:normalizedFile:symbol:sortedEngineers
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Sort engineer names.
|
||||
- Normalize file paths.
|
||||
- Use `*` for missing symbol.
|
||||
- Never include timestamps.
|
||||
|
||||
## UI-Facing Loop Snapshot
|
||||
|
||||
The graph response may include:
|
||||
|
||||
```text
|
||||
loop
|
||||
activeStep
|
||||
steps[]
|
||||
key
|
||||
label
|
||||
value
|
||||
detail
|
||||
status
|
||||
```
|
||||
|
||||
Step mapping:
|
||||
|
||||
| Step | Source |
|
||||
| --- | --- |
|
||||
| Observe | recent observations and git updates |
|
||||
| Store | team model, graph records, memory vectors |
|
||||
| Predict | open collisions |
|
||||
| Outcome | accepted and dismissed outcomes |
|
||||
| Adapt | learned owners, learned edges, strategy changes |
|
||||
|
||||
## Activity Stream
|
||||
|
||||
The graph response may include:
|
||||
|
||||
```text
|
||||
activity[]
|
||||
id
|
||||
at
|
||||
kind
|
||||
title
|
||||
detail
|
||||
nodeId
|
||||
edgeId
|
||||
```
|
||||
|
||||
Allowed `kind` values:
|
||||
|
||||
```text
|
||||
editing, collision, intervention, outcome, learned, agent
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- The system can show one accepted outcome changing future memory.
|
||||
- Exact recall works without vector search.
|
||||
- The Team memory graph can explain the learning loop.
|
||||
- Dismissals and false positives are retained.
|
||||
- The demo does not rely on raw screenshots or hidden state.
|
||||
|
||||
@@ -95,6 +95,17 @@ Build once:
|
||||
docker build -f infra/Dockerfile -t podman-backend .
|
||||
```
|
||||
|
||||
Or use the repository script and verifier:
|
||||
|
||||
```bash
|
||||
pnpm build:container
|
||||
pnpm verify:containers
|
||||
```
|
||||
|
||||
`verify:containers` uses Docker by default to match `build:container`. To verify
|
||||
against a Podman image store instead, run
|
||||
`VERIFY_CONTAINER_RUNTIME=podman pnpm verify:containers`.
|
||||
|
||||
The image entrypoint runs `node backend/dist/server.js` when
|
||||
`PODMAN_PROCESS=server`, and `node backend/dist/agent.js` when
|
||||
`PODMAN_PROCESS=agent`. Do not run the combined Hermes supervisor inside App
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Graph Discovery Plan
|
||||
|
||||
Status: draft
|
||||
Goal: make MongoDB graph discovery visible as a dynamic learning observatory
|
||||
|
||||
## Must-Have
|
||||
|
||||
1. Keep live materializer as source of graph truth.
|
||||
2. Add optional loop and activity fields.
|
||||
3. Build a dynamic graph layout.
|
||||
4. Default to risk path.
|
||||
5. Make selected-node detail explain the story.
|
||||
|
||||
## Build Order
|
||||
|
||||
### R1: Stabilize discovered graph
|
||||
|
||||
- Keep file and engineer noise filters.
|
||||
- Keep collision collapse.
|
||||
- Keep priority for accepted-outcome paths.
|
||||
- Keep graph size capped.
|
||||
|
||||
### R2: Add observatory data
|
||||
|
||||
- Compute learning-loop snapshot.
|
||||
- Compute activity stream.
|
||||
- Preserve current graph contract.
|
||||
|
||||
### R3: Improve path selection
|
||||
|
||||
- Pick one primary risk path.
|
||||
- Include learned path when present.
|
||||
- Dim unrelated collisions and repeated interventions.
|
||||
|
||||
### R4: Render dynamically
|
||||
|
||||
- Use `d3-force` or animated layered layout.
|
||||
- Make nodes draggable.
|
||||
- Curve or bundle edges.
|
||||
- Animate `learned_from`.
|
||||
|
||||
### R5: Verify with real data
|
||||
|
||||
- Fetch live `demo-pod` graph.
|
||||
- Confirm labels do not collide badly.
|
||||
- Confirm red edges do not dominate.
|
||||
- Confirm activity and loop explain the graph.
|
||||
|
||||
## Nice-to-Have
|
||||
|
||||
- Reachability panel using `$graphLookup`.
|
||||
- Hover path previews.
|
||||
- Edge bundling by file or collision.
|
||||
- Time scrubber for graph snapshots.
|
||||
|
||||
## Cut
|
||||
|
||||
- Generic analytics dashboard.
|
||||
- Large graph database migration.
|
||||
- Rendering every historical event.
|
||||
- Static fixed-column final layout.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Risk path is obvious in 10 seconds.
|
||||
- Learned path is visible when data exists.
|
||||
- Whole graph mode exists but is not the default.
|
||||
- The graph remains backed by MongoDB, not hardcoded mock data.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Graph Discovery Policy
|
||||
|
||||
Status: draft
|
||||
Scope: graph hygiene, evidence thresholds, and UI truthfulness
|
||||
|
||||
## Prime Rule
|
||||
|
||||
The graph must be sparse enough to explain the learning loop and truthful enough
|
||||
to audit from MongoDB.
|
||||
|
||||
## Node Policy
|
||||
|
||||
Create nodes only when they add explanation value.
|
||||
|
||||
Allowed:
|
||||
|
||||
- Current engineers.
|
||||
- Real files.
|
||||
- Current or recent collisions.
|
||||
- Interventions tied to surviving collisions.
|
||||
- Learned ownership paths.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Test engineers.
|
||||
- Scratch files.
|
||||
- URLs or environment values misread as files.
|
||||
- Repeated identical intervention diamonds.
|
||||
- Orphan nodes with no story value.
|
||||
|
||||
## Edge Policy
|
||||
|
||||
Edges need evidence.
|
||||
|
||||
| Edge | Required evidence |
|
||||
| --- | --- |
|
||||
| `editing` | observation or git state |
|
||||
| `touches` | file involved in collision |
|
||||
| `collides` | collision prediction |
|
||||
| `warns` | intervention record |
|
||||
| `learned_from` | accepted real outcome |
|
||||
| `owns` | learned or configured ownership |
|
||||
|
||||
## De-Hairball Policy
|
||||
|
||||
Default mode must not show every relationship equally.
|
||||
|
||||
Rules:
|
||||
|
||||
- Default to risk path.
|
||||
- Collapse repeated collision signatures.
|
||||
- Cap files and collisions.
|
||||
- Dim non-risk edges.
|
||||
- Bundle or curve dense edges.
|
||||
- Hide low-priority labels until hover or select.
|
||||
- Prefer selected-node explanation over labels everywhere.
|
||||
|
||||
## Truthfulness Policy
|
||||
|
||||
- Do not show `learned_from` for orphaned or dismissed outcomes.
|
||||
- Do not label vector similarity as learned memory.
|
||||
- Do not show demo seed as live learning unless labeled.
|
||||
- Do not hide false positives from activity or memory.
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
Graph labels should not expose secrets, raw terminal output, or sensitive file
|
||||
contents. File paths are acceptable when they are repo paths and not secret
|
||||
values.
|
||||
|
||||
## Visual Policy
|
||||
|
||||
Semantic colors stay stable:
|
||||
|
||||
- Engineer: blue.
|
||||
- File: slate.
|
||||
- Feature: amber.
|
||||
- Collision: red.
|
||||
- Intervention: violet.
|
||||
- Learned: violet dashed edge.
|
||||
|
||||
Chrome should use the app's light shadcn tokens.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Graph Discovery Prompt
|
||||
|
||||
Use this prompt for an agent that materializes or reviews PodMan's Team memory
|
||||
graph.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are PodMan's graph discovery agent.
|
||||
|
||||
Your job is to turn MongoDB records into a sparse, truthful graph that explains
|
||||
the continual-learning loop. Do not maximize node count. Maximize legibility and
|
||||
evidence.
|
||||
|
||||
The default output should show the risk path and learned path, not every
|
||||
possible edge.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Pod id.
|
||||
- Pod roster.
|
||||
- Recent engineer states.
|
||||
- Recent observations.
|
||||
- Collisions.
|
||||
- Interventions.
|
||||
- Outcomes.
|
||||
- Team model.
|
||||
- Existing graph nodes and edges.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Normalize file paths.
|
||||
2. Remove noise.
|
||||
3. Create engineer and file nodes.
|
||||
4. Collapse repeated collisions by signature.
|
||||
5. Preserve accepted-outcome paths.
|
||||
6. Create intervention nodes for surviving collisions.
|
||||
7. Create learned edges only from accepted real outcomes.
|
||||
8. Select the primary risk path.
|
||||
9. Build activity and loop summaries.
|
||||
10. Explain selected-node stories.
|
||||
|
||||
## Output Format
|
||||
|
||||
```text
|
||||
Graph Summary
|
||||
- Pod:
|
||||
- Nodes:
|
||||
- Edges:
|
||||
- Primary risk path:
|
||||
- Learned path:
|
||||
|
||||
Discovery Decisions
|
||||
- Collapsed:
|
||||
- Dropped as noise:
|
||||
- Preserved because learned:
|
||||
|
||||
Loop
|
||||
- Observe:
|
||||
- Store:
|
||||
- Predict:
|
||||
- Outcome:
|
||||
- Adapt:
|
||||
|
||||
Activity
|
||||
- Recent events:
|
||||
|
||||
Risks
|
||||
- Missing evidence:
|
||||
- Potential hairball:
|
||||
- Demo caveat:
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- No `learned_from` without accepted real outcome.
|
||||
- No raw screenshots or secrets in labels.
|
||||
- Do not rewrite the backend materializer unless explicitly asked.
|
||||
- Prefer additive graph fields.
|
||||
- Default to risk path.
|
||||
- Keep whole graph optional.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Graph Discovery Spec
|
||||
|
||||
Status: draft
|
||||
Scope: how PodMan discovers graph nodes, edges, risk paths, and learning paths from MongoDB
|
||||
Owner: graph discovery / Team memory observatory
|
||||
|
||||
## Purpose
|
||||
|
||||
Graph discovery turns MongoDB memory into a legible Team memory graph. It is not
|
||||
only layout. It decides which relationships matter, which path is highlighted,
|
||||
and which evidence explains the graph.
|
||||
|
||||
The graph must answer:
|
||||
|
||||
1. Who is working?
|
||||
2. Which files or symbols overlap?
|
||||
3. Where is the risk?
|
||||
4. What did PodMan do?
|
||||
5. What outcome changed memory?
|
||||
|
||||
## Source Data
|
||||
|
||||
Graph discovery reads:
|
||||
|
||||
- `pods`
|
||||
- `engineer_states`
|
||||
- `observations`
|
||||
- `collisions`
|
||||
- `interventions`
|
||||
- `outcomes`
|
||||
- `team_model`
|
||||
- `graph_nodes`
|
||||
- `graph_edges`
|
||||
- optional `memory_vectors`
|
||||
- optional `agent_runs`
|
||||
- optional `strategy_versions`
|
||||
|
||||
## UI Graph Contract
|
||||
|
||||
```text
|
||||
PodGraph
|
||||
podId
|
||||
generatedAt
|
||||
nodes
|
||||
edges
|
||||
metrics
|
||||
loop?
|
||||
activity?
|
||||
```
|
||||
|
||||
Node kinds:
|
||||
|
||||
```text
|
||||
engineer, feature, file, collision, intervention
|
||||
```
|
||||
|
||||
Edge kinds:
|
||||
|
||||
```text
|
||||
owns, editing, touches, collides, warns, learned_from
|
||||
```
|
||||
|
||||
## Discovery Rules
|
||||
|
||||
### Engineer nodes
|
||||
|
||||
Create from pod roster, recent observations, git state, or collision membership.
|
||||
|
||||
### File nodes
|
||||
|
||||
Create only from normalized real file paths. Reject noise such as URLs, env
|
||||
values, scratch names, and non-file strings.
|
||||
|
||||
### Collision nodes
|
||||
|
||||
Create from distinct collision signatures. Collapse repeats. Prioritize
|
||||
collisions referenced by accepted outcomes.
|
||||
|
||||
### Intervention nodes
|
||||
|
||||
Create one visible intervention per surviving collision unless whole-graph mode
|
||||
explicitly expands history.
|
||||
|
||||
### Learned paths
|
||||
|
||||
Create `learned_from` only when an accepted real outcome links an intervention
|
||||
to a durable memory update.
|
||||
|
||||
## Path Modes
|
||||
|
||||
### Risk path
|
||||
|
||||
Default mode. Highlight the clearest current chain:
|
||||
|
||||
```text
|
||||
engineer -> file -> collision -> intervention -> learned owner
|
||||
```
|
||||
|
||||
Dim unrelated graph material.
|
||||
|
||||
### Learning edges
|
||||
|
||||
Highlight `learned_from`, `owns`, and the outcomes that produced them.
|
||||
|
||||
### Whole graph
|
||||
|
||||
Show all materialized nodes and edges with de-emphasized non-critical edges.
|
||||
|
||||
## MongoDB Traversal
|
||||
|
||||
Use `graph_edges` for reachability:
|
||||
|
||||
```text
|
||||
source -> target -> next target
|
||||
```
|
||||
|
||||
Primary traversal questions:
|
||||
|
||||
- What risks does this engineer reach?
|
||||
- Which files feed this collision?
|
||||
- Which intervention came from this collision?
|
||||
- Which learned owner came from this intervention?
|
||||
|
||||
## Metrics
|
||||
|
||||
Minimum metrics:
|
||||
|
||||
- Learned owners.
|
||||
- Open risk paths.
|
||||
- Accept rate.
|
||||
|
||||
Optional metrics:
|
||||
|
||||
- Observations.
|
||||
- Interventions.
|
||||
- Memory vectors.
|
||||
- Strategy versions.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Default graph is not a hairball.
|
||||
- Every visible learned edge has outcome evidence.
|
||||
- Every selected node can explain why it matters.
|
||||
- Activity stream matches graph events.
|
||||
- Graph can be rebuilt from MongoDB source records.
|
||||
|
||||
+23
-6
@@ -76,13 +76,30 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
|
||||
|
||||
- `shared/src/graph.ts` — `PodGraph`, `PodGraphNode/Edge/Metric`, `GraphNodeDoc`, `GraphEdgeDoc`
|
||||
- `backend/src/graph/demo.ts` — `createDemoPodGraph(podId)` (grounded in the demo-pod crew)
|
||||
- `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`)
|
||||
- `backend/src/graph/live.ts` — **`materializePodGraph(podId)`**: builds the graph from the real
|
||||
collections (pods, engineer_states, observations, collisions, interventions, outcomes)
|
||||
- `backend/src/graph/store.ts` — `loadPodGraph` (live → seeded → demo), `seedGraph`, `reachFrom` (`$graphLookup`)
|
||||
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
|
||||
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
|
||||
- `frontend/src/components/GraphView.tsx` — dark-Bauhaus SVG graph (toggle from `App.tsx`)
|
||||
- `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`)
|
||||
|
||||
## Demo-first plan
|
||||
## Live data → graph mapping
|
||||
|
||||
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.
|
||||
`materializePodGraph` reads the 5 real collections per pod and emits a `PodGraph`:
|
||||
|
||||
| Collection | Produces |
|
||||
| ----------------- | -------------------------------------------------------------------------- |
|
||||
| `pods.members` | baseline **engineer** nodes |
|
||||
| `engineer_states` | engineer `risk` if unpushed; **file** nodes (git paths parsed); `editing` |
|
||||
| `observations` | engineer `active`; **file** from `currentFile`; `editing` (strength=conf.) |
|
||||
| `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) |
|
||||
| `interventions` | **intervention** nodes; `warns` (col→intervention) |
|
||||
| `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` |
|
||||
|
||||
Metrics (learned owners / open risk paths / accept rate) are live counts.
|
||||
|
||||
## Fallback order (`loadPodGraph`)
|
||||
|
||||
1. **Live** — `materializePodGraph` from the real collections (returns `null` if only bare roster).
|
||||
2. **Seeded** — `team_model.graph` (from `pnpm graph:seed`).
|
||||
3. **Demo** — `createDemoPodGraph()` (stage safety; never an empty canvas).
|
||||
|
||||
+26
-1
@@ -4,7 +4,32 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0b0f17" />
|
||||
<title>PodMan</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>PodMan — the teammate that sees what git can’t</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PodMan is an ambient AI teammate that watches your pod's screens in realtime and catches merge collisions before anyone pushes."
|
||||
/>
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="PodMan" />
|
||||
<meta property="og:url" content="https://www.podman.live/" />
|
||||
<meta property="og:title" content="PodMan — the teammate that sees what git can’t" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Ambient AI teammate that watches your pod's screens in realtime and catches merge collisions before anyone pushes."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.podman.live/og.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="PodMan — the teammate that sees what git can’t" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Ambient AI teammate that catches merge collisions before anyone pushes."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://www.podman.live/og.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0b0f17" />
|
||||
<circle cx="25" cy="6.6" r="2.4" fill="#34d399" />
|
||||
<text x="14.5" y="24.5" text-anchor="middle"
|
||||
font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
font-weight="700" font-size="24" fill="#34d399">P</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="630" fill="#0b0f17" />
|
||||
<g fill="none" stroke="#34d399" opacity="0.12">
|
||||
<circle cx="1050" cy="140" r="70" stroke-width="6" />
|
||||
<circle cx="1050" cy="140" r="140" stroke-width="5" />
|
||||
<circle cx="1050" cy="140" r="215" stroke-width="4" />
|
||||
</g>
|
||||
<rect x="90" y="150" width="104" height="104" rx="24" fill="#0f1629" stroke="#1f2a3a" stroke-width="2" />
|
||||
<circle cx="176" cy="170" r="8.5" fill="#34d399" />
|
||||
<text x="142" y="228" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-weight="bold" font-size="66" fill="#34d399">P</text>
|
||||
<text x="222" y="232" font-family="Helvetica, Arial, sans-serif" font-weight="bold" font-size="68" fill="#e7eaf0">PodMan</text>
|
||||
<text x="92" y="356" font-family="Helvetica, Arial, sans-serif" font-weight="bold" font-size="46" fill="#e7eaf0">The teammate that sees what git can’t.</text>
|
||||
<text x="92" y="416" font-family="Helvetica, Arial, sans-serif" font-size="34" fill="#9aa4b2">Catches merge collisions before anyone pushes.</text>
|
||||
<rect x="92" y="500" width="13" height="13" rx="3" fill="#34d399" />
|
||||
<text x="118" y="512" font-family="Helvetica, Arial, sans-serif" font-weight="bold" font-size="30" fill="#34d399">podman.live</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -268,10 +268,6 @@ export default function App() {
|
||||
<ShieldCheckIcon data-icon="inline-start" />
|
||||
Privacy-limited
|
||||
</Badge>
|
||||
<Button variant="outline" onClick={() => setGraphPodId(pods[0]?.id ?? 'demo-pod')}>
|
||||
<BrainCircuitIcon data-icon="inline-start" />
|
||||
Team memory
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Refresh
|
||||
@@ -342,6 +338,7 @@ export default function App() {
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onOpenGraph={setGraphPodId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,95 +1,36 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
|
||||
import { fetchPodGraph } from '../lib/graph.js';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { PodGraph } from '@podman/shared';
|
||||
import { fetchPodGraph, backendEventsUrl } from '../lib/graph.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { GraphCanvas } from './graph/GraphCanvas.js';
|
||||
import { MetricsRail } from './graph/MetricsRail.js';
|
||||
import { LearningLoop } from './graph/LearningLoop.js';
|
||||
import { ActivityStream } from './graph/ActivityStream.js';
|
||||
import { SelectedNodePanel } from './graph/SelectedNodePanel.js';
|
||||
import { highlightFor, flowNarrative, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js';
|
||||
|
||||
type Mode = 'risk' | 'learn' | 'all';
|
||||
const POLL_MS = 5000;
|
||||
|
||||
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||
engineer: '#3B5BFF',
|
||||
file: '#ECE7DA',
|
||||
feature: '#F6C445',
|
||||
collision: '#E2403A',
|
||||
intervention: '#8b6cff',
|
||||
};
|
||||
|
||||
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
|
||||
owns: { c: '#3B5BFF', w: 2.6 },
|
||||
editing: { c: '#ECE7DA', w: 2 },
|
||||
touches: { c: '#5d5d66', w: 1.6 },
|
||||
collides: { c: '#E2403A', w: 3.2 },
|
||||
warns: { c: '#F6C445', w: 3.2 },
|
||||
learned_from: { c: '#8b6cff', w: 2.4, dash: true },
|
||||
};
|
||||
|
||||
function NodeShape({ node }: { node: PodGraphNode }) {
|
||||
const c = KIND_COLOR[node.kind];
|
||||
const { x, y } = node;
|
||||
switch (node.kind) {
|
||||
case 'engineer':
|
||||
return <rect x={x - 15} y={y - 15} width={30} height={30} fill={c} />;
|
||||
case 'file':
|
||||
return (
|
||||
<rect
|
||||
x={x - 15}
|
||||
y={y - 15}
|
||||
width={30}
|
||||
height={30}
|
||||
fill="none"
|
||||
stroke={c}
|
||||
strokeWidth={2.6}
|
||||
/>
|
||||
);
|
||||
case 'feature':
|
||||
return <circle cx={x} cy={y} r={17} fill={c} />;
|
||||
case 'collision':
|
||||
return <polygon points={`${x},${y - 18} ${x + 17},${y + 13} ${x - 17},${y + 13}`} fill={c} />;
|
||||
case 'intervention':
|
||||
return (
|
||||
<polygon points={`${x},${y - 18} ${x + 18},${y} ${x},${y + 18} ${x - 18},${y}`} fill={c} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
// Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final
|
||||
// keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming.
|
||||
const GRAPH_CSS = `
|
||||
.pm-node{cursor:grab;transition:opacity .25s ease}
|
||||
.pm-node:active{cursor:grabbing}
|
||||
.pm-edge{transition:opacity .25s ease}
|
||||
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none;
|
||||
paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round}
|
||||
.pm-dim{opacity:.14}
|
||||
.pm-enter{animation:pm-fade .45s ease}
|
||||
.pm-dash{animation:pm-flow 1s linear infinite}
|
||||
.pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite}
|
||||
@keyframes pm-fade{from{opacity:0}to{opacity:1}}
|
||||
@keyframes pm-flow{to{stroke-dashoffset:-26}}
|
||||
@keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pm-enter,.pm-dash,.pm-pulse{animation:none}
|
||||
}
|
||||
}
|
||||
|
||||
interface Highlight {
|
||||
nodes: Set<string>;
|
||||
edges: Set<string>;
|
||||
}
|
||||
|
||||
function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
|
||||
if (selected) {
|
||||
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
|
||||
return {
|
||||
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
if (mode === 'all') return null;
|
||||
const kinds: PodGraphEdge['kind'][] =
|
||||
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
|
||||
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
|
||||
const es = graph.edges.filter(
|
||||
(e) =>
|
||||
kinds.includes(e.kind) ||
|
||||
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
|
||||
);
|
||||
return {
|
||||
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
|
||||
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||
{ label: 'engineer', swatch: { background: '#3B5BFF' } },
|
||||
{ label: 'file', swatch: { border: '2px solid #ECE7DA' } },
|
||||
{ label: 'feature', swatch: { background: '#F6C445', borderRadius: '50%' } },
|
||||
{
|
||||
label: 'collision',
|
||||
swatch: { background: '#E2403A', clipPath: 'polygon(50% 0,100% 100%,0 100%)' },
|
||||
},
|
||||
{ label: 'intervention', swatch: { background: '#8b6cff', transform: 'rotate(45deg)' } },
|
||||
];
|
||||
`;
|
||||
|
||||
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
|
||||
const [graph, setGraph] = useState<PodGraph | null>(null);
|
||||
@@ -99,29 +40,67 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let nudge: number | null = null;
|
||||
setGraph(null);
|
||||
setError(null);
|
||||
setSelected(null);
|
||||
|
||||
const load = () =>
|
||||
fetchPodGraph(podId)
|
||||
.then((g) => alive && setGraph(g))
|
||||
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
|
||||
.then((g) => {
|
||||
if (alive) {
|
||||
setGraph(g);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
|
||||
void load();
|
||||
const poll = window.setInterval(() => void load(), POLL_MS);
|
||||
|
||||
// Best-effort realtime nudge: refetch (debounced) when the agent broadcasts.
|
||||
let ws: WebSocket | null = null;
|
||||
try {
|
||||
ws = new WebSocket(backendEventsUrl());
|
||||
ws.onmessage = () => {
|
||||
if (nudge != null) return;
|
||||
nudge = window.setTimeout(() => {
|
||||
nudge = null;
|
||||
void load();
|
||||
}, 800);
|
||||
};
|
||||
} catch {
|
||||
/* event bus is optional */
|
||||
}
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(poll);
|
||||
if (nudge != null) window.clearTimeout(nudge);
|
||||
ws?.close();
|
||||
};
|
||||
}, [podId]);
|
||||
|
||||
const hi = useMemo(
|
||||
() => (graph ? highlightFor(graph, mode, selected) : null),
|
||||
[graph, mode, selected],
|
||||
);
|
||||
const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
|
||||
const sel = selected ? nodeById.get(selected) : undefined;
|
||||
const relCount = selected
|
||||
? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length
|
||||
: 0;
|
||||
// A selected node can vanish across a poll/WS refresh. Ignore a stale id so the
|
||||
// graph doesn't dim entirely (highlightFor would otherwise light only a dead id).
|
||||
const liveSelected = selected && nodeById.has(selected) ? selected : null;
|
||||
useEffect(() => {
|
||||
if (selected && graph && !nodeById.has(selected)) setSelected(null);
|
||||
}, [graph, nodeById, selected]);
|
||||
|
||||
const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false);
|
||||
const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false);
|
||||
const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false);
|
||||
const highlight = useMemo(
|
||||
() => (graph ? highlightFor(graph, mode, liveSelected) : null),
|
||||
[graph, mode, liveSelected],
|
||||
);
|
||||
const sel = liveSelected ? nodeById.get(liveSelected) : undefined;
|
||||
const relCount = liveSelected
|
||||
? (graph?.edges ?? []).filter((e) => e.source === liveSelected || e.target === liveSelected)
|
||||
.length
|
||||
: 0;
|
||||
const flow = graph && liveSelected ? flowNarrative(graph, liveSelected) : '';
|
||||
|
||||
function pick(next: Mode) {
|
||||
setMode(next);
|
||||
@@ -129,200 +108,111 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
||||
}
|
||||
|
||||
return (
|
||||
<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{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="pm-hd">
|
||||
<div>
|
||||
<div className="pm-ttl">Team memory</div>
|
||||
<div className="pm-sub">What PodMan learned · {podId}</div>
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<style>{GRAPH_CSS}</style>
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-heading text-base font-medium">Team memory</h2>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<button className="pm-x" onClick={onClose}>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
What PodMan learned · {podId}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
← Pods
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pm-bar">
|
||||
<button
|
||||
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`}
|
||||
onClick={() => pick('risk')}
|
||||
{/* Toggles */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(v) => v && pick(v as Mode)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Risk path
|
||||
</button>
|
||||
<button
|
||||
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`}
|
||||
onClick={() => pick('learn')}
|
||||
>
|
||||
Learning edges
|
||||
</button>
|
||||
<button
|
||||
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
|
||||
onClick={() => pick('all')}
|
||||
>
|
||||
Whole graph
|
||||
</button>
|
||||
<ToggleGroupItem value="risk">Risk path</ToggleGroupItem>
|
||||
<ToggleGroupItem value="learn">Learning edges</ToggleGroupItem>
|
||||
<ToggleGroupItem value="all">Whole graph</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<span className="ml-auto hidden text-xs text-muted-foreground sm:inline">
|
||||
Drag to rearrange · double-click to release · click to inspect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ padding: '16px', color: '#ff7d76', fontSize: 13 }}>Graph error: {error}</p>
|
||||
)}
|
||||
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
|
||||
{!graph && !error && (
|
||||
<p style={{ padding: '16px', color: '#8d897e', fontSize: 13 }}>Loading graph…</p>
|
||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">Loading graph…</p>
|
||||
)}
|
||||
|
||||
{graph && (
|
||||
<>
|
||||
<div className="pm-grid">
|
||||
<div className="pm-col">
|
||||
<div className="pm-st">Workflow metrics</div>
|
||||
{graph.metrics.map((m) => (
|
||||
<div className="pm-kpi" key={m.label}>
|
||||
<div className="pm-num">{m.value}</div>
|
||||
<div className="pm-klab">{m.label}</div>
|
||||
<div className="pm-kdet">{m.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Metrics · graph · learning loop */}
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
|
||||
<MetricsRail metrics={graph.metrics} />
|
||||
|
||||
<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);
|
||||
if (!a || !b) return null;
|
||||
const s = EDGE[e.kind];
|
||||
return (
|
||||
<line
|
||||
key={e.id}
|
||||
className={dimEdge(e.id) ? 'pm-dim' : undefined}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke={s.c}
|
||||
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
|
||||
strokeDasharray={s.dash ? '7 6' : undefined}
|
||||
strokeLinecap="round"
|
||||
<div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
|
||||
<GraphCanvas
|
||||
graph={graph}
|
||||
highlight={highlight}
|
||||
selected={liveSelected}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graph.nodes.map((n) => (
|
||||
<g
|
||||
key={n.id}
|
||||
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${n.kind}: ${n.label}`}
|
||||
onClick={() => setSelected((cur) => (cur === n.id ? null : n.id))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
setSelected((cur) => (cur === n.id ? null : n.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NodeShape node={n} />
|
||||
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
|
||||
{n.label.toUpperCase()}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="pm-col pm-railR">
|
||||
{sel ? (
|
||||
<>
|
||||
<div className="pm-dkind">{sel.kind}</div>
|
||||
<div className="pm-dname">{sel.label}</div>
|
||||
<div className="pm-drow">
|
||||
<span>Status</span>
|
||||
<b
|
||||
style={{
|
||||
color:
|
||||
sel.status === 'risk'
|
||||
? '#E2403A'
|
||||
: sel.status === 'learned'
|
||||
? '#b7a4ff'
|
||||
: '#ECE7DA',
|
||||
}}
|
||||
>
|
||||
{sel.status}
|
||||
</b>
|
||||
</div>
|
||||
<div className="pm-drow">
|
||||
<span>Relationships</span>
|
||||
<b>{relCount}</b>
|
||||
</div>
|
||||
<div className="pm-note">{sel.summary}</div>
|
||||
</>
|
||||
{graph.loop?.length ? (
|
||||
<LearningLoop stages={graph.loop} />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* Activity stream · selected node */}
|
||||
<div className="grid gap-4 border-t px-4 py-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<ActivityStream events={graph.activity ?? []} />
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/40 p-4">
|
||||
<SelectedNodePanel node={sel} relCount={relCount} flow={flow} mode={mode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pm-legend">
|
||||
{LEGEND.map((l) => (
|
||||
<span className="pm-lg" key={l.label}>
|
||||
<span className="pm-sw" style={l.swatch} />
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{NODE_LEGEND.map((l) => (
|
||||
<span key={l.label} className="flex items-center gap-1.5">
|
||||
<span className="inline-block size-3" style={l.swatch} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="pm-lg">
|
||||
<span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
|
||||
collides
|
||||
</span>
|
||||
<span className="pm-lg">
|
||||
<span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
|
||||
learned_from
|
||||
<span className="mx-1 h-3 w-px bg-border" aria-hidden />
|
||||
{EDGE_LEGEND.map((l) => (
|
||||
<span key={l.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-[3px] w-3.5"
|
||||
style={
|
||||
l.dash
|
||||
? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
|
||||
: { background: l.color }
|
||||
}
|
||||
/>
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreHorizontalIcon, PlusIcon, Trash2Icon, UserRoundIcon, VideoIcon } from 'lucide-react';
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
UserRoundIcon,
|
||||
VideoIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Pod, PodInput } from '@podman/shared';
|
||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -42,6 +49,7 @@ export function PodCard({
|
||||
onRemoveMember: _onRemoveMember,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onOpenGraph,
|
||||
}: {
|
||||
pod: Pod;
|
||||
busy: boolean;
|
||||
@@ -52,6 +60,7 @@ export function PodCard({
|
||||
onRemoveMember: (id: string, name: string) => void;
|
||||
onUpdate: (id: string, patch: PodInput) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onOpenGraph: (id: string) => void;
|
||||
}) {
|
||||
const [newMember, setNewMember] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -100,6 +109,10 @@ export function PodCard({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={() => onOpenGraph(pod.id)}>
|
||||
<BrainCircuitIcon />
|
||||
Team memory
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setEditing(true)}>Edit pod</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ActivityEvent } from '@podman/shared';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ACTIVITY_TAG } from './encoding.js';
|
||||
|
||||
const fmtTime = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
|
||||
function timeOf(at: string): string {
|
||||
const t = new Date(at).getTime();
|
||||
return Number.isFinite(t) ? fmtTime.format(t) : '--:--';
|
||||
}
|
||||
|
||||
export function ActivityStream({ events }: { events: ActivityEvent[] }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Activity stream
|
||||
</p>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[176px] pr-3">
|
||||
<ul className="space-y-1.5">
|
||||
{events.map((e) => {
|
||||
const tag = ACTIVITY_TAG[e.kind];
|
||||
return (
|
||||
<li key={e.id} className="pm-enter flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{timeOf(e.at)}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide"
|
||||
style={{ color: tag.color, background: `${tag.color}1a` }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 leading-snug text-foreground/90">{e.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type PointerEvent,
|
||||
} from 'react';
|
||||
import type { PodGraph, PodGraphNode, PodGraphNodeKind } from '@podman/shared';
|
||||
import { ForceSim } from './forceSim.js';
|
||||
import { EDGE, KIND_COLOR, nodeRadius, type Highlight } from './encoding.js';
|
||||
|
||||
const W = 760;
|
||||
const H = 480;
|
||||
const MARGIN = 48;
|
||||
|
||||
/** Map the server's 0..720×0..472 layout into the canvas as a seed position. */
|
||||
function mapX(x: number): number {
|
||||
return MARGIN + (Math.max(0, Math.min(720, x)) / 720) * (W - 2 * MARGIN);
|
||||
}
|
||||
function mapY(y: number): number {
|
||||
return MARGIN + (Math.max(0, Math.min(472, y)) / 472) * (H - 2 * MARGIN);
|
||||
}
|
||||
|
||||
function linkDistance(kind: string): number {
|
||||
if (kind === 'collides') return 122;
|
||||
if (kind === 'owns') return 104;
|
||||
if (kind === 'learned_from') return 150;
|
||||
return 134;
|
||||
}
|
||||
function linkStrength(strength: number): number {
|
||||
return Math.max(0.18, Math.min(0.9, strength));
|
||||
}
|
||||
|
||||
/** Stable +/- so parallel edges between the same pair fan to opposite sides. */
|
||||
function curveSign(id: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h + id.charCodeAt(i)) % 2;
|
||||
return h === 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
function edgePath(ax: number, ay: number, bx: number, by: number, id: string): string {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const nx = -dy / len;
|
||||
const ny = dx / len;
|
||||
const off = curveSign(id) * len * 0.13;
|
||||
const cx = (ax + bx) / 2 + nx * off;
|
||||
const cy = (ay + by) / 2 + ny * off;
|
||||
return `M${ax.toFixed(1)},${ay.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${bx.toFixed(1)},${by.toFixed(1)}`;
|
||||
}
|
||||
|
||||
function nodeShape(
|
||||
kind: PodGraphNodeKind,
|
||||
color: string,
|
||||
cx: number,
|
||||
cy: number,
|
||||
r: number,
|
||||
): ReactElement | null {
|
||||
switch (kind) {
|
||||
case 'engineer':
|
||||
return <rect x={cx - r} y={cy - r} width={r * 2} height={r * 2} rx={4} fill={color} />;
|
||||
case 'file':
|
||||
return (
|
||||
<rect
|
||||
x={cx - r}
|
||||
y={cy - r}
|
||||
width={r * 2}
|
||||
height={r * 2}
|
||||
rx={4}
|
||||
fill="var(--card)"
|
||||
stroke={color}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
);
|
||||
case 'feature':
|
||||
return <circle cx={cx} cy={cy} r={r} fill={color} />;
|
||||
case 'collision':
|
||||
return (
|
||||
<polygon
|
||||
points={`${cx},${cy - r} ${cx + r},${cy + r * 0.78} ${cx - r},${cy + r * 0.78}`}
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case 'intervention':
|
||||
return (
|
||||
<polygon points={`${cx},${cy - r} ${cx + r},${cy} ${cx},${cy + r} ${cx - r},${cy}`} fill={color} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showLabel(
|
||||
node: PodGraphNode,
|
||||
dimmed: boolean,
|
||||
hovered: boolean,
|
||||
selected: boolean,
|
||||
): boolean {
|
||||
if (hovered || selected) return true;
|
||||
if (dimmed) return false;
|
||||
// Collisions cluster and often share a filename — reveal on hover/select only.
|
||||
if (node.kind === 'collision') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
pointerId: number;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
export function GraphCanvas({
|
||||
graph,
|
||||
highlight,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
graph: PodGraph;
|
||||
highlight: Highlight | null;
|
||||
selected: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const simRef = useRef<ForceSim | null>(null);
|
||||
if (!simRef.current) simRef.current = new ForceSim(W, H);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const sigRef = useRef<string>('');
|
||||
const [, setFrame] = useState(0);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
|
||||
const loop = useCallback(() => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
const working = sim.tick();
|
||||
setFrame((f) => (f + 1) % 1_000_000);
|
||||
if (working || dragRef.current) {
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
} else {
|
||||
rafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureRaf = useCallback(() => {
|
||||
if (rafRef.current == null) rafRef.current = requestAnimationFrame(loop);
|
||||
}, [loop]);
|
||||
|
||||
// Rebuild the simulation when the graph data changes, preserving positions.
|
||||
useEffect(() => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
const nodeInputs = graph.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
radius: nodeRadius(n),
|
||||
seedX: mapX(n.x),
|
||||
seedY: mapY(n.y),
|
||||
}));
|
||||
const linkInputs = graph.edges.map((e) => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
distance: linkDistance(e.kind),
|
||||
strength: linkStrength(e.strength),
|
||||
}));
|
||||
const sig =
|
||||
nodeInputs
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(',') +
|
||||
'|' +
|
||||
graph.edges
|
||||
.map((e) => e.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
const first = sigRef.current === '';
|
||||
const changed = sig !== sigRef.current;
|
||||
sim.setData(nodeInputs, linkInputs);
|
||||
if (changed) {
|
||||
sigRef.current = sig;
|
||||
sim.reheat(first ? 1 : 0.5);
|
||||
}
|
||||
// Always (re)arm the loop — ensureRaf is idempotent via the rafRef==null
|
||||
// guard. This must NOT be gated on `changed`: under React StrictMode the
|
||||
// dev double-invoke cancels the frame between effect passes, and pass 2 sees
|
||||
// an unchanged sig, so a `changed`-gated start would leave the sim frozen.
|
||||
if (sim.nodes.length) ensureRaf();
|
||||
}, [graph, ensureRaf]);
|
||||
|
||||
// Clean up the animation frame on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function toSvg(evt: PointerEvent): { x: number; y: number } {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return { x: 0, y: 0 };
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return { x: 0, y: 0 };
|
||||
const p = new DOMPoint(evt.clientX, evt.clientY).matrixTransform(ctm.inverse());
|
||||
return { x: p.x, y: p.y };
|
||||
}
|
||||
|
||||
function onNodePointerDown(evt: PointerEvent, id: string) {
|
||||
evt.stopPropagation();
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
(evt.currentTarget as Element).setPointerCapture(evt.pointerId);
|
||||
dragRef.current = { id, pointerId: evt.pointerId, moved: false };
|
||||
const { x, y } = toSvg(evt);
|
||||
sim.pin(id, x, y);
|
||||
sim.setActive(true);
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
function onNodePointerMove(evt: PointerEvent) {
|
||||
const drag = dragRef.current;
|
||||
const sim = simRef.current;
|
||||
if (!drag || !sim || drag.pointerId !== evt.pointerId) return;
|
||||
drag.moved = true;
|
||||
const { x, y } = toSvg(evt);
|
||||
sim.pin(drag.id, x, y);
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
function onNodePointerUp(evt: PointerEvent, id: string) {
|
||||
const drag = dragRef.current;
|
||||
const sim = simRef.current;
|
||||
if (!drag || !sim || drag.pointerId !== evt.pointerId) return;
|
||||
(evt.currentTarget as Element).releasePointerCapture?.(evt.pointerId);
|
||||
sim.setActive(false);
|
||||
// A press that never moved is a click — toggle selection (node stays pinned).
|
||||
if (!drag.moved) onSelect(selected === id ? null : id);
|
||||
dragRef.current = null;
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
function onNodeDoubleClick(id: string) {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
sim.unpin(id);
|
||||
sim.reheat(0.5);
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
const sim = simRef.current;
|
||||
const dimNode = (id: string) => (highlight ? !highlight.nodes.has(id) : false);
|
||||
const dimEdge = (id: string) => (highlight ? !highlight.edges.has(id) : false);
|
||||
const hotEdge = (id: string) => (highlight ? highlight.edges.has(id) : false);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label="PodMan team-memory graph — drag nodes to rearrange"
|
||||
className="block h-full max-h-[560px] w-full touch-none select-none"
|
||||
onPointerDown={() => onSelect(null)}
|
||||
>
|
||||
<g>
|
||||
{graph.edges.map((e) => {
|
||||
const a = sim?.get(e.source);
|
||||
const b = sim?.get(e.target);
|
||||
if (!a || !b) return null;
|
||||
const style = EDGE[e.kind];
|
||||
const hot = hotEdge(e.id);
|
||||
return (
|
||||
<path
|
||||
key={e.id}
|
||||
className={`pm-edge pm-enter ${dimEdge(e.id) ? 'pm-dim' : ''} ${e.kind === 'learned_from' ? 'pm-dash' : ''}`}
|
||||
d={edgePath(a.x, a.y, b.x, b.y, e.id)}
|
||||
fill="none"
|
||||
stroke={style.c}
|
||||
strokeWidth={hot ? style.w + 1.4 : style.w}
|
||||
strokeOpacity={hot ? 1 : 0.78}
|
||||
strokeDasharray={style.dash ? '7 6' : undefined}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
<g>
|
||||
{graph.nodes.map((n) => {
|
||||
const p = sim?.get(n.id);
|
||||
if (!p) return null;
|
||||
const r = p.radius;
|
||||
const dimmed = dimNode(n.id);
|
||||
const isHover = hovered === n.id;
|
||||
const isSel = selected === n.id;
|
||||
const color = KIND_COLOR[n.kind];
|
||||
const pinned = p.fx != null;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
className={`pm-node pm-enter ${dimmed ? 'pm-dim' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${n.kind}: ${n.label}`}
|
||||
onPointerDown={(ev) => onNodePointerDown(ev, n.id)}
|
||||
onPointerMove={onNodePointerMove}
|
||||
onPointerUp={(ev) => onNodePointerUp(ev, n.id)}
|
||||
onDoubleClick={() => onNodeDoubleClick(n.id)}
|
||||
onMouseEnter={() => setHovered(n.id)}
|
||||
onMouseLeave={() => setHovered((cur) => (cur === n.id ? null : cur))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
onSelect(selected === n.id ? null : n.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(isSel || isHover) && (
|
||||
<circle cx={p.x} cy={p.y} r={r + 7} fill="none" stroke={color} strokeWidth={2} strokeOpacity={0.5} />
|
||||
)}
|
||||
{pinned && !isSel && !isHover && (
|
||||
<circle cx={p.x} cy={p.y} r={r + 4} fill="none" stroke={color} strokeWidth={1} strokeDasharray="2 3" strokeOpacity={0.4} />
|
||||
)}
|
||||
{nodeShape(n.kind, color, p.x, p.y, r)}
|
||||
{showLabel(n, dimmed, isHover, isSel) && (
|
||||
<text className="pm-lbl" x={p.x} y={p.y + r + 13} textAnchor="middle">
|
||||
{n.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { LearningStage } from '@podman/shared';
|
||||
import { BLUE } from './encoding.js';
|
||||
|
||||
/**
|
||||
* The continual-learning loop rail: observe → store → predict → outcome → adapt.
|
||||
* The active stage (most-recent activity) gets a pulsing accent bar + ring.
|
||||
*/
|
||||
export function LearningLoop({ stages }: { stages: LearningStage[] }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Learning loop
|
||||
</p>
|
||||
{stages.map((s, i) => (
|
||||
<div key={s.key}>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-lg border bg-card py-2 pl-3.5 pr-3 shadow-sm transition-colors data-[active=true]:bg-accent/40"
|
||||
data-active={s.active}
|
||||
style={s.active ? { boxShadow: `inset 0 0 0 1px ${BLUE}55` } : undefined}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute inset-y-0 left-0 w-1 ${s.active ? 'pm-pulse' : ''}`}
|
||||
style={{ background: s.active ? BLUE : 'var(--border)' }}
|
||||
/>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="tabular-nums">{String(i + 1).padStart(2, '0')}</span> {s.title}
|
||||
</p>
|
||||
<p className="font-heading text-sm font-semibold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-muted-foreground">{s.detail}</p>
|
||||
</div>
|
||||
{i < stages.length - 1 && (
|
||||
<p aria-hidden className="py-0.5 text-center text-xs leading-none text-muted-foreground/60">
|
||||
↓
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PodGraphMetric } from '@podman/shared';
|
||||
import { BLUE, RED, VIOLET, GREEN, AMBER } from './encoding.js';
|
||||
|
||||
const ACCENTS: Array<{ test: RegExp; color: string }> = [
|
||||
{ test: /risk|collision|open/i, color: RED },
|
||||
{ test: /accept/i, color: GREEN },
|
||||
{ test: /learn|owner|adapt/i, color: VIOLET },
|
||||
{ test: /vector|memory|store/i, color: AMBER },
|
||||
];
|
||||
|
||||
function accentFor(label: string, i: number): string {
|
||||
for (const a of ACCENTS) if (a.test.test(label)) return a.color;
|
||||
return [BLUE, RED, VIOLET, GREEN, AMBER][i % 5] ?? BLUE;
|
||||
}
|
||||
|
||||
export function MetricsRail({ metrics }: { metrics: PodGraphMetric[] }) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Workflow metrics
|
||||
</p>
|
||||
{metrics.map((m, i) => (
|
||||
<div
|
||||
key={m.label}
|
||||
className="rounded-lg border bg-card py-2.5 pl-3 pr-3 shadow-sm"
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: accentFor(m.label, i) }}
|
||||
>
|
||||
<p className="font-heading text-2xl font-semibold leading-none tabular-nums">{m.value}</p>
|
||||
<p className="mt-1.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{m.label}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { PodGraphNode } from '@podman/shared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { statusColor, modeBlurb, VIOLET, type Mode } from './encoding.js';
|
||||
|
||||
export function SelectedNodePanel({
|
||||
node,
|
||||
relCount,
|
||||
flow,
|
||||
mode,
|
||||
}: {
|
||||
node: PodGraphNode | undefined;
|
||||
relCount: number;
|
||||
flow: string;
|
||||
mode: Mode;
|
||||
}) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{mode === 'learn' ? 'Learning edges' : mode === 'all' ? 'Whole graph' : 'Risk path'}
|
||||
</p>
|
||||
<h3 className="mb-2 font-heading text-base font-medium">What you're looking at</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{modeBlurb(mode)}</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Click any node to trace its{' '}
|
||||
<span className="font-medium" style={{ color: VIOLET }}>
|
||||
flow
|
||||
</span>{' '}
|
||||
— what PodMan saw, flagged, and learned. Drag to rearrange.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{node.kind}
|
||||
</p>
|
||||
<h3 className="mb-2 mt-0.5 font-heading text-lg font-medium">{node.label}</h3>
|
||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||
<span>Status</span>
|
||||
<Badge variant="outline" style={{ color: statusColor(node.status) }}>
|
||||
{node.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||
<span>Relationships</span>
|
||||
<span className="font-medium text-foreground">{relCount}</span>
|
||||
</div>
|
||||
{flow && (
|
||||
<>
|
||||
<p className="mt-2.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Flow
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{flow}</p>
|
||||
</>
|
||||
)}
|
||||
{node.summary && node.summary !== flow && (
|
||||
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{node.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type {
|
||||
PodGraph,
|
||||
PodGraphNode,
|
||||
PodGraphEdge,
|
||||
PodGraphNodeKind,
|
||||
ActivityKind,
|
||||
} from '@podman/shared';
|
||||
|
||||
/**
|
||||
* Fixed, light-readable hues for the node/edge encoding. Kept stable across
|
||||
* light/dark so kinds stay distinguishable; only the chrome uses shadcn tokens.
|
||||
*/
|
||||
export const BLUE = '#2563eb';
|
||||
export const SLATE = '#475569';
|
||||
export const SLATE_EDGE = '#94a3b8';
|
||||
export const SLATE_FAINT = '#cbd5e1';
|
||||
export const AMBER = '#d97706';
|
||||
export const RED = '#dc2626';
|
||||
export const VIOLET = '#7c3aed';
|
||||
export const GREEN = '#16a34a';
|
||||
|
||||
/** Tag color + short label per activity-stream kind. */
|
||||
export const ACTIVITY_TAG: Record<ActivityKind, { color: string; label: string }> = {
|
||||
editing: { color: SLATE, label: 'EDITING' },
|
||||
collision: { color: RED, label: 'COLLISION' },
|
||||
warns: { color: AMBER, label: 'WARNS' },
|
||||
outcome: { color: GREEN, label: 'OUTCOME' },
|
||||
learned_from: { color: VIOLET, label: 'LEARNED' },
|
||||
};
|
||||
|
||||
export const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||
engineer: BLUE,
|
||||
file: SLATE,
|
||||
feature: AMBER,
|
||||
collision: RED,
|
||||
intervention: VIOLET,
|
||||
};
|
||||
|
||||
export interface EdgeStyle {
|
||||
c: string;
|
||||
w: number;
|
||||
dash?: boolean;
|
||||
}
|
||||
|
||||
export const EDGE: Record<PodGraphEdge['kind'], EdgeStyle> = {
|
||||
owns: { c: BLUE, w: 2.4 },
|
||||
editing: { c: SLATE_EDGE, w: 1.9 },
|
||||
touches: { c: SLATE_FAINT, w: 1.5 },
|
||||
collides: { c: RED, w: 2.8 },
|
||||
warns: { c: AMBER, w: 2.8 },
|
||||
learned_from: { c: VIOLET, w: 2.4, dash: true },
|
||||
};
|
||||
|
||||
/** Collision/drawing radius for a node — scaled by its 0..1 weight. */
|
||||
export function nodeRadius(node: PodGraphNode): number {
|
||||
const base = node.kind === 'collision' || node.kind === 'intervention' ? 14 : 13;
|
||||
return base + Math.max(0, Math.min(1, node.weight)) * 7;
|
||||
}
|
||||
|
||||
export function statusColor(status: string): string {
|
||||
if (status === 'risk') return RED;
|
||||
if (status === 'learned') return VIOLET;
|
||||
if (status === 'active') return BLUE;
|
||||
return 'var(--muted-foreground)';
|
||||
}
|
||||
|
||||
export type Mode = 'risk' | 'learn' | 'all';
|
||||
|
||||
export interface Highlight {
|
||||
nodes: Set<string>;
|
||||
edges: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lit set for the current mode/selection. A selected node lights its
|
||||
* incident edges + neighbors; otherwise the mode lights the risk or learning
|
||||
* chain (collision → intervention → learned_from). `all` lights everything.
|
||||
*/
|
||||
export function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
|
||||
if (selected) {
|
||||
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
|
||||
return {
|
||||
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
if (mode === 'all') return null;
|
||||
const kinds: PodGraphEdge['kind'][] =
|
||||
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
|
||||
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
|
||||
const es = graph.edges.filter(
|
||||
(e) =>
|
||||
kinds.includes(e.kind) ||
|
||||
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
|
||||
);
|
||||
return {
|
||||
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function joinNames(ids: string[], label: (id: string) => string): string {
|
||||
const u = [...new Set(ids)].map(label);
|
||||
if (u.length <= 1) return u[0] ?? '';
|
||||
if (u.length === 2) return `${u[0]} and ${u[1]}`;
|
||||
return `${u.slice(0, -1).join(', ')} and ${u[u.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain-English walk of the flow through a node — what PodMan saw, flagged,
|
||||
* suggested, and learned — so clicking a node explains the path, not just shows
|
||||
* attributes. Built by traversing the node's incident edges.
|
||||
*/
|
||||
export function flowNarrative(graph: PodGraph, nodeId: string): string {
|
||||
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||
const node = byId.get(nodeId);
|
||||
if (!node) return '';
|
||||
const label = (id: string): string => byId.get(id)?.label ?? id;
|
||||
const out = graph.edges.filter((e) => e.source === nodeId);
|
||||
const inc = graph.edges.filter((e) => e.target === nodeId);
|
||||
|
||||
switch (node.kind) {
|
||||
case 'engineer': {
|
||||
const edits = out.filter((e) => e.kind === 'editing').map((e) => e.target);
|
||||
const collisions = out.filter((e) => e.kind === 'collides');
|
||||
const owns = out.filter((e) => e.kind === 'owns').map((e) => label(e.target));
|
||||
const learned = inc.some((e) => e.kind === 'learned_from');
|
||||
const parts: string[] = [];
|
||||
if (edits.length) parts.push(`${node.label} is working in ${joinNames(edits, label)}.`);
|
||||
if (collisions.length)
|
||||
parts.push(
|
||||
`PodMan flagged ${collisions.length} overlap${collisions.length === 1 ? '' : 's'} involving ${node.label}.`,
|
||||
);
|
||||
if (learned)
|
||||
parts.push(
|
||||
`From an accepted intervention PodMan learned ${node.label} owns ${owns[0] ?? 'this file'} — retained across sessions.`,
|
||||
);
|
||||
else if (owns.length) parts.push(`PodMan has ${node.label} owning ${joinNames(owns, (s) => s)}.`);
|
||||
return parts.join(' ') || `${node.label} has no active flow right now.`;
|
||||
}
|
||||
case 'file': {
|
||||
const editors = inc.filter((e) => e.kind === 'editing' || e.kind === 'owns').map((e) => e.source);
|
||||
const hasCollision = out.some((e) => e.kind === 'touches');
|
||||
const parts: string[] = [];
|
||||
if (editors.length) parts.push(`${node.label} is being edited by ${joinNames(editors, label)}.`);
|
||||
if (hasCollision)
|
||||
parts.push('Two of those edits overlap before push, so PodMan opened a collision on it.');
|
||||
return parts.join(' ') || node.summary || node.label;
|
||||
}
|
||||
case 'collision': {
|
||||
const engineers = inc.filter((e) => e.kind === 'collides').map((e) => e.source);
|
||||
const fileEdge = inc.find((e) => e.kind === 'touches');
|
||||
const file = fileEdge ? label(fileEdge.source) : 'the same file';
|
||||
const intervention = out.find((e) => e.kind === 'warns');
|
||||
let s = `${joinNames(engineers, label) || 'Two engineers'} are both editing ${file} before pushing — the overlap git can't see.`;
|
||||
if (intervention) s += ` PodMan stepped in and suggested a ${label(intervention.target)}.`;
|
||||
return s;
|
||||
}
|
||||
case 'intervention': {
|
||||
const colEdge = inc.find((e) => e.kind === 'warns');
|
||||
const learned = out.find((e) => e.kind === 'learned_from');
|
||||
// Resolve the collision's underlying file via its touches edge (file → collision).
|
||||
let file = '';
|
||||
if (colEdge) {
|
||||
const fileEdge = graph.edges.find((e) => e.kind === 'touches' && e.target === colEdge.source);
|
||||
file = fileEdge ? label(fileEdge.source) : '';
|
||||
}
|
||||
let s = `PodMan offered a ${node.label}${file ? ` for the overlap on ${file}` : ''}.`;
|
||||
if (learned)
|
||||
s += ` The pod accepted it, so PodMan learned ${label(learned.target)} owns ${file || 'the file'} — the graph got sharper.`;
|
||||
return s;
|
||||
}
|
||||
case 'feature': {
|
||||
const contributors = inc.filter((e) => e.kind === 'owns' || e.kind === 'touches').map((e) => e.source);
|
||||
return contributors.length
|
||||
? `${node.label} is built on work by ${joinNames(contributors, label)}.`
|
||||
: node.summary || node.label;
|
||||
}
|
||||
default:
|
||||
return node.summary ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Short explainer for the current view when nothing is selected. */
|
||||
export function modeBlurb(mode: Mode): string {
|
||||
if (mode === 'learn')
|
||||
return 'The violet learned_from links are ownership PodMan kept from accepted interventions — the graph sharpens every session.';
|
||||
if (mode === 'all')
|
||||
return 'Everyone, every file, and every collision and intervention PodMan is tracking for this pod.';
|
||||
return 'The lit path: files where two editors collide before push → the nudge PodMan sent → what it learned.';
|
||||
}
|
||||
|
||||
export const NODE_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)' } },
|
||||
];
|
||||
|
||||
export const EDGE_LEGEND: Array<{ label: string; color: string; dash?: boolean }> = [
|
||||
{ label: 'collides', color: RED },
|
||||
{ label: 'warns', color: AMBER },
|
||||
{ label: 'learned_from', color: VIOLET, dash: true },
|
||||
{ label: 'owns', color: BLUE },
|
||||
{ label: 'editing', color: SLATE_EDGE },
|
||||
{ label: 'touches', color: SLATE_FAINT },
|
||||
];
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* A tiny dependency-free force-directed layout — the same family of forces as
|
||||
* d3-force (charge repulsion, link springs, centering, collision) integrated
|
||||
* with velocity-Verlet and an annealing `alpha`. Kept in-house so the dynamic
|
||||
* graph adds no new package / lockfile churn to a fast-moving shared `main`.
|
||||
*
|
||||
* Usage: `setData()` (diff-preserving — existing nodes keep their position),
|
||||
* then drive `tick()` from a requestAnimationFrame loop until `settled()`.
|
||||
*/
|
||||
|
||||
export interface SimNodeInput {
|
||||
id: string;
|
||||
/** Drawing/collision radius. */
|
||||
radius: number;
|
||||
/** Initial position hint (e.g. the server layout), used only for new nodes. */
|
||||
seedX: number;
|
||||
seedY: number;
|
||||
}
|
||||
|
||||
export interface SimLinkInput {
|
||||
source: string;
|
||||
target: string;
|
||||
/** Preferred rest length of the spring. */
|
||||
distance: number;
|
||||
/** 0..1 spring strength. */
|
||||
strength: number;
|
||||
}
|
||||
|
||||
export interface SimNode {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
/** When non-null the node is pinned (dragged) and forces don't move it. */
|
||||
fx: number | null;
|
||||
fy: number | null;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
const ALPHA_MIN = 0.001;
|
||||
const ALPHA_DECAY = 1 - Math.pow(ALPHA_MIN, 1 / 300); // settle in ~300 ticks
|
||||
const FRICTION = 0.62; // velocity retained per tick
|
||||
const REPEL = 4400; // charge repulsion strength — must dominate centering or the graph collapses
|
||||
const LINK_K = 0.45; // spring stiffness multiplier
|
||||
const CENTER_STRENGTH = 0.014; // gentle positional pull — only keeps the cloud roughly centered
|
||||
const RECENTER = 0.5; // per-tick centroid recentering (no compression, keeps graph framed)
|
||||
const COLLIDE_PAD = 12;
|
||||
const COLLIDE_STRENGTH = 1; // hard separation so linked nodes never stack
|
||||
const COLLIDE_ITERS = 2;
|
||||
const BOUND_PAD = 30; // keep nodes this far inside the canvas edges
|
||||
|
||||
export class ForceSim {
|
||||
nodes: SimNode[] = [];
|
||||
links: SimLinkInput[] = [];
|
||||
alpha = 1;
|
||||
private byId = new Map<string, SimNode>();
|
||||
private alphaTarget = 0;
|
||||
private center: { x: number; y: number };
|
||||
private width: number;
|
||||
private height: number;
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.center = { x: width / 2, y: height / 2 };
|
||||
}
|
||||
|
||||
settled(): boolean {
|
||||
return this.alpha < ALPHA_MIN && this.alphaTarget === 0;
|
||||
}
|
||||
|
||||
reheat(a = 0.7): void {
|
||||
this.alpha = Math.max(this.alpha, a);
|
||||
}
|
||||
|
||||
/** Hold the simulation warm while dragging, then release. */
|
||||
setActive(active: boolean): void {
|
||||
this.alphaTarget = active ? 0.18 : 0;
|
||||
if (active) this.reheat(0.25);
|
||||
}
|
||||
|
||||
get(id: string): SimNode | undefined {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
pin(id: string, x: number, y: number): void {
|
||||
const n = this.byId.get(id);
|
||||
if (n) {
|
||||
n.fx = x;
|
||||
n.fy = y;
|
||||
}
|
||||
}
|
||||
|
||||
unpin(id: string): void {
|
||||
const n = this.byId.get(id);
|
||||
if (n) {
|
||||
n.fx = null;
|
||||
n.fy = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the graph, preserving the positions/pins of nodes that persist. */
|
||||
setData(nodeInputs: SimNodeInput[], linkInputs: SimLinkInput[]): { added: string[] } {
|
||||
const prev = this.byId;
|
||||
const next = new Map<string, SimNode>();
|
||||
const added: string[] = [];
|
||||
for (const inp of nodeInputs) {
|
||||
const old = prev.get(inp.id);
|
||||
if (old) {
|
||||
old.radius = inp.radius;
|
||||
next.set(inp.id, old);
|
||||
} else {
|
||||
next.set(inp.id, {
|
||||
id: inp.id,
|
||||
x: inp.seedX + (Math.random() - 0.5) * 14,
|
||||
y: inp.seedY + (Math.random() - 0.5) * 14,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
fx: null,
|
||||
fy: null,
|
||||
radius: inp.radius,
|
||||
});
|
||||
added.push(inp.id);
|
||||
}
|
||||
}
|
||||
this.byId = next;
|
||||
this.nodes = [...next.values()];
|
||||
this.links = linkInputs.filter((l) => next.has(l.source) && next.has(l.target));
|
||||
return { added };
|
||||
}
|
||||
|
||||
/** Advance one step. Returns false when already settled (no work done). */
|
||||
tick(): boolean {
|
||||
if (this.settled()) return false;
|
||||
this.alpha += (this.alphaTarget - this.alpha) * ALPHA_DECAY;
|
||||
const a = this.alpha;
|
||||
this.applyCharge(a);
|
||||
this.applyLinks(a);
|
||||
this.applyCenter(a);
|
||||
for (let k = 0; k < COLLIDE_ITERS; k++) this.applyCollide();
|
||||
const maxX = this.width - BOUND_PAD;
|
||||
const maxY = this.height - BOUND_PAD;
|
||||
for (const n of this.nodes) {
|
||||
if (n.fx != null) {
|
||||
n.x = n.fx;
|
||||
n.vx = 0;
|
||||
} else {
|
||||
n.vx *= FRICTION;
|
||||
n.x += n.vx;
|
||||
if (n.x < BOUND_PAD) {
|
||||
n.x = BOUND_PAD;
|
||||
n.vx = 0;
|
||||
} else if (n.x > maxX) {
|
||||
n.x = maxX;
|
||||
n.vx = 0;
|
||||
}
|
||||
}
|
||||
if (n.fy != null) {
|
||||
n.y = n.fy;
|
||||
n.vy = 0;
|
||||
} else {
|
||||
n.vy *= FRICTION;
|
||||
n.y += n.vy;
|
||||
if (n.y < BOUND_PAD) {
|
||||
n.y = BOUND_PAD;
|
||||
n.vy = 0;
|
||||
} else if (n.y > maxY) {
|
||||
n.y = maxY;
|
||||
n.vy = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private applyCharge(alpha: number): void {
|
||||
const ns = this.nodes;
|
||||
for (let i = 0; i < ns.length; i++) {
|
||||
const a = ns[i];
|
||||
if (!a) continue;
|
||||
for (let j = i + 1; j < ns.length; j++) {
|
||||
const b = ns[j];
|
||||
if (!b) continue;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 === 0) {
|
||||
dx = (j - i) * 0.5;
|
||||
dy = (i + 1) * 0.4;
|
||||
d2 = dx * dx + dy * dy;
|
||||
}
|
||||
const dist = Math.sqrt(d2);
|
||||
const force = (REPEL * alpha) / d2;
|
||||
const ux = dx / dist;
|
||||
const uy = dy / dist;
|
||||
a.vx -= ux * force;
|
||||
a.vy -= uy * force;
|
||||
b.vx += ux * force;
|
||||
b.vy += uy * force;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyLinks(alpha: number): void {
|
||||
for (const link of this.links) {
|
||||
const s = this.byId.get(link.source);
|
||||
const t = this.byId.get(link.target);
|
||||
if (!s || !t) continue;
|
||||
let dx = t.x - s.x;
|
||||
let dy = t.y - s.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 === 0) {
|
||||
dx = 0.5;
|
||||
dy = 0.5;
|
||||
d2 = 0.5;
|
||||
}
|
||||
const dist = Math.sqrt(d2);
|
||||
const k = ((dist - link.distance) / dist) * alpha * link.strength * LINK_K;
|
||||
const mx = dx * k * 0.5;
|
||||
const my = dy * k * 0.5;
|
||||
s.vx += mx;
|
||||
s.vy += my;
|
||||
t.vx -= mx;
|
||||
t.vy -= my;
|
||||
}
|
||||
}
|
||||
|
||||
private applyCenter(alpha: number): void {
|
||||
const n = this.nodes.length;
|
||||
if (!n) return;
|
||||
// Recenter the whole cloud so its centroid sits at canvas center (this does
|
||||
// NOT compress the layout — repulsion/links set the spread), plus a gentle
|
||||
// positional pull so stray/isolated nodes don't park against the edge.
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (const nd of this.nodes) {
|
||||
cx += nd.x;
|
||||
cy += nd.y;
|
||||
}
|
||||
cx = (this.center.x - cx / n) * RECENTER;
|
||||
cy = (this.center.y - cy / n) * RECENTER;
|
||||
for (const nd of this.nodes) {
|
||||
if (nd.fx == null) {
|
||||
nd.x += cx;
|
||||
nd.vx += (this.center.x - nd.x) * CENTER_STRENGTH * alpha;
|
||||
}
|
||||
if (nd.fy == null) {
|
||||
nd.y += cy;
|
||||
nd.vy += (this.center.y - nd.y) * CENTER_STRENGTH * alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyCollide(): void {
|
||||
const ns = this.nodes;
|
||||
for (let i = 0; i < ns.length; i++) {
|
||||
const a = ns[i];
|
||||
if (!a) continue;
|
||||
for (let j = i + 1; j < ns.length; j++) {
|
||||
const b = ns[j];
|
||||
if (!b) continue;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
const min = a.radius + b.radius + COLLIDE_PAD;
|
||||
if (d2 >= min * min) continue;
|
||||
let dist = Math.sqrt(d2);
|
||||
if (dist === 0) {
|
||||
dx = j - i;
|
||||
dy = i + 1;
|
||||
dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
}
|
||||
const push = ((min - dist) / dist) * 0.5 * COLLIDE_STRENGTH;
|
||||
const ox = dx * push;
|
||||
const oy = dy * push;
|
||||
if (a.fx == null) a.x -= ox;
|
||||
if (a.fy == null) a.y -= oy;
|
||||
if (b.fx == null) b.x += ox;
|
||||
if (b.fy == null) b.y += oy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,8 @@ export async function fetchPodGraph(podId: string): Promise<PodGraph> {
|
||||
if (!res.ok) throw new Error(`graph request failed: ${res.status}`);
|
||||
return res.json() as Promise<PodGraph>;
|
||||
}
|
||||
|
||||
/** WebSocket URL for the live event bus — used to nudge the graph to refetch. */
|
||||
export function backendEventsUrl(): string {
|
||||
return `${BACKEND_URL.replace(/^http/, 'ws')}/api/events`;
|
||||
}
|
||||
|
||||
@@ -46,10 +46,16 @@ export function useInterventions(room: Room | null) {
|
||||
accepted,
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
await room?.localParticipant.publishData(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'ACK', interventionId: active.id, status }),
|
||||
),
|
||||
{ reliable: true, topic: DATA_TOPIC },
|
||||
);
|
||||
setActive(null);
|
||||
return status;
|
||||
},
|
||||
[active],
|
||||
[active, room],
|
||||
);
|
||||
|
||||
return { active, hermes, voiceCue, actionUrl, respond };
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Room, Track, createLocalScreenTracks, VideoPresets } from 'livekit-client';
|
||||
import { fetchToken } from '../lib/api';
|
||||
|
||||
export function useScreenPublish() {
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
|
||||
const join = useCallback(
|
||||
async (pod: string, identity: string, name: string, githubLogin?: string) => {
|
||||
const { token, url } = await fetchToken({ room: pod, identity, name, githubLogin });
|
||||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||
await room.connect(url, token);
|
||||
roomRef.current = room;
|
||||
setConnected(true);
|
||||
return room;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const startSharing = useCallback(async () => {
|
||||
const room = roomRef.current;
|
||||
if (!room) throw new Error('join the pod first');
|
||||
const tracks = await createLocalScreenTracks({
|
||||
audio: true,
|
||||
resolution: VideoPresets.h1080.resolution,
|
||||
});
|
||||
for (const t of tracks) {
|
||||
await room.localParticipant.publishTrack(t.mediaStreamTrack, {
|
||||
source:
|
||||
t.kind === Track.Kind.Audio ? Track.Source.ScreenShareAudio : Track.Source.ScreenShare,
|
||||
});
|
||||
}
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
await room.localParticipant.setCameraEnabled(true);
|
||||
setSharing(true);
|
||||
}, []);
|
||||
|
||||
return { join, startSharing, connected, sharing, room: roomRef };
|
||||
}
|
||||
+4
-2
@@ -48,7 +48,8 @@ services:
|
||||
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
|
||||
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
|
||||
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||
@@ -73,7 +74,8 @@ workers:
|
||||
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-live-2.5-flash }
|
||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-3.1-flash-tts-preview }
|
||||
- { key: GEMINI_EMBEDDING_MODEL, scope: RUN_TIME, value: gemini-embedding-001 }
|
||||
- { key: GITHUB_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||
|
||||
@@ -22,3 +22,19 @@ lk.165-22-129-249.sslip.io {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
podman.live, www.podman.live {
|
||||
route {
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8787
|
||||
}
|
||||
|
||||
handle /health {
|
||||
reverse_proxy 127.0.0.1:8787
|
||||
}
|
||||
|
||||
root * /var/www/podman
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,16 @@ docker run --env-file backend/.env -e PODMAN_PROCESS=server -p 8787:8787 podman-
|
||||
docker run --env-file backend/.env -e PODMAN_PROCESS=agent podman-backend
|
||||
```
|
||||
|
||||
The automated check uses Docker by default, matching `pnpm build:container`:
|
||||
|
||||
```bash
|
||||
pnpm build:container
|
||||
pnpm verify:containers
|
||||
```
|
||||
|
||||
Set `VERIFY_CONTAINER_RUNTIME=podman` to run the same verifier against a Podman
|
||||
image store.
|
||||
|
||||
## Local production services
|
||||
|
||||
On the demo droplet, serve the API and worker with systemd instead of tmux:
|
||||
|
||||
+2
-1
@@ -26,10 +26,11 @@
|
||||
"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",
|
||||
"verify:full": "pnpm verify && pnpm verify:infra && pnpm build:container && pnpm verify:containers",
|
||||
"verify:backend": "node scripts/verify-backend.mjs",
|
||||
"verify:containers": "node scripts/verify-containers.mjs",
|
||||
"verify:frontend": "node scripts/verify-frontend.mjs",
|
||||
"verify:infra": "node scripts/verify-infra.mjs",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
|
||||
@@ -353,11 +353,10 @@ await check('gemini embeddings', checkGeminiEmbeddings);
|
||||
|
||||
if (isSet('VOYAGE_API_KEY')) {
|
||||
await check('voyage embeddings', checkVoyage);
|
||||
await check('atlas vector index', checkVectorIndex);
|
||||
} else {
|
||||
add('voyage embeddings', 'warn', 'VOYAGE_API_KEY is optional; exact Mongo recall remains active');
|
||||
add('atlas vector index', 'warn', 'requires VOYAGE_API_KEY and Atlas Search index');
|
||||
add('voyage embeddings', 'warn', 'VOYAGE_API_KEY is optional; Gemini embeddings are active');
|
||||
}
|
||||
await check('atlas vector index', checkVectorIndex);
|
||||
|
||||
const failed = results.filter((r) => r.status === 'fail');
|
||||
const warnings = results.filter((r) => r.status === 'warn');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
|
||||
const image = process.env.VERIFY_CONTAINER_IMAGE ?? 'podman-backend';
|
||||
const runtime = process.env.VERIFY_CONTAINER_RUNTIME ?? 'docker';
|
||||
const port = Number(process.env.VERIFY_CONTAINER_PORT ?? 8799);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const runId = `${process.pid}-${Date.now()}`;
|
||||
@@ -21,14 +22,19 @@ const containerEnv = {
|
||||
LIVEKIT_API_KEY: process.env.LIVEKIT_API_KEY ?? 'verify-key',
|
||||
LIVEKIT_API_SECRET: process.env.LIVEKIT_API_SECRET ?? 'verify-secret',
|
||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY ?? 'verify-gemini',
|
||||
GEMINI_VISION_MODEL: process.env.GEMINI_VISION_MODEL ?? 'gemini-2.0-flash',
|
||||
GEMINI_LIVE_MODEL: process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-tts-preview',
|
||||
GEMINI_EMBEDDING_MODEL: process.env.GEMINI_EMBEDDING_MODEL ?? 'gemini-embedding-001',
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github',
|
||||
GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman',
|
||||
MONGODB_URI: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/podman',
|
||||
VOYAGE_API_KEY: process.env.VOYAGE_API_KEY ?? '',
|
||||
VOYAGE_EMBEDDING_MODEL: process.env.VOYAGE_EMBEDDING_MODEL ?? 'voyage-4-lite',
|
||||
};
|
||||
|
||||
function runPodman(args, options = {}) {
|
||||
function runContainer(args, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('podman', args, {
|
||||
const child = spawn(runtime, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...options,
|
||||
});
|
||||
@@ -61,12 +67,15 @@ function fail(message) {
|
||||
}
|
||||
|
||||
async function assertPodmanAvailable() {
|
||||
const result = await runPodman(['--version']);
|
||||
if (result.code !== 0) fail(`podman is not available: ${result.stderr.trim()}`);
|
||||
const result = await runContainer(['--version']);
|
||||
if (result.code !== 0) fail(`${runtime} is not available: ${result.stderr.trim()}`);
|
||||
}
|
||||
|
||||
async function assertImageExists() {
|
||||
const result = await runPodman(['image', 'exists', image]);
|
||||
const result =
|
||||
runtime === 'podman'
|
||||
? await runContainer(['image', 'exists', image])
|
||||
: await runContainer(['image', 'inspect', image]);
|
||||
if (result.code !== 0) {
|
||||
fail(
|
||||
`container image "${image}" does not exist locally; build it before running this verifier`,
|
||||
@@ -75,18 +84,23 @@ async function assertImageExists() {
|
||||
}
|
||||
|
||||
async function removeContainer(name) {
|
||||
await runPodman(['rm', '-f', name]);
|
||||
await runContainer(['rm', '-f', name]);
|
||||
}
|
||||
|
||||
async function startContainer(name, extraEnv) {
|
||||
await removeContainer(name);
|
||||
const result = await runPodman([
|
||||
const networkArgs =
|
||||
runtime === 'podman'
|
||||
? ['--network', 'host']
|
||||
: extraEnv.PODMAN_PROCESS === 'server'
|
||||
? ['--publish', `127.0.0.1:${port}:${port}`]
|
||||
: [];
|
||||
const result = await runContainer([
|
||||
'run',
|
||||
'--detach',
|
||||
'--name',
|
||||
name,
|
||||
'--network',
|
||||
'host',
|
||||
...networkArgs,
|
||||
...envArgs(extraEnv),
|
||||
image,
|
||||
]);
|
||||
@@ -96,7 +110,7 @@ async function startContainer(name, extraEnv) {
|
||||
}
|
||||
|
||||
async function stopContainer(name) {
|
||||
await runPodman(['stop', '--time', '3', name]);
|
||||
await runContainer(['stop', '--time', '3', name]);
|
||||
await removeContainer(name);
|
||||
}
|
||||
|
||||
@@ -125,7 +139,7 @@ async function waitForApi() {
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
const logs = await runPodman(['logs', apiContainer]);
|
||||
const logs = await runContainer(['logs', apiContainer]);
|
||||
fail(
|
||||
`API container did not become healthy at ${baseUrl}: ${lastError}\n${logs.stdout}${logs.stderr}`,
|
||||
);
|
||||
@@ -154,11 +168,11 @@ async function verifyAgentContainer() {
|
||||
|
||||
let output = '';
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const logs = await runPodman(['logs', agentContainer]);
|
||||
const logs = await runContainer(['logs', agentContainer]);
|
||||
output = `${logs.stdout}${logs.stderr}`;
|
||||
if (output.includes('podman-hermes joined room')) return;
|
||||
|
||||
const inspect = await runPodman([
|
||||
const inspect = await runContainer([
|
||||
'inspect',
|
||||
'--format',
|
||||
'{{.State.Running}} {{.State.ExitCode}}',
|
||||
@@ -182,10 +196,17 @@ try {
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
runtime,
|
||||
image,
|
||||
baseUrl,
|
||||
containers: [apiContainer, agentContainer],
|
||||
checks: ['image-exists', 'api-health', 'api-pods', 'agent-joined-room'],
|
||||
checks: [
|
||||
'image-exists',
|
||||
'api-health',
|
||||
'api-pods',
|
||||
'agent-joined-room',
|
||||
'gemini-model-envs',
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -4,7 +4,10 @@ import { createRequire } from 'node:module';
|
||||
import { TextEncoder } from 'node:util';
|
||||
import { chromium } from 'playwright';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { RoomServiceClient } from 'livekit-server-sdk';
|
||||
|
||||
loadEnv({ path: 'backend/.env', quiet: true });
|
||||
const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:4173/';
|
||||
const shouldStartPreview = !process.env.FRONTEND_URL;
|
||||
const doFetch = globalThis.fetch;
|
||||
@@ -73,7 +76,7 @@ async function connectPublisher(roomName) {
|
||||
}),
|
||||
});
|
||||
const room = new Room();
|
||||
await room.connect(url, token);
|
||||
await room.connect(url, token, { autoSubscribe: true });
|
||||
return room;
|
||||
}
|
||||
|
||||
@@ -107,15 +110,23 @@ async function publishIntervention(room, podId) {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
return intervention;
|
||||
}
|
||||
|
||||
async function publishDataMessage(room, message) {
|
||||
await room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), {
|
||||
reliable: true,
|
||||
topic: DATA_TOPIC,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const intervention = await publishIntervention(room, podId);
|
||||
try {
|
||||
await page.getByText(cardText).waitFor({ timeout: 5_000 });
|
||||
return;
|
||||
return intervention;
|
||||
} catch (error) {
|
||||
if (attempt === 3) throw error;
|
||||
await delay(500);
|
||||
@@ -123,6 +134,40 @@ async function waitForInterventionCard(page, room, podId) {
|
||||
}
|
||||
}
|
||||
|
||||
function liveKitService() {
|
||||
if (!process.env.LIVEKIT_URL || !process.env.LIVEKIT_API_KEY || !process.env.LIVEKIT_API_SECRET) {
|
||||
throw new Error('screen publication verification requires LIVEKIT_* env vars');
|
||||
}
|
||||
const httpUrl = process.env.LIVEKIT_URL.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
|
||||
return new RoomServiceClient(
|
||||
httpUrl,
|
||||
process.env.LIVEKIT_API_KEY,
|
||||
process.env.LIVEKIT_API_SECRET,
|
||||
);
|
||||
}
|
||||
|
||||
function hasScreenShareTrack(participant) {
|
||||
return (participant.tracks ?? []).some((track) => {
|
||||
const source = JSON.stringify(track).toLowerCase();
|
||||
return source.includes('screen') || source.includes('share');
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPublishedScreenShare(roomName) {
|
||||
const service = liveKitService();
|
||||
let lastParticipants = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
lastParticipants = await service.listParticipants(roomName);
|
||||
if (lastParticipants.some(hasScreenShareTrack)) return;
|
||||
await delay(500);
|
||||
}
|
||||
throw new Error(
|
||||
`LiveKit room service did not list a screen-share publication in ${roomName}: ${JSON.stringify(
|
||||
lastParticipants,
|
||||
).slice(0, 1000)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let preview = null;
|
||||
if (shouldStartPreview) {
|
||||
preview = spawn(
|
||||
@@ -139,6 +184,7 @@ if (shouldStartPreview) {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__podmanVerifyScreens = [];
|
||||
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -149,12 +195,21 @@ await page.addInitScript(() => {
|
||||
canvas.height = 360;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas context unavailable');
|
||||
let frame = 0;
|
||||
const draw = () => {
|
||||
frame += 1;
|
||||
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);
|
||||
ctx.fillText(`frame ${frame}`, 32, 120);
|
||||
};
|
||||
draw();
|
||||
const interval = globalThis.setInterval(draw, 200);
|
||||
const stream = canvas.captureStream(5);
|
||||
globalThis.__podmanVerifyScreens.push({ canvas, stream, interval });
|
||||
return stream;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -217,12 +272,37 @@ try {
|
||||
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 waitForPublishedScreenShare('frontend-pod');
|
||||
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');
|
||||
const intervention = await waitForInterventionCard(page, publisher, 'frontend-pod');
|
||||
await publishDataMessage(publisher, {
|
||||
type: 'HERMES_MESSAGE',
|
||||
message: {
|
||||
id: `hermes-${process.pid}`,
|
||||
podId: 'frontend-pod',
|
||||
interventionId: intervention.id,
|
||||
recipients: ['Verify'],
|
||||
text: 'Hermes verification message routed to the team.',
|
||||
urgency: 'normal',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await publishDataMessage(publisher, {
|
||||
type: 'VOICE_CUE',
|
||||
text: 'Voice cue verification for urgent escalation.',
|
||||
});
|
||||
await page.getByText('Hermes message').waitFor({ timeout: 15_000 });
|
||||
await page.getByText('Hermes verification message routed to the team.').waitFor({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByText('Voice cue', { exact: true }).waitFor({ timeout: 15_000 });
|
||||
await page.getByText('Voice cue verification for urgent escalation.').waitFor({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await page.getByText('No collision detected').waitFor({ timeout: 15_000 });
|
||||
} finally {
|
||||
@@ -240,11 +320,12 @@ try {
|
||||
{
|
||||
ok: true,
|
||||
frontendUrl,
|
||||
apiBase,
|
||||
bodyLength: bodyText.length,
|
||||
graph: true,
|
||||
joined: true,
|
||||
screenShare: true,
|
||||
intervention: true,
|
||||
screenShare: 'livekit-published',
|
||||
intervention: 'collision-hermes-voice',
|
||||
member: verifyMember,
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function requireText(haystack, needle, label) {
|
||||
if (!haystack.includes(needle)) fail(`${label} missing: ${needle}`);
|
||||
}
|
||||
|
||||
function sectionBetween(text, start, end) {
|
||||
const startAt = text.indexOf(start);
|
||||
if (startAt === -1) fail(`section not found: ${start}`);
|
||||
const endAt = end ? text.indexOf(end, startAt + start.length) : -1;
|
||||
return text.slice(startAt, endAt === -1 ? undefined : endAt);
|
||||
}
|
||||
|
||||
function requireEnvKeys(section, keys, label) {
|
||||
for (const key of keys) {
|
||||
requireText(section, `key: ${key}`, label);
|
||||
}
|
||||
}
|
||||
|
||||
const [appSpec, doSpec, dockerfile, digitalOceanDocs] = await Promise.all([
|
||||
readFile('infra/app.yaml', 'utf8'),
|
||||
readFile('infra/.do/app.yaml', 'utf8'),
|
||||
readFile('infra/Dockerfile', 'utf8'),
|
||||
readFile('docs/digitalocean.md', 'utf8'),
|
||||
]);
|
||||
|
||||
if (appSpec !== doSpec) {
|
||||
fail('infra/.do/app.yaml must stay identical to infra/app.yaml');
|
||||
}
|
||||
|
||||
const web = sectionBetween(appSpec, 'static_sites:', 'services:');
|
||||
const api = sectionBetween(appSpec, ' - name: api', 'workers:');
|
||||
const worker = sectionBetween(appSpec, ' - name: podman-agent');
|
||||
|
||||
requireText(web, 'output_dir: frontend/dist', 'web static site');
|
||||
requireText(web, 'value: ${APP_URL}', 'web static site VITE_BACKEND_URL');
|
||||
requireEnvKeys(web, ['VITE_BACKEND_URL', 'VITE_LIVEKIT_URL'], 'web static site envs');
|
||||
|
||||
requireText(api, 'dockerfile_path: infra/Dockerfile', 'api service');
|
||||
requireText(api, 'http_port: 8787', 'api service');
|
||||
requireText(api, 'http_path: /health', 'api service health check');
|
||||
requireText(api, 'path: /api', 'api service route');
|
||||
requireText(api, 'preserve_path_prefix: true', 'api service route');
|
||||
requireText(api, 'value: server', 'api service PODMAN_PROCESS');
|
||||
|
||||
requireText(worker, 'dockerfile_path: infra/Dockerfile', 'worker');
|
||||
requireText(worker, 'value: agent', 'worker PODMAN_PROCESS');
|
||||
requireText(worker, 'value: demo-pod', 'worker POD_ROOM');
|
||||
if (worker.includes('health_check:') || worker.includes('http_port:')) {
|
||||
fail('podman-agent must remain a worker, not a health-checked HTTP service');
|
||||
}
|
||||
|
||||
const runtimeKeys = [
|
||||
'LIVEKIT_URL',
|
||||
'LIVEKIT_API_KEY',
|
||||
'LIVEKIT_API_SECRET',
|
||||
'GEMINI_API_KEY',
|
||||
'GEMINI_VISION_MODEL',
|
||||
'GEMINI_LIVE_MODEL',
|
||||
'GEMINI_EMBEDDING_MODEL',
|
||||
'GITHUB_TOKEN',
|
||||
'GITHUB_REPO',
|
||||
'MONGODB_URI',
|
||||
];
|
||||
requireEnvKeys(api, ['PODMAN_PROCESS', 'PORT', ...runtimeKeys], 'api service envs');
|
||||
requireEnvKeys(worker, ['PODMAN_PROCESS', 'POD_ROOM', ...runtimeKeys], 'worker envs');
|
||||
|
||||
requireText(dockerfile, 'ENV PODMAN_PROCESS=server', 'Dockerfile');
|
||||
requireText(dockerfile, 'node backend/dist/agent.js', 'Dockerfile');
|
||||
requireText(dockerfile, 'node backend/dist/server.js', 'Dockerfile');
|
||||
requireText(dockerfile, 'EXPOSE 8787', 'Dockerfile');
|
||||
|
||||
requireText(digitalOceanDocs, 'docker run --env-file backend/.env', 'DigitalOcean docs');
|
||||
requireText(digitalOceanDocs, '`/api` with `preserve_path_prefix: true`', 'DigitalOcean docs');
|
||||
requireText(
|
||||
digitalOceanDocs,
|
||||
'`podman-agent`: background LiveKit/Gemini worker',
|
||||
'DigitalOcean docs',
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
checks: [
|
||||
'app-spec-mirror',
|
||||
'static-site-envs',
|
||||
'api-route-preserves-prefix',
|
||||
'worker-split',
|
||||
'runtime-env-keys',
|
||||
'docker-entrypoint',
|
||||
'digitalocean-docs',
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -48,6 +48,36 @@ export interface PodGraphMetric {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** The five stages of PodMan's continual-learning loop, in order. */
|
||||
export type LearningStageKey = 'observe' | 'store' | 'predict' | 'outcome' | 'adapt';
|
||||
|
||||
/** One stage of the learning-loop rail (observe→store→predict→outcome→adapt). */
|
||||
export interface LearningStage {
|
||||
key: LearningStageKey;
|
||||
/** UPPERCASE display title, e.g. "OBSERVE". */
|
||||
title: string;
|
||||
/** Headline figure for the stage, e.g. "5/s" or "124". */
|
||||
value: string;
|
||||
/** One-line detail under the title. */
|
||||
detail: string;
|
||||
/** True for the single most-recently-active stage (pulses in the UI). */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Kind of an activity-stream entry (drives the colored tag). */
|
||||
export type ActivityKind = 'editing' | 'collision' | 'warns' | 'outcome' | 'learned_from';
|
||||
|
||||
/** One time-tagged entry in the activity stream. */
|
||||
export interface ActivityEvent {
|
||||
/** Stable id (source doc id + kind) so the UI can animate diffs. */
|
||||
id: string;
|
||||
/** ISO timestamp the event happened. */
|
||||
at: string;
|
||||
kind: ActivityKind;
|
||||
/** Human-readable line, e.g. "Yahya opened auth.ts — unpushed changes". */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A point-in-time render of a pod's team_model. */
|
||||
export interface PodGraph {
|
||||
podId: string;
|
||||
@@ -56,6 +86,10 @@ export interface PodGraph {
|
||||
nodes: PodGraphNode[];
|
||||
edges: PodGraphEdge[];
|
||||
metrics: PodGraphMetric[];
|
||||
/** Continual-learning loop counts (observe→…→adapt). Additive/optional. */
|
||||
loop?: LearningStage[];
|
||||
/** Recent activity feed, most-recent first, capped ~8. Additive/optional. */
|
||||
activity?: ActivityEvent[];
|
||||
}
|
||||
|
||||
/** One node as a standalone document in the `graph_nodes` collection. */
|
||||
|
||||
@@ -18,6 +18,10 @@ export type {
|
||||
PodGraphNodeKind,
|
||||
PodGraphEdgeKind,
|
||||
PodGraphNodeStatus,
|
||||
LearningStage,
|
||||
LearningStageKey,
|
||||
ActivityEvent,
|
||||
ActivityKind,
|
||||
GraphNodeDoc,
|
||||
GraphEdgeDoc,
|
||||
} from './graph.js';
|
||||
|
||||
Reference in New Issue
Block a user