Skill: highnoonoffice/brain-map-visualizer (version 3.3.5)

--- name: brain-map-visualizer version: 3.3.4 description: "Visualize how attention moves across your agent's projects. Every session leaves a trace. Over time, those traces form a map — not of what your agent was told to do, but of what it actually cared about. Where it kept returning. What it let go. This is that map. 13 attention categories. Momentum edges. The recent rises. The forgotten fades. It has no utility beyond insight. That's the point." homepage: https://github.com/highnoonoffice/hno-skills source: https://github.com/highnoonoffice/hno-skills/tree/main/oc-brain-map license: MIT metadata: ~ --- # Brain Map Visualizer The Brain Map Visualizer renders your agent's cognition as an interactive force-directed graph organized around Attention Pockets — project-level groupings that define how files relate to each other in context. Markdown files are nodes. Sessions build the lines. The graph reflects not just which files were accessed, but which files were accessed together, and in what project context. A file that is central in one Attention Pocket may be peripheral in another. That context-dependence is the core insight the graph exposes. First click on any node reorbits the graph around it: the layout reorganizes to show that file's cognitive neighborhood within its Attention Pocket. Nearby nodes share frequent co-access in the same sessions. Distant nodes rarely overlap. Second click opens the file. The graph also surfaces Emerging Projects — recurring concepts detected across session journals that have not yet been mapped to a named Attention Pocket. These appear as a separate dimmed section with a Promote action. Works for any OpenClaw agent with a vault of markdown files and a session journal history. ### What You're Looking At When the graph loads, here's what you're seeing: **Nodes** are your vault files. Bigger = accessed more often. Color = which project domain it belongs to (gold for core identity files, blue for infrastructure, green for publishing, etc.). **Lines** connect files that were opened in the same session. Thicker lines = more sessions where those two files appeared together. **Clusters** are Attention Pockets — project-level groupings. Files that get used together in the same kind of work naturally orbit each other. **First click** on any node reorbits the graph around that file — its neighborhood rearranges to show what it's most closely connected to. **Second click** opens the file. **Dim nodes at the bottom** are Emerging Projects — patterns the graph detected that don't have a named home yet. That's the whole interface. The mechanics behind each piece are below. ### What This Skill Builds A D3.js force-directed graph embedded in a React component, designed for any Next.js dashboard app or standalone React host. The skill parses session journals to extract co-access relationships between vault files, attributes those relationships to Attention Pockets, and renders them as an interactive graph. **Nodes** — every markdown file in your vault, grouped and color-coded by Attention Pocket. **Lines** — co-access relationships between files. A line exists when two files appear in the same session journal. Line weight reflects how many sessions they were co-accessed. Session type (planning, coding, publishing, etc.) is auto-classified from journal keywords and encoded as line color. **Graph behavior** — the layout reflects attention flow and session patterns. Files that are co-accessed frequently in the same project context stay close. Files with weak or no shared context drift apart and dim. **Reorbit** — first click on any node shifts the graph from project view to file-centric cognitive view centered on that node. The rest of the graph reorganizes by co-access strength relative to that file. **Emerging Projects** — concepts appearing in 3 or more session journals that are not yet mapped to a named Attention Pocket surface automatically as candidates for promotion. ### Attention Model **Attention Pocket** A project-level grouping of files based on active focus and session attribution. Attention Pockets are color-coded in the graph and represent distinct cognitive domains (Core Identity, Memory, Publishing, Infrastructure, Skills, General). A file belongs to the Attention Pocket that its directory structure maps to. **Session Influence** The graph structure is built from repeated co-access across sessions, not from single-session snapshots. A strong line between two files means they have appeared together across multiple sessions in similar project contexts. The graph is a cumulative record of where attention has been, not a real-time snapshot. **Context-Dependence** The same file can occupy different positions depending on the active Attention Pocket. `working.md` may be the gravitational center of a project-focused view and peripheral in a memory-focused view. This is expected behavior. The reorbit interaction makes this visible. ### Reorbit Interaction The graph has two interaction modes: project view and file-centric view. **Project view (default)** All nodes rendered with full weight according to global co-access frequency. Color-coded by Attention Pocket. **First click — reorbit** Clicking any node does not open the file. It recenters the graph around that node and reorganizes all other nodes based on co-access strength relative to the selected node: - Strongly co-accessed nodes pull close - Weakly co-accessed nodes drift outward and dim - The surrounding cluster is that file's cognitive neighborhood within its Attention Pocket This shift reveals how a file behaves in context, not just how often it is accessed. **Second click — open file** Clicking the already-focused node opens its contents in the reader panel. The graph resets to project view when the reader panel closes. **Click different node while focused** Refocuses to the new node without resetting first. The reorbit model means clicking is never destructive to the current view. Project view is always one close-reader-panel action away from restoration. ### Emerging Projects and Promotion On each graph rebuild, the journal parser scans session summary text for recurring phrases that do not map to any existing Attention Pocket. **Detection rule:** A concept or phrase appearing in 3 or more session journals is flagged as Emerging. The detection threshold is tunable via a config parameter in the parser script. **Weight:** Mentions in journal summaries carry more weight than incidental file access patterns. A phrase appearing once in a summary counts more than a phrase inferred from file path co-occurrence. **UI behavior:** - Emerging concepts appear in a separate Emerging Projects section below the main graph - Nodes in this section are visually dimmed relative to active Attention Pocket nodes - Each Emerging entry includes a Promote action **Promotion:** - User names the concept and assigns it as a new Attention Pocket - On the next rebuild, files matching the new pocket's pattern are grouped and color-coded accordingly - The Emerging entry is removed from the dimmed section Promotion is a local configuration write. No external calls. The result is a new named color group in the graph on the next data refresh. ### Feature List **Attention Centering** The graph layout reflects project-level attention allocation. Files that absorb more session activity in a given pocket are weighted toward the center of that pocket's cluster. **Reorbit Interaction** First click reorganizes the graph around the selected node's cognitive neighborhood. Second click opens the file. The distinction between exploring context (click one) and reading content (click two) is intentional. **Color-coded Attention Pockets** Six named pockets, each with a distinct color: Core Identity (gold), Memory (purple), Publishing (green), Infrastructure (blue), Skills (orange), General (gray). **Enhanced Tooltips** Node hover: file path, access count, Attention Pocket, number of co-access sessions. Line hover: session type, source/target names, co-access count, session dates. **Emerging Projects Panel** Automatically surfaced from journal scanning. Dimmed, named, promotable. No manual curation required to surface new patterns. **Line Filter** Toggle minimum co-access weight threshold (default 2x, options: all / 2x / 3x / 5x). Reduces visual noise on dense graphs. **Graph Freeze** When the simulation cools, nodes lock in place. No ongoing jitter. Drag to reposition any node; it releases on mouse-up. **Single-click Node Open** In project view, single click opens the node's file. In reorbit mode (focused view), single click is reserved for reorbit. Second click opens. **Rebuild Button** Wired to the parser API endpoint. Triggers a full journal rescan and graph data refresh. Shows spinner and status feedback. ### Prerequisites - OpenClaw agent with a vault directory containing markdown files - Session journals in `memory/journal/YYYY-MM-DD.md` format (each entry references vault files) - A Next.js dashboard app or equivalent React host — or serve standalone with `npx serve` - Node.js 18+ for the data extraction script - `d3` and `@types/d3` installed in your frontend project ### Bootstrapping Without Journal History If you have been running an agent but have not written structured journal files, bootstrap from session history: Pull session transcripts or conversation logs, run them through a summarization script, and output one `memory/journal/YYYY-MM-DD.md` per session. The parser only needs `.md` file references in the text. Format does not matter. Bootstrap prompt for your agent: > "Read my session history from [source] and generate a journal entry for each session at `memory/journal/YYYY-MM-DD.md`. Summarize what we worked on and list the markdown files we accessed." The graph builds from whatever journal history exists and gets richer over time as more sessions are logged. ### Installation **Step 1 — Copy the data extraction script** Copy `references/journal-parser.md` into a Node.js script at `scripts/build-brain-map-projects.js` in your Mission Control or host app. Adjust `WORKSPACE_DIR` and `OUTPUT_PATH` via environment variables if needed. Run it: ```bash node scripts/build-brain-map-projects.js ``` Output is written to `data/brain-map-projects.json` by default. **Step 2 — Wire the API routes** In your Next.js app, add two routes from `references/graph-schema.md`: ``` app/api/brain-map/projects/route.ts — serves brain-map-projects.json app/api/brain-map/rebuild/route.ts — triggers a parser run from the UI ``` **Step 3 — Add the React component** Copy `BrainMapProjects.tsx` from `references/component.md` into your `components/` directory: ```tsx import BrainMapProjects from '@/components/BrainMapProjects'; export default function BrainMapTab() { return <BrainMapProjects />; } ``` **Step 4 — Schedule weekly rebuilds** Using OpenClaw's native cron (recommended — no system crontab needed): ```json { "id": "brain-map-weekly", "schedule": "0 0 * * 0", "type": "systemEvent", "event": "brain-map-rebuild" } ``` Or run manually: ```bash node scripts/build-brain-map-projects.js ``` ### Graph Data Format See `references/graph-schema.md` for the full spec. Output is project-centric — not a flat node/edge graph. Each project contains its own subgraph. ```json { "projects": [ { "id": "ghost-publishing", "label": "Ghost Publishing", "color": "#22c55e", "sessionCount": 14, "fileCount": 8, "coAccessScore": 42, "nodes": [ { "id": "MEMORY.md", "group": "core", "accessCount": 12 } ], "edges": [ { "source": "MEMORY.md", "target": "memory/recent.md", "fromId": "MEMORY.md", "toId": "memory/recent.md", "weight": 8, "recentCount": 3, "lifetimeCount": 8, "spanDays": 54 } ] } ], "generated": "2026-04-16T02:00:00Z", "journalCount": 52 } ``` ### Attention Categories — Color Mapping The parser maps sessions to 13 named attention categories via keyword matching. Projects are sorted by co-access score (total edge weight) in the output — highest-attention projects first. | Category | Color | |---|---| | Memory System | Gold `#c8a84b` | | Ghost Publishing | Green `#22c55e` | | Ghost Publishing Pro Skill | Emerald `#34d399` | | YouTube | Red `#ef4444` | | Mission Control | Blue `#60a5fa` | | Brain Map Skill | Purple `#a78bfa` | | GitHub | White `#f0f6ff` | | Cron + Automation | Orange `#fb923c` | | Model Stack | Fuchsia `#e879f9` | | HNO Business | Pink `#ec4899` | | Finances + Bitcoin | Amber `#fbbf24` | | The Desk | Tan `#d4a373` | | Second Brain | Cyan `#67e8f9` | Categories are fully customizable — edit `PROJECT_DEFS` in `build-brain-map-projects.js` to match your vault's actual work patterns. ### Edge Momentum Every edge carries `recentCount` (co-access sessions in the last 30 days) and `lifetimeCount` (total co-access sessions). These enable momentum rendering: a relationship heating up vs. fading. The data is computed on every parser run. Momentum UI encoding (flow opacity) is on the roadmap for a future component update. ### Security **Scope:** The skill reads markdown files in your vault directory and session journals to build a graph. It writes one JSON file (`brain-map-graph.json`) as output. No network calls are made beyond fetching graph data from your own local API route. No credentials are requested, stored, or transmitted. **Filesystem access:** The journal parser reads `.md` files recursively under your configured vault directory and writes one output file. Scope is intentional and bounded. Run the parser only from a trusted working directory. **Tooltip rendering:** The graph component renders tooltips as structured React elements (filename, group, session counts). Tooltips use structured React elements only — no raw HTML injection. **Emerging Projects scan:** The journal parser reads existing session journal summaries to detect recurring phrases. This is the same local read scope as the existing co-access scan. No new file system paths are accessed. No writes occur beyond the single output JSON. **Promotion writes:** When a user promotes an Emerging concept to a named Attention Pocket, the result is a local configuration write within the parser's existing output scope. No external calls. **API access control:** The route serving graph data supports optional token-based access control: ```bash BRAIN_MAP_SECRET=your-secret-key-here ``` Pass the key in component requests: ```typescript fetch('/api/brain-map/graph', { headers: { 'x-brain-map-key': process.env.NEXT_PUBLIC_BRAIN_MAP_SECRET } }) ``` If `BRAIN_MAP_SECRET` is not set, the route is open — suitable for localhost development only. Set the secret for any networked deployment. ### Known Limitations - Journal summaries reference files inconsistently — graph data improves as journaling explicitly names files. - Graph rebuilds are not real-time; run the parser script to refresh. - Reader panel (second click to open file) requires a `/api/read-file` endpoint in the host app. - Emerging Projects detection depends on journal summary quality. Sparse summaries produce fewer signals. ### References - `references/journal-parser.md` — Node.js script to extract co-access data and detect Emerging Projects - `references/component.md` — Full `BrainMapGraph.tsx` React + D3 component - `references/graph-schema.md` — Graph JSON spec and Next.js API route ### License MIT. Copyright (c) 2026 @highnoonoffice. Retain this notice in any distributed version.

