OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / graph.html

385 lines · html ref:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>AtomSpace Knowledge Graph</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
  body {
    font-family: -apple-system, 'Segoe UI', sans-serif;
    margin: 0;
    background: #0d1117;
    color: #c9d1d9;
    overflow: hidden;
  }
  #app { display: flex; height: 100vh; }
  #sidebar {
    width: 320px;
    padding: 16px;
    background: #161b22;
    border-right: 1px solid #30363d;
    overflow-y: auto;
  }
  #graph-container { flex: 1; position: relative; }
  svg { width: 100%; height: 100%; cursor: grab; }
  svg:active { cursor: grabbing; }
  h1 { font-size: 18px; margin: 0 0 12px; color: #58a6ff; }
  h2 { font-size: 13px; margin: 16px 0 6px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.08em; }
  label { display: block; font-size: 12px; margin: 8px 0 4px; color: #8b949e; }
  input[type="text"], input[type="number"], select {
    width: 100%;
    padding: 6px 8px;
    background: #0d1117;
    border: 1px solid #30363d;
    color: #c9d1d9;
    border-radius: 4px;
    font-size: 13px;
    box-sizing: border-box;
  }
  button {
    padding: 6px 12px;
    background: #238636;
    border: 1px solid #2ea043;
    color: white;
    border-radius: 4px;
    cursor: pointer;
    font-size: 13px;
    margin-top: 8px;
  }
  button:hover { background: #2ea043; }
  .stat { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
  .stat-val { color: #58a6ff; font-variant-numeric: tabular-nums; }
  .legend-item { display: flex; align-items: center; margin: 4px 0; font-size: 12px; }
  .legend-dot { width: 12px; height: 12px; border-radius: 50%; margin-right: 8px; }
  #tooltip {
    position: absolute;
    background: #161b22;
    border: 1px solid #30363d;
    padding: 8px 12px;
    border-radius: 4px;
    font-size: 12px;
    pointer-events: none;
    max-width: 360px;
    z-index: 100;
    display: none;
  }
  #tooltip strong { color: #58a6ff; }
  .node { cursor: pointer; }
  .node:hover { stroke: white; stroke-width: 2; }
  .node.selected { stroke: #f85149; stroke-width: 3; }
  .node.firing {
    stroke: #3fb950;
    stroke-width: 3;
    animation: pulse 2s infinite;
  }
  @keyframes pulse {
    0% { stroke-opacity: 1; }
    50% { stroke-opacity: 0.4; }
    100% { stroke-opacity: 1; }
  }
  .link { stroke-opacity: 0.4; }
  .label { font-size: 10px; fill: #8b949e; pointer-events: none; text-anchor: middle; }
  #details { margin-top: 16px; padding: 12px; background: #0d1117; border-radius: 4px; font-size: 12px; line-height: 1.5; display: none; }
  #details strong { color: #58a6ff; }
  #details pre { margin: 4px 0; font-size: 11px; overflow-x: auto; color: #8b949e; }
  .small { font-size: 11px; color: #6e7681; }
</style>
</head>
<body>
<div id="app">
  <div id="sidebar">
    <h1>AtomSpace Graph</h1>
    <p class="small">Knowledge-ratchet meta-cognition layer. Node size = wisdom score. Drag nodes to explore.</p>

    <h2>Filters</h2>
    <label>Module (leave empty for top-wisdom slice)</label>
    <input type="text" id="module-input" placeholder="e.g. pear-git">
    <label>Minimum wisdom</label>
    <input type="number" id="wisdom-input" value="0.5" step="0.1" min="0">
    <label>Node limit</label>
    <input type="number" id="limit-input" value="100" step="10" min="20" max="500">
    <label><input type="checkbox" id="hide-orphans" checked> Hide orphans (no edges)</label>
    <button onclick="loadGraph()">Reload</button>

    <h2>Stats</h2>
    <div id="stats"></div>

    <h2>Live Rule Firings
      <span id="fires-status" class="small" style="float: right; color: #6e7681;">●</span>
    </h2>
    <div id="fires" class="small" style="max-height: 220px; overflow-y: auto; margin-bottom: 12px;"></div>

    <h2>Legend</h2>
    <div id="legend"></div>

    <div id="details"></div>
  </div>
  <div id="graph-container">
    <svg id="graph"></svg>
    <div id="tooltip"></div>
  </div>
</div>

<script>
const COLORS = {
  'file': '#58a6ff',
  'module': '#3fb950',
  'package': '#6e7681',
  'function': '#39c5cf',
  'rule': '#f0883e',
  'loop': '#f85149',
  'pattern': '#bc8cff',
  'meta-loop': '#d2a8ff',
  'session': '#e3b341',
  'correction': '#ff7b72',
  'approval': '#7ee787',
  'edit': '#a5a5a5',
  'other': '#8b949e'
}

const EDGE_COLORS = {
  'contains': '#30363d',
  'imports': '#58a6ff33',
  'depends-on': '#58a6ff66',
  'exports': '#3fb95066',
  'loop-in-file': '#f8514966',
  'rule-for-file': '#f0883e66',
  'resolved-by': '#7ee78766',
  'approach-works': '#7ee78799',
  'approach-fails': '#ff7b7299',
  'try-first': '#bc8cff99',
  'avoid-first': '#ff7b7299'
}

const svg = d3.select('#graph')
const tooltip = document.getElementById('tooltip')
let simulation = null

function renderLegend () {
  const el = document.getElementById('legend')
  el.innerHTML = ''
  for (const [type, color] of Object.entries(COLORS)) {
    const div = document.createElement('div')
    div.className = 'legend-item'
    div.innerHTML = '<div class="legend-dot" style="background:' + color + '"></div>' + type
    el.appendChild(div)
  }
}

function renderStats (stats) {
  const el = document.getElementById('stats')
  if (!stats) { el.innerHTML = '<div class="stat">No data</div>'; return }
  let html = ''
  html += '<div class="stat">nodes<span class="stat-val">' + stats.nodeCount + '</span></div>'
  html += '<div class="stat">edges<span class="stat-val">' + stats.edgeCount + '</span></div>'
  if (stats.byType) {
    for (const [type, count] of Object.entries(stats.byType).sort((a,b) => b[1]-a[1])) {
      html += '<div class="stat">' + type + '<span class="stat-val">' + count + '</span></div>'
    }
  }
  el.innerHTML = html
}

function nodeRadius (d) {
  // Base radius by type
  const base = {
    'file': 5, 'module': 10, 'package': 5, 'function': 4,
    'rule': 7, 'loop': 6, 'pattern': 12, 'meta-loop': 7,
    'session': 9, 'correction': 6, 'approval': 6, 'edit': 4, 'other': 5
  }[d.type] || 5
  // Wisdom bonus (only for rule/pattern)
  if (d.wisdom && d.wisdom > 0) {
    return base + Math.min(10, d.wisdom * 4)
  }
  return base
}

function renderGraph (data) {
  svg.selectAll('*').remove()
  const width = svg.node().clientWidth
  const height = svg.node().clientHeight

  const g = svg.append('g')

  // Zoom + pan
  const zoom = d3.zoom()
    .scaleExtent([0.1, 8])
    .on('zoom', (e) => g.attr('transform', e.transform))
  svg.call(zoom)

  if (simulation) simulation.stop()

  // Optionally filter out orphans (no connecting edges)
  const hideOrphans = document.getElementById('hide-orphans').checked
  let nodes = data.nodes.map(n => ({ ...n }))
  let links = data.edges.map(e => ({ source: e.source, target: e.target, type: e.type }))

  if (hideOrphans) {
    const connected = new Set()
    for (const e of links) { connected.add(e.source); connected.add(e.target) }
    nodes = nodes.filter(n => connected.has(n.id))
  }

  // Tighter layout: smaller charge, stronger center, bounded radial
  simulation = d3.forceSimulation(nodes)
    .force('link', d3.forceLink(links).id(d => d.id).distance(d => d.type === 'contains' ? 30 : 60).strength(0.6))
    .force('charge', d3.forceManyBody().strength(-120).distanceMax(300))
    .force('center', d3.forceCenter(width/2, height/2))
    .force('collide', d3.forceCollide().radius(d => nodeRadius(d) + 3).strength(0.9))
    .force('x', d3.forceX(width/2).strength(0.05))
    .force('y', d3.forceY(height/2).strength(0.05))
    .alphaDecay(0.025)

  const link = g.append('g').selectAll('line').data(links).join('line')
    .attr('class', 'link')
    .attr('stroke', d => EDGE_COLORS[d.type] || '#30363d')
    .attr('stroke-width', 1)

  const node = g.append('g').selectAll('circle').data(nodes).join('circle')
    .attr('class', 'node')
    .attr('r', nodeRadius)
    .attr('fill', d => COLORS[d.type] || COLORS.other)
    .attr('stroke', '#161b22')
    .attr('stroke-width', 1.5)
    .call(d3.drag()
      .on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
      .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y })
      .on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
    .on('mouseover', (e, d) => {
      tooltip.style.display = 'block'
      tooltip.style.left = (e.pageX + 10) + 'px'
      tooltip.style.top = (e.pageY + 10) + 'px'
      tooltip.innerHTML = '<strong>' + d.type + '</strong><br>' + d.fullName +
        (d.wisdom > 0 ? '<br>wisdom: ' + d.wisdom.toFixed(2) : '')
    })
    .on('mouseout', () => { tooltip.style.display = 'none' })
    .on('click', (e, d) => showDetails(d))

  const label = g.append('g').selectAll('text').data(nodes).join('text')
    .attr('class', 'label')
    .text(d => d.label)
    .attr('dy', d => nodeRadius(d) + 10)

  simulation.on('tick', () => {
    link
      .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
      .attr('x2', d => d.target.x).attr('y2', d => d.target.y)
    node.attr('cx', d => d.x).attr('cy', d => d.y)
    label.attr('x', d => d.x).attr('y', d => d.y)
  })
}

function showDetails (d) {
  const el = document.getElementById('details')
  el.style.display = 'block'
  el.innerHTML = '<strong>' + d.fullName + '</strong>' +
    '<br>type: ' + d.type +
    (d.wisdom > 0 ? '<br>wisdom: ' + d.wisdom.toFixed(2) : '')
}

async function loadGraph () {
  const module = document.getElementById('module-input').value.trim()
  const wisdom = document.getElementById('wisdom-input').value
  const limit = document.getElementById('limit-input').value
  const params = new URLSearchParams()
  if (module) params.set('module', module)
  if (wisdom) params.set('minWisdom', wisdom)
  if (limit) params.set('limit', limit)

  try {
    const res = await fetch('graph.json?' + params.toString())
    const data = await res.json()
    renderStats(data.stats)
    renderGraph(data)
  } catch (err) {
    alert('Load failed: ' + err.message)
  }
}

renderLegend()
loadGraph()

// ── Live rule firings — polling every 3 seconds ──
let firingAtoms = new Set()  // atoms that fired recently
let lastSeenTimestamp = 0

const PULSE_WINDOW_MS = 300000  // 5 minutes — atoms fired within this window pulse
let lastFireTimestamp = null

async function pollFires () {
  try {
    const res = await fetch('fires.json?limit=50')
    const data = await res.json()
    const fires = data.fires || []

    // Detect new fires since last poll
    const newestTs = fires.length > 0 ? new Date(fires[fires.length-1].timestamp).getTime() : 0
    const isNew = lastFireTimestamp !== null && newestTs > lastFireTimestamp
    lastFireTimestamp = newestTs

    // Update sidebar list
    const el = document.getElementById('fires')
    if (fires.length === 0) {
      el.innerHTML = '<div style="color:#6e7681; padding: 8px 0">No fires logged yet.<br><span style="font-size: 10px">Rules fire when: (1) you mention a file in a prompt, or (2) Claude edits a shared module</span></div>'
    } else {
      el.innerHTML = fires.slice(-15).reverse().map((f, idx) => {
        const age = Date.now() - new Date(f.timestamp).getTime()
        const ageStr = age < 60000 ? Math.round(age/1000) + 's'
          : age < 3600000 ? Math.round(age/60000) + 'm'
          : Math.round(age/3600000) + 'h'
        const hookColor = f.hook === 'preflight' ? '#f0883e' : '#58a6ff'
        const label = f.atom.length > 50 ? f.atom.slice(0, 50) + '…' : f.atom
        const trigger = f.trigger ? ' <span style="color:#6e7681">[' + String(f.trigger).slice(0, 40) + ']</span>' : ''
        const isRecent = age < PULSE_WINDOW_MS
        const bg = isRecent ? 'background: rgba(63, 185, 80, 0.08);' : ''
        return `<div style="padding: 3px 6px; border-bottom: 1px solid #21262d; ${bg}">
          <span style="color:${hookColor}; font-size: 10px">${f.hook}</span>
          <span style="color:#6e7681; font-size: 10px; float: right">${ageStr} ago</span>
          <br><span style="color:#c9d1d9">${label}</span>${trigger}
        </div>`
      }).join('')
    }

    // Update firing atom set (pulse green) — atoms fired in last 5 minutes
    const recent = fires.filter(f => (Date.now() - new Date(f.timestamp).getTime()) < PULSE_WINDOW_MS)
    const newFiring = new Set(recent.map(f => f.atom))
    const changed = newFiring.size !== firingAtoms.size ||
                    [...newFiring].some(a => !firingAtoms.has(a))
    if (changed) {
      firingAtoms = newFiring
      d3.selectAll('.node')
        .classed('firing', d => firingAtoms.has(d.id))
    }

    // Status indicator
    const status = document.getElementById('fires-status')
    const recentCount = recent.length
    if (recentCount > 0) {
      status.style.color = '#3fb950'
      status.textContent = '● ' + recentCount + ' active (' + fires.length + ' total)'
    } else if (fires.length > 0) {
      status.style.color = '#e3b341'
      status.textContent = '○ ' + fires.length + ' total, none recent'
    } else {
      status.style.color = '#6e7681'
      status.textContent = '○ idle'
    }

    // Flash the status on new fire
    if (isNew) {
      status.style.color = '#58a6ff'
      status.textContent = '▸ NEW FIRE'
      setTimeout(() => pollFires(), 300)
    }
  } catch (err) {
    document.getElementById('fires-status').style.color = '#f85149'
    document.getElementById('fires-status').textContent = '✗ poll error'
  }
}

setInterval(pollFires, 3000)
pollFires()
</script>
</body>
</html>