Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ testsprite_tests/tmp/config.json

.vercel
.env*

# ghost recorder state (sessions, shadow index, patches)
.ghost/
257 changes: 11 additions & 246 deletions packages/ghost/ghost.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
import * as shadow from "./lib/shadowgit.mjs";
import * as store from "./lib/store.mjs";
import { createProbes } from "./lib/probes.mjs";
import { cityHtml3d } from "./lib/city.mjs";

const ROOT = process.cwd();
const GHOST_DIR = path.join(ROOT, ".ghost");
Expand Down Expand Up @@ -195,245 +196,8 @@ tick();setInterval(tick,1600);addEventListener("resize",draw);
</script></body></html>`;
}

// Ghost City 3D — same layout language as the published playground (3D city in
// the center, info panel left, selected-tower panel right, turn dock bottom),
// but stream-fed from the recorder's own /session, so ANY project gets the
// full experience from `ghost watch` alone. three.js is loaded from a CDN in
// the browser (the recorder itself stays zero-dependency); if the browser is
// offline the 2D canvas city at /city2d is still served by the recorder.
function cityHtml3d() {
return `<!doctype html><html><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Ghost City — live playground (3D)</title>
<style>
:root{--bg:#05060a;--fg:#e6e9f5;--dim:#9aa3b8;--line:rgba(139,124,255,.14);--hot:#ff7a6b;--cold:#67e8f9;--violet:#8b7cff;--card:rgba(139,124,255,.06)}
*{box-sizing:border-box}html,body{height:100%}
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.55 Inter,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden;display:flex;flex-direction:column}
a{color:var(--cold);text-decoration:none}a:hover{text-decoration:underline}
header{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);flex:none;backdrop-filter:blur(6px)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--cold);box-shadow:0 0 10px var(--cold);animation:pulse 2s infinite}
@keyframes pulse{50%{opacity:.35}}
header h1{font-size:14px;margin:0;letter-spacing:.05em;color:#cbd5ff}
.meta{color:var(--dim);font-size:11px}
.spacer{flex:1}
.layout{flex:1;display:grid;grid-template-columns:270px 1fr 320px;min-height:0}
aside{overflow:auto;padding:14px;border-right:1px solid var(--line)}
aside.right{border-right:none;border-left:1px solid var(--line)}
h2{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin:18px 0 8px}
h2:first-child{margin-top:0}
.stat{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 10px;margin-bottom:8px;overflow:hidden}
.stat i{font-style:normal;color:var(--dim);font-size:9px;letter-spacing:.1em;text-transform:uppercase;display:block}
.stat b{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
.turn{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 10px;margin-bottom:6px;cursor:pointer}
.turn:hover{border-color:var(--cold)}
.turn.on{border-color:var(--cold);box-shadow:0 0 0 1px var(--cold) inset}
.turn b{color:var(--dim);margin-right:6px}
.turn .p{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:230px}
.turn .f{color:var(--cold);font-size:11px;word-break:break-all}
.empty{color:var(--dim);border:1px dashed var(--line);border-radius:9px;padding:10px;font-size:11px}
main{position:relative;min-width:0}
canvas.gl{display:block;width:100%;height:100%}
#tip{position:fixed;pointer-events:none;background:#061713;border:1px solid var(--line);border-radius:8px;padding:6px 9px;font-size:11px;display:none;z-index:9;max-width:320px;word-break:break-all}
#sel b.big{display:block;font-size:13px;word-break:break-all;margin:2px 0 8px}
.kv{display:grid;grid-template-columns:88px 1fr;gap:3px 8px;font-size:12px}
.kv i{font-style:normal;color:var(--dim)}
.filelist{max-height:38vh;overflow:auto}
.file{display:block;color:var(--cold);font-size:11px;padding:1px 0;cursor:pointer;word-break:break-all}
.file:hover{text-decoration:underline}
.dock{flex:none;display:flex;align-items:center;gap:6px;padding:8px 16px;border-top:1px solid var(--line);overflow-x:auto}
.dock .t{min-width:26px;height:22px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--dim);font-size:10px;cursor:pointer}
.dock .t.on{border-color:var(--cold);color:var(--fg)}
.dock .note{color:var(--dim);font-size:10px;margin-left:8px;white-space:nowrap}
</style>
<script type="importmap">
{"imports":{"three":"https://unpkg.com/three@0.160.0/build/three.module.js","three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"}}
</script>
</head><body>
<header><span class="dot"></span><h1>Ghost City</h1><span class="meta" id="meta">connecting…</span>
<span class="spacer"></span><span class="meta"><a href="/studio">studio</a> · <a href="/session">session</a> · <a href="/city2d">2D fallback</a></span>
</header>
<div class="layout">
<aside>
<h2>Session</h2>
<div class="stat"><i>repo</i><b id="repo">…</b></div>
<div class="stat"><i>git head</i><b id="head">…</b></div>
<div class="stat"><i>turns</i><b id="turnsN">0</b></div>
<div class="stat"><i>files touched</i><b id="filesN">0</b></div>
<h2>Turns</h2><div id="turns"></div>
<h2>Mode</h2><div id="mode" class="empty">…</div>
</aside>
<main><canvas class="gl" id="gl"></canvas><div id="tip"></div></main>
<aside class="right">
<h2>Selected tower</h2><div id="sel" class="empty">click a tower in the city</div>
<h2>Files in this session</h2><div id="filelist" class="filelist empty">none yet — the agent has not touched files</div>
</aside>
</div>
<div class="dock"><span class="meta">turns</span><span id="dock"></span><span class="note" id="docknote"></span></div>
<script type="module">
const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const $=id=>document.getElementById(id);
const PRIORITY_LIGHT=0.5;
let towers=[],byId=new Map(),selected=null,THREE=null,OrbitControls=null,renderer,scene,camera,controls,raycaster,hoverMesh=null,selMesh=null,meshGroup=null,threeReady=false,lastData=null,activeTurnN=0;

function buildTowers(s){
const seen=new Map();
for(const cp of s.checkpoints||[]){
for(const f of cp.files||[]){
const cur=seen.get(f)||{path:f,loc:0,git:false,turn:cp.turnNumber,ts:cp.ts,sha:null};
if(cp.churn&&cp.churn[f]!=null){cur.loc+=cp.churn[f];cur.git=true;cur.sha=cur.sha||cp.sha;}
seen.set(f,cur);
}
}
towers=[...seen.values()].sort((a,b)=>a.ts.localeCompare(b.ts));
byId=new Map(towers.map(t=>[t.path,t]));
}

function renderPanels(s){
$("repo").textContent=s.repo||"—";
$("head").textContent=s.head?String(s.head).slice(0,10):"(no git)";
const turns=s.turns||[];
$("turnsN").textContent=String(turns.length);
$("filesN").textContent=String(towers.length);
$("mode").innerHTML=s.head?
"git shadow checkpoint active — tower height = churn (added+deleted lines from the real patch).":
"NOT a git repo — file-list checkpoints only; towers stay low because no patch exists. Run git init to scale the city by churn.";
$("turns").innerHTML=turns.slice(-9).reverse().map(t=>{
const q=esc(t.prompt||(t.id?"[assistant turn]":"[no prompt captured]"));
const fs=(t.files&&t.files.length)?('<span class="f">'+t.files.slice(0,3).map(esc).join(", ")+"</span>"):"";
return '<div class="turn" data-turn="'+t.index+'"><b>t'+t.index+"</b>"+ '<span class="p">'+q+"</span>"+fs+"</div>";
}).join("")||'<div class="empty">no turns yet — chat with your agent</div>';
[...document.querySelectorAll(".turn")].forEach(el=>el.onclick=()=>focbyTurn(Number(el.dataset.turn)));
$("filelist").className="filelist";
$("filelist").innerHTML=towers.map(t=>'<span class="file" data-p="'+esc(t.path)+'">'+esc(t.path)+"</span>").join("")||'<div class="empty">none yet — the agent has not touched files</div>';
[...document.querySelectorAll(".file")].forEach(el=>el.onclick=()=>selectTower(el.dataset.p));
$("dock").innerHTML=turns.map(t=>'<button class="t'+(t.index===activeTurnN?" on":"")+'" data-turn="'+t.index+'">t'+t.index+"</button>").join(" ")||'<span class="meta">—</span>';
[...document.querySelectorAll(".dock .t")].forEach(b=>b.onclick=()=>focbyTurn(Number(b.dataset.turn)));
$("docknote").textContent=turns.length?("hover the city or click files on the right — data is real, refreshed from this machine"):"";
$("meta").textContent=towers.length+" towers · "+turns.length+" turns · live";
}

function focbyTurn(n){activeTurnN=n;
[...document.querySelectorAll(".dock .t,.turn")].forEach(b=>b.classList.toggle("on",Number(b.dataset.turn)===n));
const f=towers.find(t=>t.turn===n);
if(f)selectTower(f.path);
}

async function initThree(){
THREE=await import("three");
({OrbitControls}=await import("three/addons/controls/OrbitControls.js"));
const cv=$("gl");
renderer=new THREE.WebGLRenderer({canvas:cv,antialias:true});
renderer.setPixelRatio(devicePixelRatio);
scene=new THREE.Scene();scene.background=new THREE.Color(0x05060a);
scene.fog=new THREE.Fog(0x05060a,30,95);
camera=new THREE.PerspectiveCamera(46,1,0.1,300);
camera.position.set(14,13,16);
controls=new OrbitControls(camera,cv);
controls.enableDamping=true;controls.autoRotate=true;controls.autoRotateSpeed=0.5;
controls.maxPolarAngle=Math.PI/2.06;controls.minDistance=7;controls.maxDistance=64;
scene.add(new THREE.HemisphereLight(0xcdd2ff,0x05060a,1.0));
const dir=new THREE.DirectionalLight(0xbfb8ff,0.9);dir.position.set(12,20,8);scene.add(dir);
const cyan=new THREE.PointLight(0x67e8f9,0.5,40);cyan.position.set(-10,6,-10);scene.add(cyan);
const ground=new THREE.Mesh(new THREE.PlaneGeometry(160,160),new THREE.MeshStandardMaterial({color:0x070811,roughness:0.9,metalness:0.1}));
ground.rotation.x=-Math.PI/2;ground.position.y=-0.02;scene.add(ground);
const grid=new THREE.GridHelper(90,60,0x3d2f7a,0x1c1a3a);if(grid){grid.position.y=0.001;scene.add(grid);}
meshGroup=new THREE.Group();scene.add(meshGroup);
raycaster=new THREE.Raycaster();
threeReady=true;resize();
cv.addEventListener("pointermove",onMove);
cv.addEventListener("click",onClick);
animate();
}

function heightOf(t){return t.git&&t.loc>0?Math.min(1.4+t.loc*0.16,14):(t.git?1.0:0.55)}

function rebuildMeshes(){
if(!threeReady)return;
meshGroup.clear();
const n=Math.max(1,towers.length);
const cols=Math.ceil(Math.sqrt(n));
towers.forEach((t,i)=>{
const x=(i%cols-(cols-1)/2)*2.1,z=(Math.floor(i/cols)-(cols-1)/2)*2.1;
const h=heightOf(t);
const hot=t.git&&t.loc>0;
// match the dashboard palette: violet base, cyan edge-lines, red = anomaly
let baseHue=252,baseLight=hot?0.62-h*0.012:0.5+0.02*0;
const geo=new THREE.BoxGeometry(1,h,1);
const mat=new THREE.MeshStandardMaterial({
color:new THREE.Color().setHSL((baseHue-(hot?26:0))/360, hot?0.58:0.34, Math.max(0.3,Math.min(0.72,baseLight-h*0.014))),
roughness:0.42,metalness:0.22,
emissive:new THREE.Color().setHSL((baseHue-(hot?38:6))/360,0.65,hot?0.16:0.07),
});
const m=new THREE.Mesh(geo,mat);
m.position.set(x,h/2+0.02,z);
m.userData=t;
const edges=new THREE.LineSegments(new THREE.EdgesGeometry(geo),new THREE.LineBasicMaterial({color:0x67e8f9,transparent:true,opacity:hot?0.55:0.3}));
m.add(edges);
if(hot&&t.loc>=1){
const aura=new THREE.Sprite(new THREE.SpriteMaterial({map:auraTexture,color:0xff7a6b,transparent:true,opacity:0.5,depthWrite:false}));
aura.scale.set(3.2,3.2,1);aura.position.set(0,h*0.5,0);m.add(aura);
}
t._mesh=m;
meshGroup.add(m);
});
}
let _auraTex=null;
function auraTexture(){if(_auraTex)return _auraTex;
const c=document.createElement("canvas");c.width=c.height=128;const g=c.getContext("2d");
const grd=g.createRadialGradient(64,64,2,64,64,62);
grd.addColorStop(0,"rgba(255,122,107,.9)");grd.addColorStop(0.5,"rgba(255,122,107,.28)");grd.addColorStop(1,"rgba(255,122,107,0)");
g.fillStyle=grd;g.fillRect(0,0,128,128);_auraTex=new THREE.CanvasTexture(c);return _auraTex;}

function animate(){requestAnimationFrame(animate);if(controls)controls.update();
if(threeReady&&controls){controls.target.y=1.2;}
if(renderer&&scene&&camera)renderer.render(scene,camera);}

function resize(){const cv=$("gl"),box=cv.parentElement;renderer.setSize(box.clientWidth,box.clientHeight);camera.aspect=box.clientWidth/box.clientHeight;camera.updateProjectionMatrix();}

function pickMesh(e){if(!threeReady)return null;const r=$("gl").getBoundingClientRect();
raycaster.setFromCamera(new THREE.Vector2(((e.clientX-r.left)/r.width)*2-1,-((e.clientY-r.top)/r.height)*2+1),camera);
const hit=raycaster.intersectObjects(meshGroup.children,false)[0];
return hit?hit.object:null;}

function onMove(e){const tip=$("tip");const m=pickMesh(e);
if(hoverMesh&&hoverMesh!==selMesh)hoverMesh.material.emissive&&hoverMesh.material.emissive.setHex(0x000000);
hoverMesh=m;
if(m){m.material.emissive&&m.material.emissive.setHex(0x3b2f8e);
const t=m.userData;tip.style.display="block";tip.style.left=e.clientX+14+"px";tip.style.top=e.clientY+10+"px";
tip.innerHTML="<b>"+esc(t.path)+"</b><br/>"+(t.git?("churn "+t.loc+" lines"):("touched (non-git — no patch)"))+" · turn t"+t.turn+(t.sha?' · <a href="/diff/'+t.sha+'" target="_blank">patch</a>':"");}
else tip.style.display="none";}

function onClick(e){const m=pickMesh(e);if(m)selectTower(m.userData.path);}

function selectTower(path){const t=byId.get(path);if(!t)return;selected=t;
if(selMesh)selMesh.material.emissive&&selMesh.material.emissive.setHex(0x000000);
selMesh=t._mesh||null;
if(selMesh)selMesh.material.emissive&&selMesh.material.emissive.setHex(0x8b7cff);
$("sel").className="stat";
$("sel").innerHTML="<b class='big'>"+esc(t.path)+"</b><div class='kv'>"+
"<i>churn</i><span>"+(t.git?t.loc+" lines":"no patch (non-git)")+"</span>"+
"<i>turn</i><span>t"+t.turn+"</span>"+
(t.sha?"<i>patch</i><span><a href='/diff/"+t.sha+"' target='_blank'>/diff view</a></span>":"")+
"<i>device</i><span>loopback 127.0.0.1 — never uploaded</span></div>";
}

async function tick(){try{
lastData=await fetch("/session").then(r=>r.json());
buildTowers(lastData);
renderPanels(lastData);
if(threeReady){rebuildMeshes();if(selected){const t=byId.get(selected.path);if(t){selected=t;}}}
}catch(e){$("meta").textContent="reconnecting to recorder…"}}

async function boot(){
try{await initThree();}catch(e){
document.querySelector("main").innerHTML='<div class="empty" style="margin:40px">3D engine unavailable (offline CDN). Use the zero-dependency 2D city: <a href="/city2d">/city2d</a> — same live data, served by the recorder.</div>';
}
tick();setInterval(tick,1700);
addEventListener("resize",()=>{if(threeReady)resize();});
}
boot();
</script></body></html>`;
}
// Ghost City (the /city route) lives in ./lib/city.mjs so the recorder and the
// published /playground share one shell: tape rail, 3D city, autopsy, dock.

// ── helpers ──────────────────────────────────────────────────────────────────
// Parse a unified diff into per-file churn (added+deleted lines) used as the
Expand Down Expand Up @@ -553,31 +317,32 @@ async function cmdWatch() {
const port = Number(arg("--recorder-port", config.recorderPort || DEFAULT_RECORDER_PORT));
const eventsBase = `http://127.0.0.1:${port}`;
const emitter = http.createServer((req, res) => {
if (req.method === "GET" && (req.url === "/" || req.url === "/studio")) {
const pathname = (req.url || "/").split("?")[0];
if (req.method === "GET" && (pathname === "/" || pathname === "/studio")) {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(studioHtml(eventsBase));
return;
}
if (req.method === "GET" && req.url === "/city") {
if (req.method === "GET" && pathname === "/city") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
res.end(cityHtml3d());
return;
}
if (req.method === "GET" && req.url === "/city2d") {
if (req.method === "GET" && pathname === "/city2d") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
res.end(cityHtml());
return;
}
if (req.method === "GET" && req.url === "/session") {
if (req.method === "GET" && pathname === "/session") {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"access-control-allow-origin": "*",
});
res.end(JSON.stringify(session));
return;
}
if (req.method === "GET" && req.url.startsWith("/diff/")) {
const name = decodeURIComponent(req.url.slice(6).split("?")[0]);
if (req.method === "GET" && pathname.startsWith("/diff/")) {
const name = decodeURIComponent(pathname.slice(6)).slice(0, 12);
const dir = store.sessionDir(ROOT, session);
try {
const file = fs.readdirSync(path.join(dir, "diffs")).find((f) => f.startsWith(name));
Expand All @@ -590,7 +355,7 @@ async function cmdWatch() {
} catch { res.writeHead(404).end(); return; }
return;
}
if (req.method !== "POST" || req.url !== "/events") { res.writeHead(404).end(); return; }
if (req.method !== "POST" || pathname !== "/events") { res.writeHead(404).end(); return; }
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
Expand Down
Loading
Loading