<!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>