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 { AccessToken } from 'livekit-server-sdk';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { PodMan } from './agent/podman.js';
|
import { PodMan } from './agent/podman.js';
|
||||||
|
import { initMemory } from './memory/db.js';
|
||||||
|
|
||||||
const POD_ROOM = process.env.POD_ROOM ?? 'demo-pod';
|
const POD_ROOM = process.env.POD_ROOM ?? 'demo-pod';
|
||||||
const HERMES_IDENTITY = 'podman-hermes';
|
const HERMES_IDENTITY = 'podman-hermes';
|
||||||
@@ -30,6 +31,10 @@ async function agentToken(room: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
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 room = new Room();
|
||||||
const podman = new PodMan(room, POD_ROOM);
|
const podman = new PodMan(room, POD_ROOM);
|
||||||
await room.connect(env.LIVEKIT_URL, await agentToken(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 { analyzeFrame } from '../vision/gemini.js';
|
||||||
import { detectCollisions } from '../collision/detector.js';
|
import { detectCollisions } from '../collision/detector.js';
|
||||||
import { getGithubState } from '../github/client.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 { getGitStates } from '../memory/db.js';
|
||||||
import { recallSimilar } from '../memory/vectors.js';
|
import { recallSimilar } from '../memory/vectors.js';
|
||||||
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
||||||
@@ -30,6 +35,7 @@ export class PodMan {
|
|||||||
if (c)
|
if (c)
|
||||||
c.hasUnpushedChanges = msg.report.unpushedCount > 0 || msg.report.dirtyFiles.length > 0;
|
c.hasUnpushedChanges = msg.report.unpushedCount > 0 || msg.report.dirtyFiles.length > 0;
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'ACK') void updateInterventionStatus(msg.interventionId, msg.status);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore malformed */
|
/* ignore malformed */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,24 +8,62 @@ import type { PodGraph } from '@podman/shared';
|
|||||||
* auth* — is the continual-learning story the demo lights up.
|
* auth* — is the continual-learning story the demo lights up.
|
||||||
*/
|
*/
|
||||||
export function createDemoPodGraph(podId: string): PodGraph {
|
export function createDemoPodGraph(podId: string): PodGraph {
|
||||||
|
const base = Date.now();
|
||||||
|
const at = (secAgo: number): string => new Date(base - secAgo * 1000).toISOString();
|
||||||
return {
|
return {
|
||||||
podId,
|
podId,
|
||||||
generatedAt: new Date().toISOString(),
|
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: [
|
metrics: [
|
||||||
{
|
{
|
||||||
label: 'Learned owners',
|
label: 'Learned owners',
|
||||||
value: '5',
|
value: '3',
|
||||||
detail: 'Ownership edges retained from accepted interventions.',
|
detail: 'Distinct owners retained from accepted interventions.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Open risk paths',
|
label: 'Open risk paths',
|
||||||
value: '2',
|
value: '1',
|
||||||
detail: 'auth.ts and the memory API have converging editors.',
|
detail: 'File with two or more converging editors.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Accept rate',
|
label: 'Accept rate',
|
||||||
value: '86%',
|
value: '100%',
|
||||||
detail: 'Interventions accepted this session (+14%).',
|
detail: 'Interventions accepted vs total this session.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
nodes: [
|
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 type { PodGraph, GraphNodeDoc, GraphEdgeDoc } from '@podman/shared';
|
||||||
import { getDb } from '../memory/db.js';
|
import { getDb } from '../memory/db.js';
|
||||||
import { createDemoPodGraph } from './demo.js';
|
import { createDemoPodGraph } from './demo.js';
|
||||||
|
import { materializePodGraph } from './live.js';
|
||||||
|
|
||||||
interface TeamModelDoc {
|
interface TeamModelDoc {
|
||||||
podId: string;
|
podId: string;
|
||||||
@@ -14,6 +15,14 @@ interface TeamModelDoc {
|
|||||||
* unreachable — so the demo path never depends on a populated DB.
|
* unreachable — so the demo path never depends on a populated DB.
|
||||||
*/
|
*/
|
||||||
export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
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 {
|
try {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const doc = await db.collection<TeamModelDoc>('team_model').findOne({ podId });
|
const doc = await db.collection<TeamModelDoc>('team_model').findOne({ podId });
|
||||||
@@ -21,6 +30,7 @@ export async function loadPodGraph(podId: string): Promise<PodGraph> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[graph] loadPodGraph fell back to demo: ${(err as Error).message}`);
|
console.warn(`[graph] loadPodGraph fell back to demo: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
// 3. Demo (stage safety — never an empty canvas).
|
||||||
return createDemoPodGraph(podId);
|
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 { collections } from './db.js';
|
||||||
import { enrichCollisionMemory } from './vectors.js';
|
import { enrichCollisionMemory } from './vectors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Continual-learning memory: persist observations, collisions, interventions,
|
* Continual-learning memory: persist observations, collisions, interventions,
|
||||||
* and outcomes to MongoDB so later sessions get sharper. Writes are best-effort
|
* and outcomes to MongoDB so later sessions get sharper. MongoDB is mandatory —
|
||||||
* — a Mongo hiccup logs a warning rather than crashing the agent/server.
|
* 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> {
|
async function persist(name: string, fn: () => Promise<unknown>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
} catch (err) {
|
} 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> {
|
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||||
await persist('outcome', async () => {
|
await persist('outcome', async () => {
|
||||||
const c = await collections();
|
const c = await collections();
|
||||||
|
|||||||
@@ -177,7 +177,11 @@ http.listen(env.PORT, '0.0.0.0', () => {
|
|||||||
console.log(`[server] :${env.PORT}`);
|
console.log(`[server] :${env.PORT}`);
|
||||||
initMemory()
|
initMemory()
|
||||||
.then(() => seedDefaultPods())
|
.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;
|
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 .
|
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
|
The image entrypoint runs `node backend/dist/server.js` when
|
||||||
`PODMAN_PROCESS=server`, and `node backend/dist/agent.js` when
|
`PODMAN_PROCESS=server`, and `node backend/dist/agent.js` when
|
||||||
`PODMAN_PROCESS=agent`. Do not run the combined Hermes supervisor inside App
|
`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`
|
- `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/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)
|
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
|
||||||
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
|
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
|
||||||
- `frontend/src/components/GraphView.tsx` — 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).
|
`materializePodGraph` reads the 5 real collections per pod and emits a `PodGraph`:
|
||||||
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.
|
| 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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0b0f17" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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" />
|
<ShieldCheckIcon data-icon="inline-start" />
|
||||||
Privacy-limited
|
Privacy-limited
|
||||||
</Badge>
|
</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}>
|
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
|
||||||
<RefreshCwIcon data-icon="inline-start" />
|
<RefreshCwIcon data-icon="inline-start" />
|
||||||
Refresh
|
Refresh
|
||||||
@@ -342,6 +338,7 @@ export default function App() {
|
|||||||
onRemoveMember={handleRemoveMember}
|
onRemoveMember={handleRemoveMember}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onOpenGraph={setGraphPodId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,95 +1,36 @@
|
|||||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
|
import type { PodGraph } from '@podman/shared';
|
||||||
import { fetchPodGraph } from '../lib/graph.js';
|
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> = {
|
// Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final
|
||||||
engineer: '#3B5BFF',
|
// keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming.
|
||||||
file: '#ECE7DA',
|
const GRAPH_CSS = `
|
||||||
feature: '#F6C445',
|
.pm-node{cursor:grab;transition:opacity .25s ease}
|
||||||
collision: '#E2403A',
|
.pm-node:active{cursor:grabbing}
|
||||||
intervention: '#8b6cff',
|
.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}
|
||||||
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
|
.pm-dim{opacity:.14}
|
||||||
owns: { c: '#3B5BFF', w: 2.6 },
|
.pm-enter{animation:pm-fade .45s ease}
|
||||||
editing: { c: '#ECE7DA', w: 2 },
|
.pm-dash{animation:pm-flow 1s linear infinite}
|
||||||
touches: { c: '#5d5d66', w: 1.6 },
|
.pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite}
|
||||||
collides: { c: '#E2403A', w: 3.2 },
|
@keyframes pm-fade{from{opacity:0}to{opacity:1}}
|
||||||
warns: { c: '#F6C445', w: 3.2 },
|
@keyframes pm-flow{to{stroke-dashoffset:-26}}
|
||||||
learned_from: { c: '#8b6cff', w: 2.4, dash: true },
|
@keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}}
|
||||||
};
|
@media (prefers-reduced-motion:reduce){
|
||||||
|
.pm-enter,.pm-dash,.pm-pulse{animation:none}
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
`;
|
||||||
|
|
||||||
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 }) {
|
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
|
||||||
const [graph, setGraph] = useState<PodGraph | null>(null);
|
const [graph, setGraph] = useState<PodGraph | null>(null);
|
||||||
@@ -99,29 +40,67 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
let nudge: number | null = null;
|
||||||
setGraph(null);
|
setGraph(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSelected(null);
|
||||||
|
|
||||||
|
const load = () =>
|
||||||
fetchPodGraph(podId)
|
fetchPodGraph(podId)
|
||||||
.then((g) => alive && setGraph(g))
|
.then((g) => {
|
||||||
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
|
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 () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
window.clearInterval(poll);
|
||||||
|
if (nudge != null) window.clearTimeout(nudge);
|
||||||
|
ws?.close();
|
||||||
};
|
};
|
||||||
}, [podId]);
|
}, [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 nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
|
||||||
const sel = selected ? nodeById.get(selected) : undefined;
|
// A selected node can vanish across a poll/WS refresh. Ignore a stale id so the
|
||||||
const relCount = selected
|
// graph doesn't dim entirely (highlightFor would otherwise light only a dead id).
|
||||||
? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length
|
const liveSelected = selected && nodeById.has(selected) ? selected : null;
|
||||||
: 0;
|
useEffect(() => {
|
||||||
|
if (selected && graph && !nodeById.has(selected)) setSelected(null);
|
||||||
|
}, [graph, nodeById, selected]);
|
||||||
|
|
||||||
const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false);
|
const highlight = useMemo(
|
||||||
const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false);
|
() => (graph ? highlightFor(graph, mode, liveSelected) : null),
|
||||||
const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false);
|
[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) {
|
function pick(next: Mode) {
|
||||||
setMode(next);
|
setMode(next);
|
||||||
@@ -129,200 +108,111 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pm-graph">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<style>{`
|
<style>{GRAPH_CSS}</style>
|
||||||
.pm-graph{--bg:#0c0c0e;--panel:#141417;--line:#2a2a31;--paper:#ECE7DA;--mut:#8d897e;--red:#E2403A;--yel:#F6C445;--vio:#8b6cff;
|
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||||
font-family:'Space Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--paper);border:1px solid var(--line);border-radius:14px;overflow:hidden}
|
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
|
||||||
.pm-graph *{box-sizing:border-box}
|
{/* Header */}
|
||||||
.pm-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:3px solid var(--paper)}
|
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
|
||||||
.pm-ttl{font-weight:800;font-size:18px;letter-spacing:.14em;text-transform:uppercase;font-family:Archivo,'Space Grotesk',sans-serif}
|
<div className="min-w-0">
|
||||||
.pm-sub{font-size:10px;letter-spacing:.3em;color:var(--mut);text-transform:uppercase;margin-top:5px}
|
<div className="flex items-center gap-2">
|
||||||
.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}
|
<h2 className="font-heading text-base font-medium">Team memory</h2>
|
||||||
.pm-x:hover{border-color:var(--paper)}
|
<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">
|
||||||
.pm-bar{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
<span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
|
||||||
.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}
|
Live
|
||||||
.pm-btn:hover{border-color:var(--paper)}
|
</span>
|
||||||
.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>
|
</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
|
← Pods
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pm-bar">
|
{/* Toggles */}
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
|
||||||
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`}
|
<ToggleGroup
|
||||||
onClick={() => pick('risk')}
|
type="single"
|
||||||
|
value={mode}
|
||||||
|
onValueChange={(v) => v && pick(v as Mode)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
Risk path
|
<ToggleGroupItem value="risk">Risk path</ToggleGroupItem>
|
||||||
</button>
|
<ToggleGroupItem value="learn">Learning edges</ToggleGroupItem>
|
||||||
<button
|
<ToggleGroupItem value="all">Whole graph</ToggleGroupItem>
|
||||||
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`}
|
</ToggleGroup>
|
||||||
onClick={() => pick('learn')}
|
<span className="ml-auto hidden text-xs text-muted-foreground sm:inline">
|
||||||
>
|
Drag to rearrange · double-click to release · click to inspect
|
||||||
Learning edges
|
</span>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
|
|
||||||
onClick={() => pick('all')}
|
|
||||||
>
|
|
||||||
Whole graph
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
|
||||||
<p style={{ padding: '16px', color: '#ff7d76', fontSize: 13 }}>Graph error: {error}</p>
|
|
||||||
)}
|
|
||||||
{!graph && !error && (
|
{!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 && (
|
{graph && (
|
||||||
<>
|
<>
|
||||||
<div className="pm-grid">
|
{/* Metrics · graph · learning loop */}
|
||||||
<div className="pm-col">
|
<div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
|
||||||
<div className="pm-st">Workflow metrics</div>
|
<MetricsRail metrics={graph.metrics} />
|
||||||
{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>
|
|
||||||
|
|
||||||
<div className="pm-canvas">
|
<div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
|
||||||
<svg viewBox="0 0 720 472" role="img" aria-label="PodMan team-memory graph">
|
<GraphCanvas
|
||||||
{graph.edges.map((e) => {
|
graph={graph}
|
||||||
const a = nodeById.get(e.source);
|
highlight={highlight}
|
||||||
const b = nodeById.get(e.target);
|
selected={liveSelected}
|
||||||
if (!a || !b) return null;
|
onSelect={setSelected}
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
{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>
|
||||||
|
|
||||||
<div className="pm-col pm-railR">
|
{graph.loop?.length ? (
|
||||||
{sel ? (
|
<LearningLoop stages={graph.loop} />
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div />
|
||||||
<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>
|
||||||
|
|
||||||
<div className="pm-legend">
|
{/* Legend */}
|
||||||
{LEGEND.map((l) => (
|
<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">
|
||||||
<span className="pm-lg" key={l.label}>
|
{NODE_LEGEND.map((l) => (
|
||||||
<span className="pm-sw" style={l.swatch} />
|
<span key={l.label} className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block size-3" style={l.swatch} />
|
||||||
{l.label}
|
{l.label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span className="pm-lg">
|
<span className="mx-1 h-3 w-px bg-border" aria-hidden />
|
||||||
<span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
|
{EDGE_LEGEND.map((l) => (
|
||||||
collides
|
<span key={l.label} className="flex items-center gap-1.5">
|
||||||
</span>
|
<span
|
||||||
<span className="pm-lg">
|
className="inline-block h-[3px] w-3.5"
|
||||||
<span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
|
style={
|
||||||
learned_from
|
l.dash
|
||||||
|
? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
|
||||||
|
: { background: l.color }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{l.label}
|
||||||
</span>
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useState } from 'react';
|
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 type { Pod, PodInput } from '@podman/shared';
|
||||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -42,6 +49,7 @@ export function PodCard({
|
|||||||
onRemoveMember: _onRemoveMember,
|
onRemoveMember: _onRemoveMember,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onOpenGraph,
|
||||||
}: {
|
}: {
|
||||||
pod: Pod;
|
pod: Pod;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
@@ -52,6 +60,7 @@ export function PodCard({
|
|||||||
onRemoveMember: (id: string, name: string) => void;
|
onRemoveMember: (id: string, name: string) => void;
|
||||||
onUpdate: (id: string, patch: PodInput) => void;
|
onUpdate: (id: string, patch: PodInput) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onOpenGraph: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [newMember, setNewMember] = useState('');
|
const [newMember, setNewMember] = useState('');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -100,6 +109,10 @@ export function PodCard({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onSelect={() => onOpenGraph(pod.id)}>
|
||||||
|
<BrainCircuitIcon />
|
||||||
|
Team memory
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setEditing(true)}>Edit pod</DropdownMenuItem>
|
<DropdownMenuItem onSelect={() => setEditing(true)}>Edit pod</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
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}`);
|
if (!res.ok) throw new Error(`graph request failed: ${res.status}`);
|
||||||
return res.json() as Promise<PodGraph>;
|
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,
|
accepted,
|
||||||
recordedAt: new Date().toISOString(),
|
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);
|
setActive(null);
|
||||||
return status;
|
return status;
|
||||||
},
|
},
|
||||||
[active],
|
[active, room],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { active, hermes, voiceCue, actionUrl, respond };
|
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: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-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_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||||
@@ -73,7 +74,8 @@ workers:
|
|||||||
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
- { key: LIVEKIT_API_SECRET, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
- { key: GEMINI_API_KEY, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
- { key: GEMINI_VISION_MODEL, scope: RUN_TIME, value: gemini-2.0-flash }
|
||||||
- { key: GEMINI_LIVE_MODEL, scope: RUN_TIME, value: gemini-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_TOKEN, scope: RUN_TIME, type: SECRET }
|
||||||
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
- { key: GITHUB_REPO, scope: RUN_TIME, value: karti-ai/podman }
|
||||||
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
- { key: MONGODB_URI, scope: RUN_TIME, type: SECRET }
|
||||||
|
|||||||
@@ -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
|
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
|
## Local production services
|
||||||
|
|
||||||
On the demo droplet, serve the API and worker with systemd instead of tmux:
|
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",
|
"hermes:install": "node scripts/install-hermes-ops.mjs",
|
||||||
"healthcheck:public": "node scripts/healthcheck-public.mjs",
|
"healthcheck:public": "node scripts/healthcheck-public.mjs",
|
||||||
"verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend",
|
"verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend",
|
||||||
"verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers",
|
"verify:full": "pnpm verify && pnpm verify:infra && pnpm build:container && pnpm verify:containers",
|
||||||
"verify:backend": "node scripts/verify-backend.mjs",
|
"verify:backend": "node scripts/verify-backend.mjs",
|
||||||
"verify:containers": "node scripts/verify-containers.mjs",
|
"verify:containers": "node scripts/verify-containers.mjs",
|
||||||
"verify:frontend": "node scripts/verify-frontend.mjs",
|
"verify:frontend": "node scripts/verify-frontend.mjs",
|
||||||
|
"verify:infra": "node scripts/verify-infra.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ."
|
||||||
|
|||||||
@@ -353,11 +353,10 @@ await check('gemini embeddings', checkGeminiEmbeddings);
|
|||||||
|
|
||||||
if (isSet('VOYAGE_API_KEY')) {
|
if (isSet('VOYAGE_API_KEY')) {
|
||||||
await check('voyage embeddings', checkVoyage);
|
await check('voyage embeddings', checkVoyage);
|
||||||
await check('atlas vector index', checkVectorIndex);
|
|
||||||
} else {
|
} else {
|
||||||
add('voyage embeddings', 'warn', 'VOYAGE_API_KEY is optional; exact Mongo recall remains active');
|
add('voyage embeddings', 'warn', 'VOYAGE_API_KEY is optional; Gemini embeddings are active');
|
||||||
add('atlas vector index', 'warn', 'requires VOYAGE_API_KEY and Atlas Search index');
|
|
||||||
}
|
}
|
||||||
|
await check('atlas vector index', checkVectorIndex);
|
||||||
|
|
||||||
const failed = results.filter((r) => r.status === 'fail');
|
const failed = results.filter((r) => r.status === 'fail');
|
||||||
const warnings = results.filter((r) => r.status === 'warn');
|
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';
|
import { config as loadEnv } from 'dotenv';
|
||||||
|
|
||||||
const image = process.env.VERIFY_CONTAINER_IMAGE ?? 'podman-backend';
|
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 port = Number(process.env.VERIFY_CONTAINER_PORT ?? 8799);
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
const runId = `${process.pid}-${Date.now()}`;
|
const runId = `${process.pid}-${Date.now()}`;
|
||||||
@@ -21,14 +22,19 @@ const containerEnv = {
|
|||||||
LIVEKIT_API_KEY: process.env.LIVEKIT_API_KEY ?? 'verify-key',
|
LIVEKIT_API_KEY: process.env.LIVEKIT_API_KEY ?? 'verify-key',
|
||||||
LIVEKIT_API_SECRET: process.env.LIVEKIT_API_SECRET ?? 'verify-secret',
|
LIVEKIT_API_SECRET: process.env.LIVEKIT_API_SECRET ?? 'verify-secret',
|
||||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY ?? 'verify-gemini',
|
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_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github',
|
||||||
GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman',
|
GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman',
|
||||||
MONGODB_URI: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/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) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn('podman', args, {
|
const child = spawn(runtime, args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
@@ -61,12 +67,15 @@ function fail(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function assertPodmanAvailable() {
|
async function assertPodmanAvailable() {
|
||||||
const result = await runPodman(['--version']);
|
const result = await runContainer(['--version']);
|
||||||
if (result.code !== 0) fail(`podman is not available: ${result.stderr.trim()}`);
|
if (result.code !== 0) fail(`${runtime} is not available: ${result.stderr.trim()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertImageExists() {
|
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) {
|
if (result.code !== 0) {
|
||||||
fail(
|
fail(
|
||||||
`container image "${image}" does not exist locally; build it before running this verifier`,
|
`container image "${image}" does not exist locally; build it before running this verifier`,
|
||||||
@@ -75,18 +84,23 @@ async function assertImageExists() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeContainer(name) {
|
async function removeContainer(name) {
|
||||||
await runPodman(['rm', '-f', name]);
|
await runContainer(['rm', '-f', name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startContainer(name, extraEnv) {
|
async function startContainer(name, extraEnv) {
|
||||||
await removeContainer(name);
|
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',
|
'run',
|
||||||
'--detach',
|
'--detach',
|
||||||
'--name',
|
'--name',
|
||||||
name,
|
name,
|
||||||
'--network',
|
...networkArgs,
|
||||||
'host',
|
|
||||||
...envArgs(extraEnv),
|
...envArgs(extraEnv),
|
||||||
image,
|
image,
|
||||||
]);
|
]);
|
||||||
@@ -96,7 +110,7 @@ async function startContainer(name, extraEnv) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function stopContainer(name) {
|
async function stopContainer(name) {
|
||||||
await runPodman(['stop', '--time', '3', name]);
|
await runContainer(['stop', '--time', '3', name]);
|
||||||
await removeContainer(name);
|
await removeContainer(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +139,7 @@ async function waitForApi() {
|
|||||||
}
|
}
|
||||||
await delay(500);
|
await delay(500);
|
||||||
}
|
}
|
||||||
const logs = await runPodman(['logs', apiContainer]);
|
const logs = await runContainer(['logs', apiContainer]);
|
||||||
fail(
|
fail(
|
||||||
`API container did not become healthy at ${baseUrl}: ${lastError}\n${logs.stdout}${logs.stderr}`,
|
`API container did not become healthy at ${baseUrl}: ${lastError}\n${logs.stdout}${logs.stderr}`,
|
||||||
);
|
);
|
||||||
@@ -154,11 +168,11 @@ async function verifyAgentContainer() {
|
|||||||
|
|
||||||
let output = '';
|
let output = '';
|
||||||
for (let i = 0; i < 60; i++) {
|
for (let i = 0; i < 60; i++) {
|
||||||
const logs = await runPodman(['logs', agentContainer]);
|
const logs = await runContainer(['logs', agentContainer]);
|
||||||
output = `${logs.stdout}${logs.stderr}`;
|
output = `${logs.stdout}${logs.stderr}`;
|
||||||
if (output.includes('podman-hermes joined room')) return;
|
if (output.includes('podman-hermes joined room')) return;
|
||||||
|
|
||||||
const inspect = await runPodman([
|
const inspect = await runContainer([
|
||||||
'inspect',
|
'inspect',
|
||||||
'--format',
|
'--format',
|
||||||
'{{.State.Running}} {{.State.ExitCode}}',
|
'{{.State.Running}} {{.State.ExitCode}}',
|
||||||
@@ -182,10 +196,17 @@ try {
|
|||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
|
runtime,
|
||||||
image,
|
image,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
containers: [apiContainer, agentContainer],
|
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,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { createRequire } from 'node:module';
|
|||||||
import { TextEncoder } from 'node:util';
|
import { TextEncoder } from 'node:util';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
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 frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:4173/';
|
||||||
const shouldStartPreview = !process.env.FRONTEND_URL;
|
const shouldStartPreview = !process.env.FRONTEND_URL;
|
||||||
const doFetch = globalThis.fetch;
|
const doFetch = globalThis.fetch;
|
||||||
@@ -73,7 +76,7 @@ async function connectPublisher(roomName) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const room = new Room();
|
const room = new Room();
|
||||||
await room.connect(url, token);
|
await room.connect(url, token, { autoSubscribe: true });
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,15 +110,23 @@ async function publishIntervention(room, podId) {
|
|||||||
reliable: true,
|
reliable: true,
|
||||||
topic: DATA_TOPIC,
|
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) {
|
async function waitForInterventionCard(page, room, podId) {
|
||||||
const cardText = 'Verification collision: two engineers are editing frontend/src/App.tsx.';
|
const cardText = 'Verification collision: two engineers are editing frontend/src/App.tsx.';
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
await publishIntervention(room, podId);
|
const intervention = await publishIntervention(room, podId);
|
||||||
try {
|
try {
|
||||||
await page.getByText(cardText).waitFor({ timeout: 5_000 });
|
await page.getByText(cardText).waitFor({ timeout: 5_000 });
|
||||||
return;
|
return intervention;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (attempt === 3) throw error;
|
if (attempt === 3) throw error;
|
||||||
await delay(500);
|
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;
|
let preview = null;
|
||||||
if (shouldStartPreview) {
|
if (shouldStartPreview) {
|
||||||
preview = spawn(
|
preview = spawn(
|
||||||
@@ -139,6 +184,7 @@ if (shouldStartPreview) {
|
|||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
|
globalThis.__podmanVerifyScreens = [];
|
||||||
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
|
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
@@ -149,12 +195,21 @@ await page.addInitScript(() => {
|
|||||||
canvas.height = 360;
|
canvas.height = 360;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) throw new Error('canvas context unavailable');
|
if (!ctx) throw new Error('canvas context unavailable');
|
||||||
|
let frame = 0;
|
||||||
|
const draw = () => {
|
||||||
|
frame += 1;
|
||||||
ctx.fillStyle = '#fff';
|
ctx.fillStyle = '#fff';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.fillStyle = '#111';
|
ctx.fillStyle = '#111';
|
||||||
ctx.font = '28px sans-serif';
|
ctx.font = '28px sans-serif';
|
||||||
ctx.fillText('PodMan verification screen', 32, 72);
|
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: 'Share screen' }).click();
|
||||||
await page.getByRole('button', { name: 'Stop sharing' }).waitFor({ timeout: 15_000 });
|
await page.getByRole('button', { name: 'Stop sharing' }).waitFor({ timeout: 15_000 });
|
||||||
await page.getByText(/Screen\s*published/i).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: 'Stop sharing' }).click();
|
||||||
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||||
|
|
||||||
const publisher = await connectPublisher('frontend-pod');
|
const publisher = await connectPublisher('frontend-pod');
|
||||||
try {
|
try {
|
||||||
await waitForInterventionCard(page, publisher, 'frontend-pod');
|
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.getByRole('button', { name: 'Dismiss' }).click();
|
||||||
await page.getByText('No collision detected').waitFor({ timeout: 15_000 });
|
await page.getByText('No collision detected').waitFor({ timeout: 15_000 });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -240,11 +320,12 @@ try {
|
|||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
frontendUrl,
|
frontendUrl,
|
||||||
|
apiBase,
|
||||||
bodyLength: bodyText.length,
|
bodyLength: bodyText.length,
|
||||||
graph: true,
|
graph: true,
|
||||||
joined: true,
|
joined: true,
|
||||||
screenShare: true,
|
screenShare: 'livekit-published',
|
||||||
intervention: true,
|
intervention: 'collision-hermes-voice',
|
||||||
member: verifyMember,
|
member: verifyMember,
|
||||||
},
|
},
|
||||||
null,
|
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;
|
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. */
|
/** A point-in-time render of a pod's team_model. */
|
||||||
export interface PodGraph {
|
export interface PodGraph {
|
||||||
podId: string;
|
podId: string;
|
||||||
@@ -56,6 +86,10 @@ export interface PodGraph {
|
|||||||
nodes: PodGraphNode[];
|
nodes: PodGraphNode[];
|
||||||
edges: PodGraphEdge[];
|
edges: PodGraphEdge[];
|
||||||
metrics: PodGraphMetric[];
|
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. */
|
/** One node as a standalone document in the `graph_nodes` collection. */
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export type {
|
|||||||
PodGraphNodeKind,
|
PodGraphNodeKind,
|
||||||
PodGraphEdgeKind,
|
PodGraphEdgeKind,
|
||||||
PodGraphNodeStatus,
|
PodGraphNodeStatus,
|
||||||
|
LearningStage,
|
||||||
|
LearningStageKey,
|
||||||
|
ActivityEvent,
|
||||||
|
ActivityKind,
|
||||||
GraphNodeDoc,
|
GraphNodeDoc,
|
||||||
GraphEdgeDoc,
|
GraphEdgeDoc,
|
||||||
} from './graph.js';
|
} from './graph.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user