path: references/component.md
content: ---
title: "OC Brain Map — BrainMapProjects Component"
created: 2026-03-17
modified: 2026-04-17
tags: [brain-map, react, d3, component, tsx]
status: active
---

# BrainMapProjects.tsx

Project-centric Brain Map component. Left panel shows attention projects ranked by recency-weighted density. Right panel renders a D3 force-directed graph for the selected project.

**Dependencies:** `d3`, `@types/d3`

```bash
npm install d3 @types/d3
```

---

## Recency-Weighted Ranking

The left panel sorts projects by a recency-weighted score rather than raw co-access density. This surfaces recently-active projects over historically-dense-but-stale ones.

**Equation:**

```
rank_score = coAccessScore / (daysSinceLastSession + 1)
```

- `coAccessScore` — total edge weight across all sessions (aggregate attention density)
- `daysSinceLastSession` — days since `dateLast` (ISO date from parser output)
- `+1` — prevents division by zero for projects touched today

A project touched yesterday with moderate density outranks a project with high lifetime density last touched 60 days ago. The `+1` floor keeps today's sessions from producing infinite scores.

**Implementation:**

```tsx
const rankedProjects = data?.projects
  ? [...data.projects].sort((a, b) => {
      const now = Date.now();
      const daysA = (now - new Date(a.dateLast).getTime()) / 86_400_000;
      const daysB = (now - new Date(b.dateLast).getTime()) / 86_400_000;
      const scoreA = a.coAccessScore / (daysA + 1);
      const scoreB = b.coAccessScore / (daysB + 1);
      return scoreB - scoreA;
    })
  : [];
```

