7 Commits

Author SHA1 Message Date
sb-iam 8f11d7792a Merge pull request #22 from karti-ai/feat/team-memory-dynamic-graph
feat(graph): dynamic force-directed Team-memory graph + learning-loop & activity rails
2026-06-28 02:20:04 -07:00
sb-iam 9870a1c710 docs: add learning and graph specs 2026-06-28 00:34:47 -07:00
sb-iam 5e9929f8da fix(graph): honest graph-consistent metrics + flow narrative on node click
Address review feedback on the live view:

Metrics looked fake because they counted raw DB events (test churn) instead of
the de-noised entities actually drawn — e.g. "Open risk paths: 50" for 2 files,
"Learned owners: 16" with 2 ownership edges, and the caption ("Files with 2+
editors") contradicting the number. Now derived from the final graph:
- Open risk paths = distinct files carrying a surviving collision (50 -> 4 on live).
- Learned owners  = distinct engineers retained as owners via owns/learned_from
  edges (16 -> 1 on live).
- Accept rate     = accepted vs total real outcomes.
demo.ts metrics + loop counts realigned to its own graph (3 owners / 1 risk path
/ 100%) so nothing contradicts the picture; the PREDICT/ADAPT loop stages reuse
the same de-noised counts.

Right pane now explains the flow: clicking a node renders a plain-English walk of
its path (flowNarrative) — "Karti and Yahya are both editing auth.ts before
pushing ... PodMan suggested a sync PR", "PodMan offered a sync PR for the overlap
on auth.ts. The pod accepted it, so PodMan learned Karti owns auth.ts." With no
selection the panel gives a mode-aware explainer of what the lit path means. Edge
legend rounded out with editing/touches.

Verified: lint + -r typecheck + -r build pass; Playwright confirmed the flow text
per node kind and the demo metrics (3/1/100%); the metric formula re-checked
against the real live graph (4 risk files / 1 learned owner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:47:14 -07:00
sb-iam c97863e05e feat(graph): dynamic force-directed Team-memory graph + learning-loop & activity rails
Rebuild the live "Team memory" view so the light/real-data version matches the
dark Bauhaus mock and the graph is genuinely DYNAMIC instead of dead static columns.

Frontend (frontend/src/components/graph/*, composed into GraphView.tsx):
- forceSim.ts: a tiny dependency-free force layout (charge repulsion, link springs,
  centroid recentering + gentle pull, 2-pass collision, bounds clamp, alpha anneal).
  No d3-force dependency added — keeps the shared pnpm-lock untouched so CI's
  frozen-lockfile install and the deploy path are unaffected.
- GraphCanvas.tsx: SVG render driven by the sim — draggable + pinnable nodes
  (double-click to release), curved edges that fan parallel pairs, weight-sized
  geometric node shapes, fade-in on new nodes/edges, animated learned_from dash,
  risk-path lighting with the rest dimmed, label collision-avoidance.
- MetricsRail / LearningLoop / ActivityStream / SelectedNodePanel / encoding.ts:
  the mock's rails + stream + detail panel, light shadcn (ToggleGroup, ScrollArea,
  Badge, Button) on theme tokens; only the SVG is bespoke.
- GraphView polls /api/pods/:id/graph every 5s and diffs (positions preserved across
  refreshes), with a best-effort ws /api/events nudge. A stale selection (node gone
  across a poll) is dropped so the canvas can't dim entirely.

Backend (additive — materializer de-noise untouched):
- live.ts: buildLoop() (observe→store→predict→outcome→adapt counts, deepest-recent
  stage active) and buildActivity() (time-sorted typed feed, same isFilePath /
  ENGINEER_NOISE / signature de-noise) emitted alongside nodes/edges/metrics.
- demo.ts: fallback loop + activity so the panels render on the demo path.
- shared/src/graph.ts: additive optional PodGraph.loop / .activity + LearningStage /
  ActivityEvent types.

Verified: pnpm lint + -r typecheck + -r build pass; Playwright on the dev build
confirmed force layout (distinct positions, ticks on load under StrictMode), drag,
risk-mode dimming (opacity 0.14), selection panel, legend, and no overlaps on both
the clean demo and the 31-node live hairball.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:27:54 -07:00
sb-iam 03a8af6da0 docs: add codex team memory redesign brief 2026-06-27 22:39:07 -07:00
sb-iam 62f5d26f83 docs: team-memory redesign brief as claude_team_memory_redesign.md
Renamed to a root-level, easy-to-discover name so a fresh Claude Code session
can pick it up directly. Committed locally only (not pushed).
2026-06-27 22:36:02 -07:00
sb-iam ad73659389 docs: deep redesign brief for the live Team-memory graph
Self-contained handoff for a fresh session: rebuild the light/real-data graph to
the dark Bauhaus mock's quality + structure (metrics rail, learning-loop rail,
activity stream, selected-node panel) and make the graph dynamic (force-directed
+ animated), in light shadcn. Captures architecture, data, the dynamic-layout
options, what to feed the new panels, files, gotchas, and acceptance criteria.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:28:01 -07:00
27 changed files with 3561 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.
*/
export function createDemoPodGraph(podId: string): PodGraph {
const base = Date.now();
const at = (secAgo: number): string => new Date(base - secAgo * 1000).toISOString();
return {
podId,
generatedAt: new Date().toISOString(),
loop: [
{ key: 'observe', title: 'OBSERVE', value: '5', detail: '~5/s vision contexts', active: false },
{ key: 'store', title: 'STORE', value: '124', detail: 'memory vectors · Atlas', active: false },
{ key: 'predict', title: 'PREDICT', value: '1', detail: 'open risk path', active: true },
{ key: 'outcome', title: 'OUTCOME', value: '1/0', detail: 'accepted · dismissed', active: false },
{ key: 'adapt', title: 'ADAPT', value: '3', detail: 'learned owners', active: false },
],
activity: [
{
id: 'demo-learn',
at: at(20),
kind: 'learned_from',
text: 'Memory updated: Karti owns auth.ts (confidence ↑)',
},
{ id: 'demo-out', at: at(24), kind: 'outcome', text: 'Intervention accepted by the pod' },
{
id: 'demo-warn',
at: at(40),
kind: 'warns',
text: 'PodMan: "Karti & Yahya are both in auth.ts — open a sync PR?" → card sent',
},
{
id: 'demo-col',
at: at(58),
kind: 'collision',
text: 'Critical overlap on auth.ts · Karti + Yahya',
},
{
id: 'demo-edit',
at: at(72),
kind: 'editing',
text: 'Yahya opened auth.ts — unpushed changes',
},
],
// Kept consistent with the graph below (3 owner engineers, 1 collision file,
// 1 of 1 interventions accepted) so the numbers never contradict the picture.
metrics: [
{
label: 'Learned owners',
value: '5',
detail: 'Ownership edges retained from accepted interventions.',
value: '3',
detail: 'Distinct owners retained from accepted interventions.',
},
{
label: 'Open risk paths',
value: '2',
detail: 'auth.ts and the memory API have converging editors.',
value: '1',
detail: 'File with two or more converging editors.',
},
{
label: 'Accept rate',
value: '86%',
detail: 'Interventions accepted this session (+14%).',
value: '100%',
detail: 'Interventions accepted vs total this session.',
},
],
nodes: [
+249 -12
View File
@@ -6,6 +6,13 @@ import type {
PodGraphNodeKind,
PodGraphEdgeKind,
PodGraphNodeStatus,
LearningStage,
LearningStageKey,
ActivityEvent,
EngineerContext,
Collision,
Intervention,
InterventionOutcome,
} from '@podman/shared';
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 };
/** 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();
@@ -363,38 +549,89 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
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 riskPaths = new Set(
collisionDocs.map(
(col) =>
(col as { memorySignature?: string }).memorySignature ??
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
),
).size;
const acceptRate = totalOutcomes ? Math.round((acceptedReal / totalOutcomes) * 100) : null;
const metrics: PodGraphMetric[] = [
{
label: 'Learned owners',
value: String(acceptedReal),
detail: 'Ownership retained from accepted interventions.',
value: String(learnedOwners),
detail: 'Distinct owners retained from accepted interventions.',
},
{
label: 'Open risk paths',
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',
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—',
detail: 'Interventions accepted this session.',
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,
};
}
+293
View File
@@ -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**.
+483
View File
@@ -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.
```
+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 type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
import { fetchPodGraph } from '../lib/graph.js';
import { useEffect, useMemo, useState } from 'react';
import type { PodGraph } from '@podman/shared';
import { fetchPodGraph, backendEventsUrl } from '../lib/graph.js';
import { Button } from '@/components/ui/button';
import { 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
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens).
const BLUE = '#2563eb';
const SLATE = '#475569';
const SLATE_EDGE = '#94a3b8';
const SLATE_FAINT = '#cbd5e1';
const AMBER = '#d97706';
const RED = '#dc2626';
const VIOLET = '#7c3aed';
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
engineer: BLUE,
file: SLATE,
feature: AMBER,
collision: RED,
intervention: VIOLET,
};
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;
// Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final
// keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming.
const GRAPH_CSS = `
.pm-node{cursor:grab;transition:opacity .25s ease}
.pm-node:active{cursor:grabbing}
.pm-edge{transition:opacity .25s ease}
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none;
paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round}
.pm-dim{opacity:.14}
.pm-enter{animation:pm-fade .45s ease}
.pm-dash{animation:pm-flow 1s linear infinite}
.pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite}
@keyframes pm-fade{from{opacity:0}to{opacity:1}}
@keyframes pm-flow{to{stroke-dashoffset:-26}}
@keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}}
@media (prefers-reduced-motion:reduce){
.pm-enter,.pm-dash,.pm-pulse{animation:none}
}
}
interface Highlight {
nodes: Set<string>;
edges: Set<string>;
}
function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
if (selected) {
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
return {
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
edges: new Set(es.map((e) => e.id)),
};
}
if (mode === 'all') return null;
const kinds: PodGraphEdge['kind'][] =
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
const es = graph.edges.filter(
(e) =>
kinds.includes(e.kind) ||
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
);
return {
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
edges: new Set(es.map((e) => e.id)),
};
}
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
{ label: 'engineer', swatch: { background: 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 }) {
const [graph, setGraph] = useState<PodGraph | null>(null);
@@ -115,198 +40,174 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
useEffect(() => {
let alive = true;
let nudge: number | null = null;
setGraph(null);
setError(null);
fetchPodGraph(podId)
.then((g) => alive && setGraph(g))
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
setSelected(null);
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 () => {
alive = false;
window.clearInterval(poll);
if (nudge != null) window.clearTimeout(nudge);
ws?.close();
};
}, [podId]);
const hi = useMemo(
() => (graph ? highlightFor(graph, mode, selected) : null),
[graph, mode, selected],
);
const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
const sel = selected ? nodeById.get(selected) : undefined;
const relCount = selected
? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length
: 0;
// A selected node can vanish across a poll/WS refresh. Ignore a stale id so the
// graph doesn't dim entirely (highlightFor would otherwise light only a dead id).
const liveSelected = selected && nodeById.has(selected) ? selected : null;
useEffect(() => {
if (selected && graph && !nodeById.has(selected)) setSelected(null);
}, [graph, nodeById, selected]);
const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false);
const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false);
const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false);
const highlight = useMemo(
() => (graph ? highlightFor(graph, mode, liveSelected) : null),
[graph, mode, liveSelected],
);
const sel = liveSelected ? nodeById.get(liveSelected) : undefined;
const relCount = liveSelected
? (graph?.edges ?? []).filter((e) => e.source === liveSelected || e.target === liveSelected)
.length
: 0;
const flow = graph && liveSelected ? flowNarrative(graph, liveSelected) : '';
function pick(next: Mode) {
setMode(next);
setSelected(null);
}
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
return (
<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">
<style>{`
.pm-node{cursor:pointer}
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
.pm-dim{opacity:.18;transition:opacity .25s}
`}</style>
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
<div className="flex items-center justify-between border-b px-5 py-4">
<div>
<h2 className="text-base font-medium">Team memory</h2>
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
{/* Header */}
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="font-heading text-base font-medium">Team memory</h2>
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">
<span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
Live
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
What PodMan learned · {podId}
</p>
</div>
<Button variant="outline" size="sm" onClick={onClose}>
Pods
</Button>
</div>
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
Risk path
</Button>
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
Learning edges
</Button>
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
Whole graph
</Button>
{/* Toggles */}
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
<ToggleGroup
type="single"
value={mode}
onValueChange={(v) => v && pick(v as Mode)}
variant="outline"
size="sm"
>
<ToggleGroupItem value="risk">Risk path</ToggleGroupItem>
<ToggleGroupItem value="learn">Learning edges</ToggleGroupItem>
<ToggleGroupItem value="all">Whole graph</ToggleGroupItem>
</ToggleGroup>
<span className="ml-auto hidden text-xs text-muted-foreground sm:inline">
Drag to rearrange · double-click to release · click to inspect
</span>
</div>
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
{!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 && (
<>
<div className="grid lg:grid-cols-[190px_1fr_250px]">
<div className="space-y-3 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Workflow metrics
</p>
{graph.metrics.map((m) => (
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
<p className="text-2xl font-medium tabular-nums">{m.value}</p>
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
{m.label}
</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
</div>
))}
{/* Metrics · graph · learning loop */}
<div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
<MetricsRail metrics={graph.metrics} />
<div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
<GraphCanvas
graph={graph}
highlight={highlight}
selected={liveSelected}
onSelect={setSelected}
/>
</div>
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
<svg
viewBox="0 0 720 472"
role="img"
aria-label="PodMan team-memory graph"
className="block h-auto w-full"
>
{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>
{graph.loop?.length ? (
<LearningLoop stages={graph.loop} />
) : (
<div />
)}
</div>
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
{sel ? (
<>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{sel.kind}
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3>
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
<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>
</>
)}
{/* Activity stream · selected node */}
<div className="grid gap-4 border-t px-4 py-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="rounded-xl border bg-card p-4">
<ActivityStream events={graph.activity ?? []} />
</div>
<div className="rounded-xl border bg-muted/40 p-4">
<SelectedNodePanel node={sel} relCount={relCount} flow={flow} mode={mode} />
</div>
</div>
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
{LEGEND.map((l) => (
{/* Legend */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-4 py-2.5 text-xs text-muted-foreground">
{NODE_LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5">
<span className="inline-block size-3" style={l.swatch} />
{l.label}
</span>
))}
<span className="flex items-center gap-1.5">
<span className="inline-block h-[3px] w-3" style={{ background: RED }} />
collides
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
learned_from
</span>
<span className="mx-1 h-3 w-px bg-border" aria-hidden />
{EDGE_LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5">
<span
className="inline-block h-[3px] w-3.5"
style={
l.dash
? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
: { background: l.color }
}
/>
{l.label}
</span>
))}
</div>
</>
)}
@@ -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}`);
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;
}
/** The five stages of PodMan's continual-learning loop, in order. */
export type LearningStageKey = 'observe' | 'store' | 'predict' | 'outcome' | 'adapt';
/** One stage of the learning-loop rail (observe→store→predict→outcome→adapt). */
export interface LearningStage {
key: LearningStageKey;
/** UPPERCASE display title, e.g. "OBSERVE". */
title: string;
/** Headline figure for the stage, e.g. "5/s" or "124". */
value: string;
/** One-line detail under the title. */
detail: string;
/** True for the single most-recently-active stage (pulses in the UI). */
active: boolean;
}
/** Kind of an activity-stream entry (drives the colored tag). */
export type ActivityKind = 'editing' | 'collision' | 'warns' | 'outcome' | 'learned_from';
/** One time-tagged entry in the activity stream. */
export interface ActivityEvent {
/** Stable id (source doc id + kind) so the UI can animate diffs. */
id: string;
/** ISO timestamp the event happened. */
at: string;
kind: ActivityKind;
/** Human-readable line, e.g. "Yahya opened auth.ts — unpushed changes". */
text: string;
}
/** A point-in-time render of a pod's team_model. */
export interface PodGraph {
podId: string;
@@ -56,6 +86,10 @@ export interface PodGraph {
nodes: PodGraphNode[];
edges: PodGraphEdge[];
metrics: PodGraphMetric[];
/** Continual-learning loop counts (observe→…→adapt). Additive/optional. */
loop?: LearningStage[];
/** Recent activity feed, most-recent first, capped ~8. Additive/optional. */
activity?: ActivityEvent[];
}
/** One node as a standalone document in the `graph_nodes` collection. */
+4
View File
@@ -18,6 +18,10 @@ export type {
PodGraphNodeKind,
PodGraphEdgeKind,
PodGraphNodeStatus,
LearningStage,
LearningStageKey,
ActivityEvent,
ActivityKind,
GraphNodeDoc,
GraphEdgeDoc,
} from './graph.js';