Merge pull request #22 from karti-ai/feat/team-memory-dynamic-graph

feat(graph): dynamic force-directed Team-memory graph + learning-loop & activity rails
This commit is contained in:
sb-iam
2026-06-28 02:20:04 -07:00
committed by GitHub
25 changed files with 2785 additions and 271 deletions
+44 -6
View File
@@ -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: [
+249 -12
View File
@@ -6,6 +6,13 @@ import type {
PodGraphNodeKind, PodGraphNodeKind,
PodGraphEdgeKind, PodGraphEdgeKind,
PodGraphNodeStatus, PodGraphNodeStatus,
LearningStage,
LearningStageKey,
ActivityEvent,
EngineerContext,
Collision,
Intervention,
InterventionOutcome,
} from '@podman/shared'; } from '@podman/shared';
import { collections, getGitStates, getDb } from '../memory/db.js'; import { collections, getGitStates, getDb } from '../memory/db.js';
@@ -148,6 +155,185 @@ function layout(nodes: PodGraphNode[]): void {
const SEVERITY_WEIGHT: Record<string, number> = { info: 0.4, warn: 0.7, critical: 1 }; 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> { export async function materializePodGraph(podId: string): Promise<PodGraph | null> {
const c = await collections(); const c = await collections();
const db = await getDb(); const db = await getDb();
@@ -363,38 +549,89 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
layout(nodes); 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 acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
const totalOutcomes = outcomeDocs.length; const totalOutcomes = outcomeDocs.length;
const riskPaths = new Set( const acceptRate = totalOutcomes ? Math.round((acceptedReal / totalOutcomes) * 100) : null;
collisionDocs.map(
(col) =>
(col as { memorySignature?: string }).memorySignature ??
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
),
).size;
const metrics: PodGraphMetric[] = [ const metrics: PodGraphMetric[] = [
{ {
label: 'Learned owners', label: 'Learned owners',
value: String(acceptedReal), value: String(learnedOwners),
detail: 'Ownership retained from accepted interventions.', detail: 'Distinct owners retained from accepted interventions.',
}, },
{ {
label: 'Open risk paths', label: 'Open risk paths',
value: String(riskPaths), value: String(riskPaths),
detail: 'Files with two or more converging editors.', detail: `${riskPaths === 1 ? 'File' : 'Files'} with two or more converging editors.`,
}, },
{ {
label: 'Accept rate', label: 'Accept rate',
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—', value: acceptRate == null ? '—' : `${acceptRate}%`,
detail: 'Interventions accepted this session.', 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 { return {
podId, podId,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
nodes, nodes,
edges: [...b.edges.values()], edges: [...b.edges.values()],
metrics, metrics,
loop,
activity,
}; };
} }
+89
View File
@@ -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.
+84
View File
@@ -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.
+74
View File
@@ -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.
+185
View File
@@ -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?"
+69
View File
@@ -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.
+97
View File
@@ -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.
+87
View File
@@ -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.
+217
View File
@@ -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.
+69
View File
@@ -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.
+83
View File
@@ -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.
+81
View File
@@ -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.
+146
View File
@@ -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.
+154 -253
View File
@@ -1,111 +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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; 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;
// Fixed, light-readable hues for the node/edge encoding (kept stable across // Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens). // keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming.
const BLUE = '#2563eb'; const GRAPH_CSS = `
const SLATE = '#475569'; .pm-node{cursor:grab;transition:opacity .25s ease}
const SLATE_EDGE = '#94a3b8'; .pm-node:active{cursor:grabbing}
const SLATE_FAINT = '#cbd5e1'; .pm-edge{transition:opacity .25s ease}
const AMBER = '#d97706'; .pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none;
const RED = '#dc2626'; paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round}
const VIOLET = '#7c3aed'; .pm-dim{opacity:.14}
.pm-enter{animation:pm-fade .45s ease}
const KIND_COLOR: Record<PodGraphNodeKind, string> = { .pm-dash{animation:pm-flow 1s linear infinite}
engineer: BLUE, .pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite}
file: SLATE, @keyframes pm-fade{from{opacity:0}to{opacity:1}}
feature: AMBER, @keyframes pm-flow{to{stroke-dashoffset:-26}}
collision: RED, @keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}}
intervention: VIOLET, @media (prefers-reduced-motion:reduce){
}; .pm-enter,.pm-dash,.pm-pulse{animation:none}
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
owns: { c: BLUE, w: 2.6 },
editing: { c: SLATE_EDGE, w: 2 },
touches: { c: SLATE_FAINT, w: 1.6 },
collides: { c: RED, w: 3.2 },
warns: { c: AMBER, w: 3.2 },
learned_from: { c: VIOLET, w: 2.4, dash: true },
};
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} rx={4} fill={c} />;
case 'file':
return (
<rect
x={x - 15}
y={y - 15}
width={30}
height={30}
rx={4}
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: BLUE } },
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
];
function statusColor(status: string): string {
if (status === 'risk') return RED;
if (status === 'learned') return VIOLET;
return 'var(--foreground)';
}
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) { export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
const [graph, setGraph] = useState<PodGraph | null>(null); const [graph, setGraph] = useState<PodGraph | null>(null);
@@ -115,198 +40,174 @@ 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);
fetchPodGraph(podId) setSelected(null);
.then((g) => alive && setGraph(g))
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e))); const load = () =>
fetchPodGraph(podId)
.then((g) => {
if (alive) {
setGraph(g);
setError(null);
}
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
});
void load();
const poll = window.setInterval(() => void load(), POLL_MS);
// Best-effort realtime nudge: refetch (debounced) when the agent broadcasts.
let ws: WebSocket | null = null;
try {
ws = new WebSocket(backendEventsUrl());
ws.onmessage = () => {
if (nudge != null) return;
nudge = window.setTimeout(() => {
nudge = null;
void load();
}, 800);
};
} catch {
/* event bus is optional */
}
return () => { 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);
setSelected(null); setSelected(null);
} }
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<style>{GRAPH_CSS}</style>
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8"> <div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<style>{` <div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
.pm-node{cursor:pointer} {/* Header */}
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500} <div className="flex items-center justify-between gap-3 border-b px-5 py-4">
.pm-dim{opacity:.18;transition:opacity .25s} <div className="min-w-0">
`}</style> <div className="flex items-center gap-2">
<h2 className="font-heading text-base font-medium">Team memory</h2>
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground"> <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">
<div className="flex items-center justify-between border-b px-5 py-4"> <span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
<div> Live
<h2 className="text-base font-medium">Team memory</h2> </span>
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p> </div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
What PodMan learned · {podId}
</p>
</div> </div>
<Button variant="outline" size="sm" onClick={onClose}> <Button variant="outline" size="sm" onClick={onClose}>
Pods Pods
</Button> </Button>
</div> </div>
<div className="flex flex-wrap gap-2 border-b px-4 py-3"> {/* Toggles */}
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}> <div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
Risk path <ToggleGroup
</Button> type="single"
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}> value={mode}
Learning edges onValueChange={(v) => v && pick(v as Mode)}
</Button> variant="outline"
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}> size="sm"
Whole graph >
</Button> <ToggleGroupItem value="risk">Risk path</ToggleGroupItem>
<ToggleGroupItem value="learn">Learning edges</ToggleGroupItem>
<ToggleGroupItem value="all">Whole graph</ToggleGroupItem>
</ToggleGroup>
<span className="ml-auto hidden text-xs text-muted-foreground sm:inline">
Drag to rearrange · double-click to release · click to inspect
</span>
</div> </div>
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>} {error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
{!graph && !error && ( {!graph && !error && (
<p className="px-4 py-4 text-sm text-muted-foreground">Loading graph</p> <p className="px-4 py-10 text-center text-sm text-muted-foreground">Loading graph</p>
)} )}
{graph && ( {graph && (
<> <>
<div className="grid lg:grid-cols-[190px_1fr_250px]"> {/* Metrics · graph · learning loop */}
<div className="space-y-3 p-4"> <div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <MetricsRail metrics={graph.metrics} />
Workflow metrics
</p> <div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
{graph.metrics.map((m) => ( <GraphCanvas
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5"> graph={graph}
<p className="text-2xl font-medium tabular-nums">{m.value}</p> highlight={highlight}
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground"> selected={liveSelected}
{m.label} onSelect={setSelected}
</p> />
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
</div>
))}
</div> </div>
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0"> {graph.loop?.length ? (
<svg <LearningLoop stages={graph.loop} />
viewBox="0 0 720 472" ) : (
role="img" <div />
aria-label="PodMan team-memory graph" )}
className="block h-auto w-full" </div>
>
{graph.edges.map((e) => {
const a = nodeById.get(e.source);
const b = nodeById.get(e.target);
if (!a || !b) return null;
const s = EDGE[e.kind];
return (
<line
key={e.id}
className={dimEdge(e.id) ? 'pm-dim' : undefined}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke={s.c}
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
strokeDasharray={s.dash ? '7 6' : undefined}
strokeLinecap="round"
/>
);
})}
{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}
</text>
</g>
))}
</svg>
</div>
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0"> {/* Activity stream · selected node */}
{sel ? ( <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">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <ActivityStream events={graph.activity ?? []} />
{sel.kind} </div>
</p> <div className="rounded-xl border bg-muted/40 p-4">
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3> <SelectedNodePanel node={sel} relCount={relCount} flow={flow} mode={mode} />
<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(sel.status) }}>
{sel.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>
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
{sel.summary}
</p>
</>
) : (
<>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Continual learning
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
The violet{' '}
<span className="font-medium" style={{ color: VIOLET }}>
learned_from
</span>{' '}
edges are ownership PodMan retained from accepted interventions the graph
gets sharper every session. Click any node to trace its relationships.
</p>
</>
)}
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground"> {/* 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">
{NODE_LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5"> <span key={l.label} className="flex items-center gap-1.5">
<span className="inline-block size-3" style={l.swatch} /> <span className="inline-block size-3" style={l.swatch} />
{l.label} {l.label}
</span> </span>
))} ))}
<span className="flex items-center gap-1.5"> <span className="mx-1 h-3 w-px bg-border" aria-hidden />
<span className="inline-block h-[3px] w-3" style={{ background: RED }} /> {EDGE_LEGEND.map((l) => (
collides <span key={l.label} className="flex items-center gap-1.5">
</span> <span
<span className="flex items-center gap-1.5"> className="inline-block h-[3px] w-3.5"
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} /> style={
learned_from l.dash
</span> ? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
: { background: l.color }
}
/>
{l.label}
</span>
))}
</div> </div>
</> </>
)} )}
@@ -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>
);
}
+209
View File
@@ -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 },
];
+284
View File
@@ -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;
}
}
}
}
+5
View File
@@ -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`;
}
+34
View File
@@ -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. */
+4
View File
@@ -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';