Then render `rankedProjects.map(proj => ...)` instead of `data.projects.map`.

---

## Project Interface

```tsx
interface Project {
  id: string;           // machine id, e.g. "ghost-publishing"
  label: string;        // display name
  color: string;        // hex color for this project
  sessionCount: number; // journal sessions attributed to this project
  fileCount: number;    // unique files accessed
  coAccessScore: number;// total edge weight — aggregate attention proxy
  dateFirst: string;    // YYYY-MM-DD of earliest session
  dateLast: string;     // YYYY-MM-DD of most recent session
  sessions: ProjectSession[];
  nodes: ProjectNode[];
  edges: ProjectEdge[];
}

interface ProjectData {
  projects: Project[];
  generated: string;    // ISO timestamp of last parser run
  journalCount: number; // number of journal files parsed
}
```

---

## Node Color Groups

```tsx
const NODE_COLORS: Record<string, string> = {
  core: '#c8a84b',
  memory: '#a78bfa',
  publishing: '#22c55e',
  infrastructure: '#60a5fa',
  skills: '#f97316',
  journal: '#6b7280',
  general: '#6b7280',
};
```

---

## Component

```tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';

// --- Types ---
interface Node extends d3.SimulationNodeDatum {
  id: string;
  group: string;
  accessCount: number;
  path: string;
}

interface Edge {
  source: string | Node;
  target: string | Node;
  weight: number;
  sessionType: string;
  sessions: string[];
}

interface GraphData {
  nodes: Node[];
  edges: Edge[];
  generated: string;
  sessionCount: number;
}

// --- Color maps ---
const GROUP_COLORS: Record<string, string> = {
  core:           '#c8a84b',   // Gold
  memory:         '#a78bfa',   // Purple
  publishing:     '#22c55e',   // Green
  infrastructure: '#60a5fa',   // Blue
  skills:         '#f97316',   // Orange
  journal:        '#94a3b8',   // Slate
  general:        '#6b7280',   // Gray
};

const SESSION_TYPE_COLORS: Record<string, string> = {
  strategy:       '#c8a84b',
  memory:         '#a78bfa',
  publishing:     '#22c55e',
  infrastructure: '#60a5fa',
  research:       '#f97316',
  general:        '#6b7280',
};

// --- Upstream tier (for dot direction) ---
const GROUP_TIER: Record<string, number> = {
  core: 5, memory: 4, publishing: 2, infrastructure: 2, skills: 1, journal: 0, general: 0
};

export default function BrainMapGraph() {
  const svgRef = useRef<SVGSVGElement>(null);
  const [graphData, setGraphData] = useState<GraphData | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [tooltip, setTooltip] = useState<{ x: number; y: number; title: string; rows: { label: string; value: string }[]; hint?: string } | null>(null);
  const [focusNode, setFocusNode] = useState<string | null>(null);
  const lastClickRef = useRef<string | null>(null);
  const simRef = useRef<d3.Simulation<Node, Edge> | null>(null);

  // Fetch graph data
  useEffect(() => {
    fetch('/api/brain-map/graph')
      .then(r => r.json())
      .then(data => { setGraphData(data); setLoading(false); })
      .catch(err => { setError(String(err)); setLoading(false); });
  }, []);

  // Render graph
  useEffect(() => {
    if (!graphData || !svgRef.current) return;

    const svg = d3.select(svgRef.current);
    svg.selectAll('*').remove();

    const width = svgRef.current.clientWidth || 900;
    const height = svgRef.current.clientHeight || 600;

    // Defs: glow filter
    const defs = svg.append('defs');
    const filter = defs.append('filter').attr('id', 'glow');
    filter.append('feGaussianBlur').attr('stdDeviation', 2.5).attr('result', 'coloredBlur');
    const feMerge = filter.append('feMerge');
    feMerge.append('feMergeNode').attr('in', 'coloredBlur');
    feMerge.append('feMergeNode').attr('in', 'SourceGraphic');

    // Zoom layer
    const g = svg.append('g');
    svg.call(
      d3.zoom<SVGSVGElement, unknown>()
        .scaleExtent([0.3, 4])
        .on('zoom', (event) => g.attr('transform', event.transform))
    );

    const nodes: Node[] = graphData.nodes.map(n => ({ ...n }));
    const edges: Edge[] = graphData.edges.map(e => ({ ...e }));

    // Simulation
    const sim = d3.forceSimulation<Node>(nodes)
      .force('link', d3.forceLink<Node, Edge>(edges)
        .id(d => d.id)
        .distance(d => Math.max(60, 120 - (d as any).weight * 6))
        .strength(d => Math.min(0.8, 0.2 + (d as any).weight * 0.05))
      )
      .force('charge', d3.forceManyBody().strength(-220))
      .force('center', d3.forceCenter(width / 2, height / 2))
      .force('collision', d3.forceCollide(20));

    simRef.current = sim;

    // Edges
    const link = g.append('g')
      .selectAll<SVGLineElement, Edge>('line')
      .data(edges)
      .join('line')
      .attr('stroke', d => SESSION_TYPE_COLORS[d.sessionType] || '#6b7280')
      .attr('stroke-opacity', 0.35)
      .attr('stroke-width', d => Math.max(1, Math.min(4, (d as any).weight * 0.7)));

    // Flow dots (one per edge)
    const dotData = edges.map((e, i) => ({ edge: e, phase: i * 0.413, speed: 0.00025 + (e as any).weight * 0.00006 }));
    const dots = g.append('g')
      .selectAll<SVGCircleElement, typeof dotData[0]>('circle.flow-dot')
      .data(dotData)
      .join('circle')
      .attr('class', 'flow-dot')
      .attr('r', 3)
      .attr('fill', d => SESSION_TYPE_COLORS[d.edge.sessionType] || '#6b7280')
      .attr('opacity', 0.55)
      .attr('filter', 'url(#glow)');

    // Nodes
    const node = g.append('g')
      .selectAll<SVGCircleElement, Node>('circle.node')
      .data(nodes)
      .join('circle')
      .attr('class', 'node')
      .attr('r', d => Math.max(6, Math.min(18, 6 + d.accessCount * 1.8)))
      .attr('fill', d => GROUP_COLORS[d.group] || '#6b7280')
      .attr('stroke', '#1a1a1a')
      .attr('stroke-width', 1.5)
      .attr('cursor', 'pointer')
      .call(
        d3.drag<SVGCircleElement, Node>()
          .on('start', (event, d) => { if (!event.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
          .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
          .on('end', (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
      );

    // Labels
    const labels = g.append('g')
      .selectAll<SVGTextElement, Node>('text')
      .data(nodes)
      .join('text')
      .text(d => d.id.split('/').pop() || d.id)
      .attr('font-size', 9)
      .attr('fill', '#e5e7eb')
      .attr('text-anchor', 'middle')
      .attr('dy', d => -(Math.max(6, Math.min(18, 6 + d.accessCount * 1.8)) + 4))
      .attr('pointer-events', 'none');

    // Tooltip handlers
    node
      .on('mouseover', (event, d) => {
        setTooltip({
          x: event.pageX + 12,
          y: event.pageY - 28,
          title: d.id,
          rows: [
            { label: 'Group', value: d.group },
            { label: 'Sessions', value: String(d.accessCount) },
          ],
          hint: 'Click to focus · Click again to open',
        });
      })
      .on('mousemove', (event) => {
        setTooltip(prev => prev ? { ...prev, x: event.pageX + 12, y: event.pageY - 28 } : null);
      })
      .on('mouseout', () => setTooltip(null))
      .on('click', (event, d) => {
        event.stopPropagation();
        if (lastClickRef.current === d.id) {
          // Second click: open file, reset graph
          lastClickRef.current = null;
          setFocusNode(null);
          resetGraph();
          // Attempt to open file in reader panel if available
          window.dispatchEvent(new CustomEvent('brain-map:open-file', { detail: { path: d.path } }));
        } else {
          lastClickRef.current = d.id;
          setFocusNode(d.id);
          focusOnNode(d.id);
        }
      });

    // Edge tooltips
    link
      .on('mouseover', (event, d) => {
        const src = typeof d.source === 'object' ? (d.source as Node).id : d.source;
        const tgt = typeof d.target === 'object' ? (d.target as Node).id : d.target;
        setTooltip({
          x: event.pageX + 12,
          y: event.pageY - 28,
          title: `${src} → ${tgt}`,
          rows: [
            { label: 'Type', value: d.sessionType },
            { label: 'Co-access', value: `${(d as any).weight}×` },
            { label: 'Sessions', value: d.sessions.slice(0, 3).join(', ') + (d.sessions.length > 3 ? '…' : '') },
          ],
        });
      })
      .on('mousemove', (event) => {
        setTooltip(prev => prev ? { ...prev, x: event.pageX + 12, y: event.pageY - 28 } : null);
      })
      .on('mouseout', () => setTooltip(null));

    // Click background: reset focus
    svg.on('click', () => {
      lastClickRef.current = null;
      setFocusNode(null);
      resetGraph();
    });

    // Simulation tick
    sim.on('tick', () => {
      link
        .attr('x1', d => (d.source as Node).x!)
        .attr('y1', d => (d.source as Node).y!)
        .attr('x2', d => (d.target as Node).x!)
        .attr('y2', d => (d.target as Node).y!);
      node
        .attr('cx', d => d.x!)
        .attr('cy', d => d.y!);
      labels
        .attr('x', d => d.x!)
        .attr('y', d => d.y!);
    });

    // --- Flow dot animation ---
    let rafId: number;
    function animateDots(timestamp: number) {
      dots.each(function (d) {
        const src = d.edge.source as Node;
        const tgt = d.edge.target as Node;
        if (!src.x || !tgt.x) return;
        const t = ((timestamp * d.speed + d.phase) % 1);
        const x = src.x + (tgt.x - src.x) * t;
        const y = src.y! + (tgt.y! - src.y!) * t;
        d3.select(this).attr('cx', x).attr('cy', y);
      });
      rafId = requestAnimationFrame(animateDots);
    }
    rafId = requestAnimationFrame(animateDots);

    // --- Focus helpers ---
    function focusOnNode(nodeId: string) {
      const connected = new Set<string>([nodeId]);
      edges.forEach(e => {
        const srcId = typeof e.source === 'object' ? (e.source as Node).id : e.source;
        const tgtId = typeof e.target === 'object' ? (e.target as Node).id : e.target;
        if (srcId === nodeId) connected.add(tgtId);
        if (tgtId === nodeId) connected.add(srcId);
      });

      node.attr('opacity', n => connected.has(n.id) ? 1 : 0.15);
      labels.attr('opacity', n => connected.has(n.id) ? 1 : 0.1);
      link.attr('stroke-opacity', e => {
        const srcId = typeof e.source === 'object' ? (e.source as Node).id : e.source;
        const tgtId = typeof e.target === 'object' ? (e.target as Node).id : e.target;
        return (srcId === nodeId || tgtId === nodeId) ? 0.8 : 0.05;
      });
      dots.attr('opacity', d => {
        const srcId = typeof d.edge.source === 'object' ? (d.edge.source as Node).id : d.edge.source;
        const tgtId = typeof d.edge.target === 'object' ? (d.edge.target as Node).id : d.edge.target;
        return (srcId === nodeId || tgtId === nodeId) ? 0.9 : 0;
      });

      // Restart simulation centered on this node
      const target = nodes.find(n => n.id === nodeId);
      if (target) {
        sim.force('center', d3.forceCenter(target.x || width / 2, target.y || height / 2));
        sim.alphaTarget(0.5).restart();
        setTimeout(() => sim.alphaTarget(0), 800);
      }
    }

    function resetGraph() {
      node.attr('opacity', 1);
      labels.attr('opacity', 1);
      link.attr('stroke-opacity', 0.35);
      dots.attr('opacity', 0.55);
      sim.force('center', d3.forceCenter(width / 2, height / 2));
      sim.alphaTarget(0.3).restart();
      setTimeout(() => sim.alphaTarget(0), 600);
    }

    return () => {
      sim.stop();
      cancelAnimationFrame(rafId);
    };
  }, [graphData]);

  if (loading) return (
    <div className="flex items-center justify-center h-full text-gray-400 text-sm">
      Loading brain map…
    </div>
  );

  if (error) return (
    <div className="flex items-center justify-center h-full text-red-400 text-sm">
      Error loading graph: {error}
    </div>
  );

  const stats = graphData ? `${graphData.nodes.length} nodes · ${graphData.edges.length} edges · ${graphData.sessionCount} sessions` : '';

  return (
    <div className="relative w-full h-full bg-black" style={{ minHeight: 500 }}>
      {/* Stats bar */}
      <div className="absolute top-2 left-3 text-xs text-gray-500 z-10 select-none">
        {stats}
        {graphData?.generated && (
          <span className="ml-3 text-gray-600">
            Updated: {new Date(graphData.generated).toLocaleDateString()}
          </span>
        )}
      </div>

      {/* Legend */}
      <div className="absolute top-2 right-3 z-10 flex flex-col gap-1">
        {Object.entries(GROUP_COLORS).map(([group, color]) => (
          <div key={group} className="flex items-center gap-1.5 text-xs text-gray-400">
            <span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
            {group}
          </div>
        ))}
      </div>

      {/* SVG */}
      <svg
        ref={svgRef}
        className="w-full h-full"
        style={{ display: 'block', background: 'black' }}
      />

      {/* Focus label */}
      {focusNode && (
        <div className="absolute bottom-3 left-3 text-xs text-gray-400 z-10">
          Focused: <span className="text-white">{focusNode}</span>
          <span className="ml-2 text-gray-600">(click again to open · click background to reset)</span>
        </div>
      )}

      {/* Tooltip */}
      {tooltip && (
        <div
          className="fixed z-50 pointer-events-none bg-gray-900 border border-gray-700 text-gray-200 text-xs rounded px-2 py-1.5 max-w-xs shadow-lg"
          style={{ left: tooltip.x, top: tooltip.y }}
        >
          <div className="font-semibold mb-1">{tooltip.title}</div>
          {tooltip.rows.map(r => (
            <div key={r.label}>{r.label}: {r.value}</div>
          ))}
          {tooltip.hint && <div className="mt-1 italic text-gray-400">{tooltip.hint}</div>}
        </div>
      )}
    </div>
  );
}
```

---

## Integration Notes

- The component fetches `/api/brain-map/graph` on mount. Ensure the API route is wired (see `references/graph-schema.md`).
- The `brain-map:open-file` custom event fires on second-click. Wire a listener in your host page to handle file reading if you have a reader panel.
- Tooltip renders structured data (title, label/value rows, optional hint) as discrete React elements — no raw HTML injection.
- Flow dot animation uses `requestAnimationFrame` and cleans up on unmount via the returned function from `useEffect`.
- The component is fully self-contained — no external state, no context dependencies.

## Standalone Usage (no Next.js)

To use outside of Next.js, compile the component with any React bundler (Vite, CRA, etc.) and serve `data/brain-map-graph.json` as a static file or via a simple Express route.


path: references/journal-parser.md
content: ---
title: "OC Brain Map — Journal Parser Script"
created: 2026-03-17
modified: 2026-04-17
tags: [brain-map, journal, parser, node-script]
status: active
---

# Journal Parser — `scripts/build-brain-map-projects.js`

Parses `memory/journal/*.md` session files, maps sessions to 13 named attention projects via keyword matching, builds per-project co-access graphs with momentum fields and edge directionality, and writes `data/brain-map-projects.json`.

Copy this script to `scripts/build-brain-map-projects.js` in your Mission Control or host app. Adjust `WORKSPACE_DIR` and `OUTPUT_PATH` via environment variables.

---

## Configuration

```bash
# Override defaults via environment variables before running
WORKSPACE_DIR=/path/to/your/vault node scripts/build-brain-map-projects.js
OUTPUT_PATH=/path/to/output/brain-map-projects.json node scripts/build-brain-map-projects.js
```

Defaults:
- `WORKSPACE_DIR` → `~/.openclaw/vault`
- `OUTPUT_PATH` → `data/brain-map-projects.json` relative to the script's directory

---

## Project Definitions

The parser maps sessions to 13 named attention categories. Each category has a label, color, and keyword list. Sessions matching keywords are attributed to that project; file co-access within those sessions builds the edges.

| ID | Label | Color |
|---|---|---|
| `memory-system` | Memory System | Gold `#c8a84b` |
| `ghost-publishing` | Ghost Publishing | Green `#22c55e` |
| `ghost-publishing-pro` | Ghost Publishing Pro Skill | Emerald `#34d399` |
| `youtube` | YouTube | Red `#ef4444` |
| `mission-control` | Mission Control | Blue `#60a5fa` |
| `brain-map-skill` | Brain Map Skill | Purple `#a78bfa` |
| `github` | GitHub | White `#f0f6ff` |
| `cron-automation` | Cron + Automation | Orange `#fb923c` |
| `model-stack` | Model Stack | Fuchsia `#e879f9` |
| `hno-business` | HNO Business | Pink `#ec4899` |
| `finances` | Finances + Bitcoin | Amber `#fbbf24` |
| `the-desk` | The Desk | Tan `#d4a373` |
| `second-brain` | Second Brain | Cyan `#67e8f9` |

Sessions not matching any category fall back to `mission-control`.

### Customizing for your agent

The `PROJECT_DEFS` array at the top of the script is the only thing you need to change to adapt the Brain Map to your vault. Replace the keyword lists with terms that reflect your actual work. The category names and colors are arbitrary — use whatever groupings make sense for your attention patterns.

If a journal entry has a `## Project` block, that label takes priority over keyword inference:

```markdown
## Project
Ghost Publishing
```

This lets you manually attribute sessions that keyword matching would misclassify.

---

## File Extraction

The parser uses a two-pass strategy to find which markdown files were accessed in each session:

**Pass 1 — `## Files Accessed` block (preferred)**

If the journal has an explicit files list under `## Files Accessed`, the parser reads it directly. This is accurate and fast.

```markdown
## Files Accessed
- MEMORY.md
- memory/recent.md
- skills/ghost-publishing-pro/SKILL.md
```

**Pass 2 — transcript regex fallback**

If the `## Files Accessed` block is absent or has fewer than 3 entries, the parser scans the full transcript text for `.md` filename references using a regex. Less precise but catches sessions without structured file lists.

The more consistently you write `## Files Accessed` blocks in your journals, the more accurate the graph becomes.

---

## Edge Computation

For each project, edges are built from file co-access within sessions:

- Any two files appearing in the same session get an edge (or increment an existing one)
- `weight` = number of sessions where both files appeared
- `recentCount` = weight for sessions in the last 30 days
- `lifetimeCount` = same as weight
- `spanDays` = days between project's first and last session

**Edge directionality** is computed from session order:

- For each session, the parser records which file appeared first in the `## Files Accessed` list
- Across all sessions, a majority vote determines `fromId` (typically accessed first) and `toId` (typically accessed later)
- `fromId`/`toId` reflect cognitive flow — what you typically open before and after each file

---

## Script

The parser exports a `buildBrainMap(options)` function so it can be called directly from API routes or other scripts without shell execution. It also runs as a standalone CLI script when invoked directly via `node`.

```javascript
#!/usr/bin/env node
/**
 * Brain Map — Project-Centric Builder
 *
 * 13 attention categories, ordered by co-access count descending.
 * Parses all journal entries, maps each session to one or more projects
 * via keyword matching. Extracts files from explicit Files Accessed blocks
 * where available; falls back to regex extraction from transcript text.
 *
 * Exports: buildBrainMap(options?) — call directly from API routes.
 * Output:  brain-map-projects.json (or options.outputPath)
 */

const fs   = require('fs');
const path = require('path');

const WORKSPACE_DIR = process.env.WORKSPACE_DIR || path.join(process.env.HOME, '.openclaw/vault');
const JOURNAL_DIR   = path.join(WORKSPACE_DIR, 'memory/journal');
const OUTPUT_PATH   = process.env.OUTPUT_PATH
  || path.join(__dirname, '../data/brain-map-projects.json');

// ── 13 attention categories ───────────────────────────────────────────────────
// Customize PROJECT_DEFS to match your vault's actual work patterns.
// Each project needs: id (machine name), label (display), color (hex), keywords (array of strings).

const PROJECT_DEFS = [
  {
    id: 'memory-system',
    label: 'Memory System',
    color: '#c8a84b',
    keywords: [
      'memory system', 'memory.md', 'recent.md', 'deep-memory', 'working.md',
      'self-memory', 'meta-memory', 'journal-writer', 'memory layers',
      'memory architecture', 'memory tier', 'vault audit', 'session log',
      'rolling log', 'memory file', 'slop creep',
    ],
  },
  {
    id: 'ghost-publishing',
    label: 'Ghost Publishing',
    color: '#22c55e',
    keywords: [
      'ghost', 'josephvoelbel.com', 'article', 'draft', 'lexical', 'ghost admin',
      'theme', 'subscribe form', 'magic link', 'continue reading',
      'feature image', 'ghost publishing', 'ghost theme', 'batch lexical',
      'member', 'subscriber', 'email newsletter', 'excerpt',
    ],
  },
  {
    id: 'ghost-publishing-pro',
    label: 'Ghost Publishing Pro Skill',
    color: '#34d399',
    keywords: [
      'ghost publishing pro', 'skill publish', 'clawhub', 'scanner',
      'virus total', 'benign', 'suspicious', 'skill security',
      'workflow 14', 'workflow 15', 'jwt', 'admin api key', 'ghost skill',
      'skill iteration', 'skill update',
    ],
  },
  {
    id: 'youtube',
    label: 'YouTube',
    color: '#ef4444',
    keywords: [
      'youtube', 'thumbnail', 'playlist', 'video description',
      'flux', 'replicate', 'pillow', 'ffmpeg', 'short', 'narration',
      'audio recording', 'whisper', 'channel description', 'watch hours',
      'ypp', 'monetization', 'thumbnail system', 'upload to youtube',
    ],
  },
  {
    id: 'mission-control',
    label: 'Mission Control',
    color: '#60a5fa',
    keywords: [
      'mission control', 'mc tab', 'ops queue', 'pipeline tab', 'analytics tab',
      'today tab', 'finances tab', 'models tab', 'sidebar',
      'hno-mission-control', 'next.js', 'activity feed', 'mc chat',
      'mc deploy', 'mc route', 'mc api',
    ],
  },
  {
    id: 'brain-map-skill',
    label: 'Brain Map Skill',
    color: '#a78bfa',
    keywords: [
      'brain map visualizer', 'oc-brain-map', 'brain map skill',
      'graph rebuild', 'force graph', 'd3', 'brain map graph', 'journal parser',
      'co-access', 'brain map tab', 'attention pocket', 'attention model',
      'reorbit', 'brainmapprojects', 'force simulation',
    ],
  },
  {
    id: 'github',
    label: 'GitHub',
    color: '#f0f6ff',
    keywords: [
      'github', 'git push', 'git commit', 'pull request', 'pr #', 'merge',
      'feature branch', 'main branch', 'hno-skills', 'ghost-theme',
      'pr open', 'pr merged', 'pat', 'personal access token',
    ],
  },
  {
    id: 'cron-automation',
    label: 'Cron + Automation',
    color: '#fb923c',
    keywords: [
      'cron', 'launchd', 'scheduled', 'second-brain-weekly', 'brain-map-weekly',
      'openclaw cron', 'heartbeat', 'filedrop', 'automation', 'dispatch',
      'cron job', 'watchdog', 'proactive',
    ],
  },
  {
    id: 'model-stack',
    label: 'Model Stack',
    color: '#e879f9',
    keywords: [
      'model routing', 'cadmus', 'maple', 'haiku', 'ollama', 'sonnet',
      'anthropic', 'openai', 'local model', 'model stack', 'model delegation',
      'tier 1', 'tier 2', 'tier 3', 'model override', 'subagent',
      'claude', 'qwen', 'gemini', 'api balance', 'topped up', 'credit',
    ],
  },
  {
    id: 'hno-business',
    label: 'HNO Business',
    color: '#ec4899',
    keywords: [
      'b2b', 'sales agent', 'client', 'hno strategy', 'consulting',
      'entrepreneur', 'product strategy', 'first client', 'pipeline card',
      'ai resume',
    ],
  },
  {
    id: 'finances',
    label: 'Finances + Bitcoin',
    color: '#fbbf24',
    keywords: [
      'bitcoin', 'btc', 'heloc', 'hysa', 'balance sheet', 'finances',
      'eth', 'sol', 'mstr', 'river', 'cold storage', 'roth', '401k',
      'mortgage', 'savings', 'net worth',
    ],
  },
  {
    id: 'the-desk',
    label: 'The Desk',
    color: '#d4a373',
    keywords: [
      'fiction', 'short story', 'literary', 'prose', 'writing', 'narrative',
      'nineteen stories', 'book layout', 'amazon print', 'published book',
    ],
  },
  {
    id: 'second-brain',
    label: 'Second Brain',
    color: '#67e8f9',
    keywords: [
      'second brain', 'second-brain', 'atom', 'slack',
      'second brain weekly', 'second brain tab', 'weekly synthesis',
      'knowledge atom', 'signal rating', 'second brain html',
    ],
  },
];

// ── Helpers ───────────────────────────────────────────────────────────────────

function extractFilesFromAccessedBlock(content) {
  const match = content.match(/## Files Accessed\n([\s\S]*?)(?:\n## |\n---|\n# |$)/);
  if (!match) return [];
  return match[1]
    .split('\n')
    .map(l => l.replace(/^[-*]\s*/, '').trim())
    .filter(l => l.endsWith('.md') && l.length > 0 && !l.includes(' '));
}

function extractFilesFromTranscript(content) {
  const found = new Set();
  const re = /(?:^|[\s"'`(,])([a-zA-Z0-9._\-/]+\.md)(?:[\s"'`),]|$)/gm;
  let m;
  while ((m = re.exec(content)) !== null) {
    const f = m[1].replace(/^\/+/, '');
    if (f.length > 3 && !f.startsWith('http') && !f.includes('node_modules')) {
      found.add(f);
    }
  }
  return [...found];
}

function matchProjects(text) {
  const lower = text.toLowerCase();
  const matched = [];
  for (const proj of PROJECT_DEFS) {
    if (proj.keywords.some(k => lower.includes(k))) matched.push(proj.id);
  }
  return matched.length > 0 ? matched : ['mission-control'];
}

function extractSummary(content) {
  const m = content.match(/## Summary\n+([\s\S]*?)(?:\n## |\n---|\n# |$)/);
  if (!m) return '';
  return m[1].replace(/\n/g, ' ').trim().slice(0, 400);
}

// ## Project block takes priority over keyword inference
function extractExplicitProject(content) {
  const m = content.match(/## Project\n+([\s\S]*?)(?:\n## |\n---|\n# |$)/);
  if (!m) return null;
  const label = m[1].replace(/\n/g, ' ').trim().toLowerCase();
  if (!label) return null;
  const matched = [];
  for (const def of PROJECT_DEFS) {
    if (label.includes(def.label.toLowerCase()) || label.includes(def.id.toLowerCase())) {
      matched.push(def.id);
    }
  }
  return matched.length > 0 ? matched : null;
}

// ── Parse journals ────────────────────────────────────────────────────────────

if (!fs.existsSync(JOURNAL_DIR)) {
  console.error('Journal dir not found:', JOURNAL_DIR);
  process.exit(1);
}

const journalFiles = fs.readdirSync(JOURNAL_DIR)
  .filter(f => f.match(/^\d{4}-\d{2}-\d{2}\.md$/))
  .sort();

process.stdout.write(`Parsing ${journalFiles.length} journal files...`);

const projectMap = {};
for (const def of PROJECT_DEFS) {
  projectMap[def.id] = { ...def, sessions: [], fileSet: new Set() };
}

let journalCount = 0;

for (const jf of journalFiles) {
  const content = fs.readFileSync(path.join(JOURNAL_DIR, jf), 'utf8');
  const date    = jf.replace('.md', '');
  const summary = extractSummary(content);
  if (!summary) continue;
  journalCount++;

  let files = extractFilesFromAccessedBlock(content);
  if (files.length < 3) {
    const extra = extractFilesFromTranscript(content);
    files = [...new Set([...files, ...extra])];
  }
  files = [...new Set(files.map(f => f.replace(/^\.?\//, '')))].filter(f => f.endsWith('.md'));

  const projIds = extractExplicitProject(content) || matchProjects(summary + ' ' + content.slice(0, 3000));
  for (const pid of projIds) {
    if (!projectMap[pid]) continue;
    projectMap[pid].sessions.push({ date, summary: summary.slice(0, 200), files });
    files.forEach(f => projectMap[pid].fileSet.add(f));
  }
}

// ── Build nodes + edges ───────────────────────────────────────────────────────

const GROUP_RULES = [
  { prefix: 'memory/',             group: 'memory' },
  { prefix: 'PublishingPipeline/', group: 'publishing' },
  { prefix: 'drafts/',             group: 'publishing' },
  { prefix: 'tools/',              group: 'infrastructure' },
  { prefix: 'workflows/',          group: 'infrastructure' },
  { prefix: 'prompts/',            group: 'infrastructure' },
  { prefix: 'scripts/',            group: 'infrastructure' },
  { prefix: 'mission-control/',    group: 'infrastructure' },
  { prefix: '.learnings/',         group: 'infrastructure' },
  { prefix: 'skills/',             group: 'skills' },
];
const CORE_FILES = new Set(['MEMORY.md','SOUL.md','USER.md','IDENTITY.md','AGENTS.md','TOOLS.md','HEARTBEAT.md','BOOTSTRAP.md']);

function classifyGroup(filePath) {
  const base = path.basename(filePath);
  if (CORE_FILES.has(base) && !filePath.includes('/')) return 'core';
  for (const r of GROUP_RULES) if (filePath.startsWith(r.prefix)) return r.group;
  return 'general';
}

const projects = [];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

for (const def of PROJECT_DEFS) {
  const proj = projectMap[def.id];
  if (proj.sessions.length === 0) continue;

  const files = [...proj.fileSet];
  const nodes = files.map(f => ({
    id: f,
    group: classifyGroup(f),
    accessCount: proj.sessions.filter(s => s.files.includes(f)).length,
  }));

  const edgeMap = {};
  for (const session of proj.sessions) {
    const sf = session.files;
    for (let i = 0; i < sf.length; i++) {
      for (let j = i + 1; j < sf.length; j++) {
        const sorted = [sf[i], sf[j]].sort();
        const k = sorted.join('|||');
        if (!edgeMap[k]) {
          edgeMap[k] = {
            source: sorted[0], target: sorted[1],
            weight: 0, sessions: [],
            votes: { forward: 0, backward: 0 }
          };
        }
        edgeMap[k].weight++;
        if (!edgeMap[k].sessions.includes(session.date)) edgeMap[k].sessions.push(session.date);
        if (sf[i] === sorted[0]) { edgeMap[k].votes.forward++; }
        else                     { edgeMap[k].votes.backward++; }
      }
    }
  }

  const allDates = proj.sessions.map(s => s.date).sort();
  const spanDays = allDates.length >= 2
    ? (new Date(allDates[allDates.length-1]).getTime() - new Date(allDates[0]).getTime()) / 86400000
    : 1;

  const edges = Object.values(edgeMap).map(edge => {
    const recentCount   = edge.sessions.filter(d => d >= thirtyDaysAgo).length;
    const lifetimeCount = edge.sessions.length;
    const fromId = edge.votes.forward >= edge.votes.backward ? edge.source : edge.target;
    const toId   = edge.votes.forward >= edge.votes.backward ? edge.target : edge.source;
    return { source: edge.source, target: edge.target, fromId, toId, weight: edge.weight, recentCount, lifetimeCount, spanDays };
  });

  const coAccessScore = edges.reduce((sum, e) => sum + e.weight, 0);

  projects.push({
    id: def.id, label: def.label, color: def.color,
    sessionCount: proj.sessions.length, fileCount: nodes.length, coAccessScore,
    dateFirst: proj.sessions[0]?.date, dateLast: proj.sessions[proj.sessions.length - 1]?.date,
    sessions: proj.sessions.map(s => ({ date: s.date, summary: s.summary })),
    nodes, edges,
  });
}

projects.sort((a, b) => b.coAccessScore - a.coAccessScore);

const output = { projects, generated: new Date().toISOString(), journalCount };
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2));

process.stdout.write(`\n✓ Written to ${OUTPUT_PATH}`);
process.stdout.write(`  ${projects.length} projects, sorted by attention:\n`);
projects.forEach((p, i) =>
  process.stdout.write(`  ${String(i+1).padStart(2)}. [${p.coAccessScore.toString().padStart(5)} co-access] ${p.label}: ${p.sessionCount} sessions, ${p.fileCount} files`)
);

// ── Exported API ──────────────────────────────────────────────────────────────
// Call buildBrainMap() directly from API routes — no shell execution needed.

function buildBrainMap(options = {}) {
  const workspaceDir = options.workspaceDir || process.env.WORKSPACE_DIR || path.join(process.env.HOME, '.openclaw/vault');
  const journalDir   = path.join(workspaceDir, 'memory/journal');
  const outputPath   = options.outputPath   || process.env.OUTPUT_PATH   || path.join(__dirname, '../data/brain-map-projects.json');

  if (!fs.existsSync(journalDir)) throw new Error('Journal dir not found: ' + journalDir);

  const journalFiles = fs.readdirSync(journalDir)
    .filter(f => f.match(/^\d{4}-\d{2}-\d{2}\.md$/))
    .sort();

  const projMap = {};
  for (const def of PROJECT_DEFS) {
    projMap[def.id] = { ...def, sessions: [], fileSet: new Set() };
  }

  let count = 0;
  for (const jf of journalFiles) {
    const content = fs.readFileSync(path.join(journalDir, jf), 'utf8');
    const date    = jf.replace('.md', '');
    const summary = extractSummary(content);
    if (!summary) continue;
    count++;

    let files = extractFilesFromAccessedBlock(content);
    if (files.length < 3) {
      const extra = extractFilesFromTranscript(content);
      files = [...new Set([...files, ...extra])];
    }
    files = [...new Set(files.map(f => f.replace(/^\.?\//,'')))].filter(f => f.endsWith('.md'));

    const projIds = extractExplicitProject(content) || matchProjects(summary + ' ' + content.slice(0, 3000));
    for (const pid of projIds) {
      if (!projMap[pid]) continue;
      projMap[pid].sessions.push({ date, summary: summary.slice(0, 200), files });
      files.forEach(f => projMap[pid].fileSet.add(f));
    }
  }

  const ago30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
  const built = [];

  for (const def of PROJECT_DEFS) {
    const proj = projMap[def.id];
    if (proj.sessions.length === 0) continue;

    const files = [...proj.fileSet];
    const nodes = files.map(f => ({
      id: f, group: classifyGroup(f),
      accessCount: proj.sessions.filter(s => s.files.includes(f)).length,
    }));

    const edgeMap = {};
    for (const session of proj.sessions) {
      const sf = session.files;
      for (let i = 0; i < sf.length; i++) {
        for (let j = i + 1; j < sf.length; j++) {
          const sorted = [sf[i], sf[j]].sort();
          const k = sorted.join('|||');
          if (!edgeMap[k]) edgeMap[k] = { source: sorted[0], target: sorted[1], weight: 0, sessions: [], votes: { forward: 0, backward: 0 } };
          edgeMap[k].weight++;
          if (!edgeMap[k].sessions.includes(session.date)) edgeMap[k].sessions.push(session.date);
          if (sf[i] === sorted[0]) edgeMap[k].votes.forward++;
          else edgeMap[k].votes.backward++;
        }
      }
    }

    const allDates = proj.sessions.map(s => s.date).sort();
    const spanDays = allDates.length >= 2
      ? (new Date(allDates[allDates.length-1]).getTime() - new Date(allDates[0]).getTime()) / 86400000
      : 1;

    const edges = Object.values(edgeMap).map(edge => {
      const recentCount   = edge.sessions.filter(d => d >= ago30).length;
      const lifetimeCount = edge.sessions.length;
      const fromId = edge.votes.forward >= edge.votes.backward ? edge.source : edge.target;
      const toId   = edge.votes.forward >= edge.votes.backward ? edge.target : edge.source;
      return { source: edge.source, target: edge.target, fromId, toId, weight: edge.weight, recentCount, lifetimeCount, spanDays };
    });

    const coAccessScore = edges.reduce((sum, e) => sum + e.weight, 0);
    built.push({
      id: def.id, label: def.label, color: def.color,
      sessionCount: proj.sessions.length, fileCount: nodes.length, coAccessScore,
      dateFirst: proj.sessions[0]?.date, dateLast: proj.sessions[proj.sessions.length - 1]?.date,
      sessions: proj.sessions.map(s => ({ date: s.date, summary: s.summary })),
      nodes, edges,
    });
  }

  built.sort((a, b) => b.coAccessScore - a.coAccessScore);
  const result = { projects: built, generated: new Date().toISOString(), journalCount: count };
  fs.writeFileSync(outputPath, JSON.stringify(result, null, 2));
  return result;
}

module.exports = { buildBrainMap };

// Run as CLI if called directly
if (require.main === module) {
  buildBrainMap();
}
```


path: references/graph-schema.md
content: ---
title: "OC Brain Map — Graph Schema & API Route"
created: 2026-03-17
modified: 2026-04-17
tags: [brain-map, schema, api, nextjs]
status: active
---

# Graph Schema & API Route

The Brain Map uses a **project-centric** data model. The parser output (`brain-map-projects.json`) groups nodes and edges by project rather than outputting a single flat graph. Each project is an independent subgraph with its own nodes, edges, session history, and metadata.

## `brain-map-projects.json` — Full Schema

```typescript
interface ProjectData {
  projects: Project[];
  generated: string;    // ISO timestamp of last parser run
  journalCount: number; // number of journal files parsed
}

interface Project {
  id: string;           // machine id, e.g. "ghost-publishing"
  label: string;        // display name, e.g. "Ghost Publishing"
  color: string;        // hex color for this project's nodes and edges
  sessionCount: number; // number of journal sessions attributed to this project
  fileCount: number;    // number of unique files accessed in this project
  coAccessScore: number;// total edge weight — proxy for how much attention this project absorbed
  dateFirst: string;    // YYYY-MM-DD of earliest session
  dateLast: string;     // YYYY-MM-DD of most recent session
  sessions: ProjectSession[];
  nodes: ProjectNode[];
  edges: ProjectEdge[];
}

interface ProjectSession {
  date: string;         // YYYY-MM-DD
  summary: string;      // first 200 chars of journal summary
}

interface ProjectNode {
  id: string;           // relative path from vault root, e.g. "MEMORY.md"
  group: NodeGroup;     // color group for rendering
  accessCount: number;  // sessions this file appeared in within this project
}

type NodeGroup =
  | 'core'              // MEMORY.md, SOUL.md, USER.md, IDENTITY.md, AGENTS.md, TOOLS.md, HEARTBEAT.md
  | 'memory'            // memory/*.md
  | 'publishing'        // PublishingPipeline/*, drafts/*, articles/*
  | 'infrastructure'    // tools/*, workflows/*, prompts/*, scripts/*, mission-control/*, .learnings/*
  | 'skills'            // skills/*
  | 'general';          // everything else

interface ProjectEdge {
  source: string;       // file id (alphabetically first of the pair)
  target: string;       // file id (alphabetically second of the pair)
  fromId: string;       // canonical upstream node — the file accessed first in sessions (majority vote)
  toId: string;         // canonical downstream node — the file accessed later in sessions
  weight: number;       // co-access count (sessions where both files appeared)
  recentCount: number;  // co-access sessions in the last 30 days
  lifetimeCount: number;// same as weight (sessions array length) — alias for momentum math
  spanDays: number;     // days between first and last session in this project
}
```

## Edge Directionality

Each edge carries two direction fields in addition to `source`/`target`:

- **`fromId`** — the file accessed *earlier* in the session on average across all sessions where both files appeared (majority vote from session order)
- **`toId`** — the file accessed *later*

This lets the component render directional flow if desired. The `source`/`target` fields are alphabetically sorted and stable for deduplication; `fromId`/`toId` reflect actual cognitive flow.

## Momentum Fields

Each edge carries `recentCount` and `lifetimeCount`:

- **`recentCount`** — co-access sessions in the last 30 days
- **`lifetimeCount`** — total co-access sessions ever

These fields enable momentum rendering: an edge where `recentCount / lifetimeCount` is high is a relationship that's heating up. One where it's low is fading. The data is computed on every parser run. UI rendering of momentum (flow opacity encoding) is planned for a future component update.

## Example JSON

```json
{
  "projects": [
    {
      "id": "ghost-publishing",
      "label": "Ghost Publishing",
      "color": "#22c55e",
      "sessionCount": 14,
      "fileCount": 8,
      "coAccessScore": 42,
      "dateFirst": "2026-02-20",
      "dateLast": "2026-04-15",
      "sessions": [
        { "date": "2026-04-15", "summary": "Batch excerpt push to 65 posts. Updated custom_excerpt field..." }
      ],
      "nodes": [
        { "id": "MEMORY.md", "group": "core", "accessCount": 12 },
        { "id": "memory/recent.md", "group": "memory", "accessCount": 9 }
      ],
      "edges": [
        {
          "source": "MEMORY.md",
          "target": "memory/recent.md",
          "fromId": "MEMORY.md",
          "toId": "memory/recent.md",
          "weight": 8,
          "recentCount": 3,
          "lifetimeCount": 8,
          "spanDays": 54
        }
      ]
    }
  ],
  "generated": "2026-04-16T02:00:00.000Z",
  "journalCount": 52
}
```

---

## Next.js API Routes

Two routes are needed — one for the project graph data, one to trigger a rebuild.

### `app/api/brain-map/projects/route.ts`

Serves `brain-map-projects.json`:

```typescript
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';

const DATA_PATH = path.join(process.cwd(), 'data/brain-map-projects.json');

export async function GET(request: Request) {
  const [REDACTED_SECRET];
  if (secret && request.headers.get('x-brain-map-key') !== secret) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  try {
    if (!fs.existsSync(DATA_PATH)) {
      return NextResponse.json(
        { error: 'Graph data not found. Run scripts/build-brain-map-projects.js first.' },
        { status: 404 }
      );
    }
    const data = JSON.parse(fs.readFileSync(DATA_PATH, 'utf8'));
    return NextResponse.json(data, { headers: { 'Cache-Control': 'no-store' } });
  } catch (err) {
    return NextResponse.json({ error: 'Failed to read graph data', detail: String(err) }, { status: 500 });
  }
}
```

### `app/api/brain-map/rebuild/route.ts`

Triggers a parser run from the UI Rebuild button by importing and calling the parser directly — no shell execution:

```typescript
import { NextResponse } from 'next/server';
import path from 'path';

export async function POST(request: Request) {
  const [REDACTED_SECRET];
  if (secret && request.headers.get('x-brain-map-key') !== secret) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  try {
    // Import the parser module directly — no shell execution.
    // eslint-disable-next-line @typescript-eslint/no-require-imports
    const { buildBrainMap } = require(
      path.join(process.cwd(), 'scripts/build-brain-map-projects.js')
    );
    buildBrainMap();
    return NextResponse.json({ ok: true });
  } catch (err) {
    return NextResponse.json({ error: 'Rebuild failed', detail: String(err) }, { status: 500 });
  }
}
```

---

## Notes

- The API routes do not cache (`Cache-Control: no-store`) — rebuilds are infrequent and the JSON is small.
- Set `BRAIN_MAP_ACCESS_KEY` env var to restrict API access for any networked deployment. Leave unset for localhost-only use.
- Projects are sorted by `coAccessScore` descending in the output — highest attention projects first.


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)