@echomem/mcp 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Echo extraction plate</title>
6
+ <style>
7
+ *{margin:0;padding:0;box-sizing:border-box;}
8
+ html,body{width:100%;height:100%;background:transparent;overflow:hidden;}
9
+ #plate{width:100%;height:100%;display:block;}
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <div id="plate"></div>
14
+ <script type="importmap">
15
+ { "imports": { "three": "./vendor/three.module.min.js" } }
16
+ </script>
17
+ <script type="module">
18
+ // The real Echo clay city (ported from echo-ai-city-only / EchoCityScene), used as the extraction
19
+ // plate: it renders the user's full repo-city from /report (token signpost = total tokens / input%).
20
+ // The city stays standing (extraction is additive). While /progress is running, a continuous gentle
21
+ // stream of memory motes flows from the city into the signpost; each completed conversation fires a
22
+ // brighter BURST aimed at that conversation's repo building (p.latestRepo). The signpost cross-fades to
23
+ // the real memory count (extracted). On any WebGL/data failure it stays transparent (SVG fallback shows).
24
+ import * as THREE from 'three';
25
+ import { RoundedBoxGeometry } from './vendor/RoundedBoxGeometry.js';
26
+
27
+ const params = new URLSearchParams(location.search);
28
+ const nonce = params.get('nonce') || '';
29
+
30
+ const TOOL = { codex: 0x10a37f, claude: 0xd97757 };
31
+ const TRAY_COL = 0xece5d3;
32
+ const TPU = 80e6, BASE_EDGE = 36, TOWER = 1.4, MIN_TOKENS = 4e6, TRAY_TOP = 25;
33
+ const LABEL_MIN_BASE = 24, MARGIN = 56, GAP = 16, MARGIN_FACTOR = 1.3;
34
+ const fmtTok = (n) => n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? Math.round(n / 1e6) + 'M' : Math.round(n / 1e3) + 'K';
35
+
36
+ const mount = document.getElementById('plate');
37
+
38
+ (async function () {
39
+ let report = null;
40
+ try { report = await loadReport(); } catch (_) { return; }
41
+ const repos = buildRepos(report);
42
+ if (!repos.length) return; // nothing to draw → leave transparent, SVG fallback shows
43
+ try { run(repos, (report && report.scale) || {}); } catch (e) { console.error('extraction-plate render failed', e); }
44
+ })();
45
+
46
+ async function loadReport() {
47
+ if (!nonce) return null;
48
+ for (let i = 0; i < 80; i++) {
49
+ try {
50
+ const res = await fetch('/report?nonce=' + encodeURIComponent(nonce), { credentials: 'omit', cache: 'no-store' });
51
+ if (res.status === 200) return await res.json();
52
+ if (res.status !== 202) return null;
53
+ } catch (_) { return null; }
54
+ await new Promise((r) => setTimeout(r, 500));
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function buildRepos(report) {
60
+ const source = Array.isArray(report && report.repos) ? report.repos : [];
61
+ const usable = source.filter((r) => Number(r && r.tokens) > 0).sort((a, b) => (Number(b.tokens) || 0) - (Number(a.tokens) || 0)).slice(0, 16);
62
+ return usable.map((repo, i) => {
63
+ let x, z;
64
+ if (i === 0) { x = -36; z = -58; }
65
+ else if (i === 1) { x = 128; z = -12; }
66
+ else { const a = i * 2.399963229728653, rad = 72 + Math.sqrt(i) * 42; x = Math.cos(a) * rad; z = Math.sin(a) * rad + 28; }
67
+ const tool = (repo.dominantProvider === 'codex' || repo.dominantProvider === 'claude') ? repo.dominantProvider : 'claude';
68
+ return { name: String(repo.name || ('Repo ' + (i + 1))), x, z, tool, tokens: Number(repo.tokens) || 0, pin: i < 2 };
69
+ });
70
+ }
71
+
72
+ function run(repos, scale) {
73
+ for (const o of repos) { const u = o.tokens / TPU; const b = Math.cbrt((u * BASE_EDGE * BASE_EDGE * BASE_EDGE) / TOWER); o.w = b; o.d = b; o.h = b * TOWER; }
74
+ const all = repos.filter((o) => o.tokens >= MIN_TOKENS);
75
+ separate(all);
76
+ let minX = 1e9, maxX = -1e9, minZ = 1e9, maxZ = -1e9;
77
+ for (const o of all) { minX = Math.min(minX, o.x - o.w / 2); maxX = Math.max(maxX, o.x + o.w / 2); minZ = Math.min(minZ, o.z - o.d / 2); maxZ = Math.max(maxZ, o.z + o.d / 2); }
78
+ const CX = (minX + maxX) / 2, CZ = (minZ + maxZ) / 2;
79
+ const traySize = Math.max(maxX - minX, maxZ - minZ) + 2 * MARGIN, trayW = traySize, trayD = traySize;
80
+
81
+ const canvas = document.createElement('canvas');
82
+ canvas.style.cssText = 'display:block;width:100%;height:100%;';
83
+ mount.appendChild(canvas);
84
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
85
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
86
+ renderer.setClearColor(0xffffff, 0);
87
+ renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
88
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
89
+ renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05;
90
+ const scene = new THREE.Scene();
91
+
92
+ scene.environment = (() => {
93
+ const c = document.createElement('canvas'); c.width = 512; c.height = 256;
94
+ const g = c.getContext('2d');
95
+ const gr = g.createLinearGradient(0, 0, 0, 256); gr.addColorStop(0, '#ffffff'); gr.addColorStop(0.5, '#eef1f6'); gr.addColorStop(1, '#d2d7e0');
96
+ g.fillStyle = gr; g.fillRect(0, 0, 512, 256);
97
+ const sp = g.createRadialGradient(150, 62, 8, 150, 62, 150); sp.addColorStop(0, 'rgba(255,255,255,0.95)'); sp.addColorStop(1, 'rgba(255,255,255,0)');
98
+ g.fillStyle = sp; g.fillRect(0, 0, 512, 256);
99
+ const t = new THREE.CanvasTexture(c); t.mapping = THREE.EquirectangularReflectionMapping; t.colorSpace = THREE.SRGBColorSpace;
100
+ const pm = new THREE.PMREMGenerator(renderer); pm.compileEquirectangularShader();
101
+ const e = pm.fromEquirectangular(t).texture; t.dispose(); pm.dispose(); return e;
102
+ })();
103
+
104
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 6000);
105
+ const TARGET = new THREE.Vector3(CX, 78, CZ);
106
+ camera.position.copy(TARGET).add(new THREE.Vector3(760, 660, 760)); camera.lookAt(TARGET);
107
+
108
+ scene.add(new THREE.HemisphereLight(0xffffff, 0xdadce2, 0.7));
109
+ scene.add(new THREE.AmbientLight(0xffffff, 0.2));
110
+ const key = new THREE.DirectionalLight(0xffffff, 2.3);
111
+ key.position.set(CX - 360, 560, CZ + 360); key.target.position.copy(TARGET);
112
+ key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.radius = 16; key.shadow.bias = -0.0004;
113
+ const sh = Math.max(trayW, trayD) * 0.8 + 140, scam = key.shadow.camera;
114
+ scam.left = -sh; scam.right = sh; scam.top = sh; scam.bottom = -sh; scam.near = 100; scam.far = 1800;
115
+ scene.add(key); scene.add(key.target);
116
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(6000, 6000), new THREE.ShadowMaterial({ opacity: 0.22 }));
117
+ floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; scene.add(floor);
118
+
119
+ const tray = new THREE.Mesh(new RoundedBoxGeometry(trayW, TRAY_TOP, trayD, 8, 12), new THREE.MeshPhysicalMaterial({ color: TRAY_COL, roughness: 0.6, clearcoat: 0.3, clearcoatRoughness: 0.5, envMapIntensity: 0.6 }));
120
+ tray.position.set(CX, TRAY_TOP / 2, CZ); tray.castShadow = true; tray.receiveShadow = true; scene.add(tray);
121
+ (() => {
122
+ const scl = 3, W = Math.round(trayW * scl), H = Math.round(trayD * scl), cv = document.createElement('canvas'); cv.width = W; cv.height = H;
123
+ const g = cv.getContext('2d'); const m = 14 * scl, rad = 26 * scl, rw = W - 2 * m, rh = H - 2 * m;
124
+ g.save(); g.beginPath(); g.roundRect(m, m, rw, rh, rad); g.clip();
125
+ const cell = 21 * scl; g.strokeStyle = 'rgba(74,80,96,0.42)'; g.lineWidth = 1.8; g.beginPath();
126
+ for (let x = m; x <= m + rw + 0.5; x += cell) { g.moveTo(x, m); g.lineTo(x, m + rh); }
127
+ for (let y = m; y <= m + rh + 0.5; y += cell) { g.moveTo(m, y); g.lineTo(m + rw, y); }
128
+ g.stroke(); g.restore();
129
+ g.strokeStyle = 'rgba(120,126,140,0.26)'; g.lineWidth = 0.7 * scl; g.beginPath(); g.roundRect(m, m, rw, rh, rad); g.stroke();
130
+ const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
131
+ const pl = new THREE.Mesh(new THREE.PlaneGeometry(trayW, trayD), new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, toneMapped: false }));
132
+ pl.rotation.x = -Math.PI / 2; pl.position.set(CX, TRAY_TOP + 0.6, CZ); scene.add(pl);
133
+ })();
134
+
135
+ const clay = (hex) => { const c = new THREE.Color(hex); return new THREE.MeshPhysicalMaterial({ color: c, roughness: 0.42, transmission: 0.85, ior: 1.34, thickness: 70, attenuationColor: c.clone(), attenuationDistance: 95, clearcoat: 0.28, clearcoatRoughness: 0.45, envMapIntensity: 1.05, transparent: true }); };
136
+ const mkLabel = (o) => {
137
+ const PW = 320, PH = 280, cv = document.createElement('canvas'); cv.width = PW; cv.height = PH;
138
+ const g = cv.getContext('2d'); g.textAlign = 'center'; g.shadowColor = 'rgba(0,0,0,0.34)'; g.shadowBlur = 9; g.shadowOffsetY = 1;
139
+ g.fillStyle = '#ffffff'; g.font = '800 ' + Math.round(PW * 0.19) + 'px "IBM Plex Mono", monospace'; g.fillText(fmtTok(o.tokens), PW / 2, PH * 0.2);
140
+ g.fillStyle = 'rgba(255,255,255,0.95)'; g.font = '600 ' + Math.round(PW * 0.078) + 'px "IBM Plex Mono", monospace';
141
+ const fw = g.measureText(o.name).width, mw = PW * 0.94; g.save(); g.translate(PW / 2, PH * 0.86); if (fw > mw) g.scale(mw / fw, mw / fw); g.fillText(o.name, 0, 0); g.restore();
142
+ const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
143
+ const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, toneMapped: false, depthWrite: false, opacity: 0 }));
144
+ const ww = Math.min(Math.max(o.w * 0.95, 42), 128), hw = (ww * PH) / PW; sp.scale.set(ww, hw, 1);
145
+ return { sp, hw };
146
+ };
147
+
148
+ const items = []; let maxTop = 0;
149
+ for (const o of all) {
150
+ const r = Math.min(Math.min(o.w, o.h, o.d) * 0.12, 12);
151
+ const mesh = new THREE.Mesh(new RoundedBoxGeometry(o.w, o.h, o.d, 8, r), clay(TOOL[o.tool]));
152
+ mesh.castShadow = true; mesh.receiveShadow = true;
153
+ mesh.position.set(o.x, TRAY_TOP + o.h / 2 - 3, o.z); // full height, fixed — extraction is additive, the city never shrinks
154
+ scene.add(mesh);
155
+ const L = (o.w >= LABEL_MIN_BASE) ? mkLabel(o) : null;
156
+ if (L) { L.sp.position.set(o.x, TRAY_TOP + o.h - 3 + 6 + 0.36 * L.hw, o.z); L.sp.material.opacity = 1; scene.add(L.sp); }
157
+ items.push({ o, mesh, L, base: new THREE.Color(TOOL[o.tool]), pop: 0, lit: 0 }); maxTop = Math.max(maxTop, TRAY_TOP + o.h);
158
+ }
159
+ const byName = {}; for (const it of items) byName[it.o.name] = it; // building lookup for aiming a burst at a repo
160
+
161
+ // Signpost on a post: a "before" face (total tokens / input%) cross-fades into the "after" face
162
+ // (memories created) as the city distills.
163
+ const sgx = CX + trayW * 0.3, sgz = CZ + trayD * 0.3, SIGN_Y = TRAY_TOP + 70;
164
+ const post = new THREE.Mesh(new THREE.CylinderGeometry(2.2, 2.2, 52, 12), new THREE.MeshStandardMaterial({ color: 0xddd9cc, roughness: 0.7 }));
165
+ post.position.set(sgx, TRAY_TOP + 26, sgz); post.castShadow = true; scene.add(post);
166
+ const mkSign = () => {
167
+ const cv = document.createElement('canvas'); cv.width = 640; cv.height = 300;
168
+ const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
169
+ const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, toneMapped: false, depthTest: false }));
170
+ sp.position.set(sgx, SIGN_Y, sgz); sp.scale.set(150, 70, 1); scene.add(sp);
171
+ return { cv, tex, sp };
172
+ };
173
+ const cardBg = (g) => {
174
+ g.clearRect(0, 0, 640, 300); g.textAlign = 'center';
175
+ g.fillStyle = 'rgba(244,241,233,0.97)'; g.beginPath(); g.roundRect(8, 8, 624, 284, 30); g.fill();
176
+ g.strokeStyle = 'rgba(120,126,140,0.30)'; g.lineWidth = 3; g.beginPath(); g.roundRect(8, 8, 624, 284, 30); g.stroke();
177
+ };
178
+ const tokSign = mkSign();
179
+ (() => {
180
+ const g = tokSign.cv.getContext('2d'); cardBg(g);
181
+ g.fillStyle = '#1a1a1a'; g.font = '800 116px "IBM Plex Mono", monospace'; g.fillText(fmtTok(scale.totalTokens || 0), 320, 142);
182
+ g.fillStyle = '#6b6b6b'; g.font = '600 34px "IBM Plex Mono", monospace'; g.fillText('TOTAL TOKENS', 320, 196);
183
+ g.fillStyle = '#c7372f'; g.font = '800 52px "IBM Plex Mono", monospace'; g.fillText((scale.inputPct || 0) + '% INPUT', 320, 268);
184
+ tokSign.tex.needsUpdate = true;
185
+ })();
186
+ const memSign = mkSign(); memSign.sp.material.opacity = 0;
187
+ const drawMem = (mem) => {
188
+ const g = memSign.cv.getContext('2d'); cardBg(g);
189
+ g.fillStyle = '#1a3a8f'; g.font = '800 150px "IBM Plex Mono", monospace'; g.fillText(String(Math.round(mem)), 320, 168);
190
+ g.fillStyle = '#6b6b6b'; g.font = '600 38px "IBM Plex Mono", monospace'; g.fillText('MEMORIES CREATED', 320, 232);
191
+ memSign.tex.needsUpdate = true;
192
+ };
193
+ drawMem(0);
194
+
195
+ // Memory motes: a continuous gentle stream rises off the city into the memory bank while a conversation
196
+ // is being processed (so it is never frozen between completions), plus a brighter BURST aimed at a
197
+ // repo's building each time a conversation completes. The signpost pulses as bursts land.
198
+ const SG = new THREE.Vector3(sgx, SIGN_Y - 4, sgz), ARC = 52;
199
+ const motes = [];
200
+ for (let i = 0; i < 72; i++) {
201
+ const m = new THREE.Mesh(new THREE.SphereGeometry(1, 10, 10), new THREE.MeshBasicMaterial({ color: 0x9fc4ff, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }));
202
+ m.visible = false; scene.add(m);
203
+ motes.push({ m, life: 0, t: 0, dur: 1, sz: 3, p0: new THREE.Vector3(), tgt: new THREE.Vector3() });
204
+ }
205
+ let signPulse = 0;
206
+ const launch = (it, big) => {
207
+ if (!it) return;
208
+ const f = motes.find((p) => p.life <= 0); if (!f) return;
209
+ const o = it.o, h = o.h;
210
+ f.p0.set(o.x + (Math.random() * 18 - 9), TRAY_TOP + h - 3 + 5, o.z + (Math.random() * 18 - 9));
211
+ f.tgt.copy(SG).add(new THREE.Vector3(Math.random() * 46 - 23, Math.random() * 22 - 6, Math.random() * 46 - 23));
212
+ f.life = 1; f.t = 0;
213
+ f.dur = big ? (0.7 + Math.random() * 0.4) : (1.0 + Math.random() * 0.6);
214
+ f.sz = big ? (3.4 + Math.random() * 2.8) : (1.7 + Math.random() * 1.9);
215
+ f.m.material.color.setHex(big ? (Math.random() < 0.7 ? 0xbfd6ff : 0xffe6c2) : 0x9fc4ff);
216
+ f.m.visible = true;
217
+ };
218
+ // pick a building weighted by token size, so the ambient flow + any fallback bursts favour bigger repos
219
+ const pickWeighted = () => {
220
+ let total = 0; for (const it of items) total += Math.sqrt(it.o.tokens || 1);
221
+ let r = Math.random() * total;
222
+ for (const it of items) { r -= Math.sqrt(it.o.tokens || 1); if (r <= 0) return it; }
223
+ return items[items.length - 1];
224
+ };
225
+ const fireBurst = (it, n) => {
226
+ if (it) { it.pop = 1; it.lit = Math.min(1, it.lit + 0.5); } // the building reacts (pop + flash) and keeps a "processed" glow
227
+ for (let k = 0; k < n; k++) launch(it, true);
228
+ signPulse = Math.max(signPulse, 1.9);
229
+ };
230
+
231
+ const corners = [];
232
+ // frame to maxTop + headroom so the floating labels and the signpost (which fly above the cube tops)
233
+ // are never clipped at the top edge.
234
+ const frameTop = maxTop + 70;
235
+ for (const sx of [CX - trayW / 2, CX + trayW / 2]) for (const sy of [0, frameTop]) for (const sz of [CZ - trayD / 2, CZ + trayD / 2]) corners.push(new THREE.Vector3(sx, sy, sz));
236
+ const autoFrame = () => {
237
+ const w = mount.clientWidth || 1, h = mount.clientHeight || 1; renderer.setSize(w, h, false);
238
+ const aspect = w / h; camera.updateMatrixWorld();
239
+ let a = 1e9, b = -1e9, c = 1e9, d = -1e9;
240
+ for (const cc of corners) { const q = cc.clone().applyMatrix4(camera.matrixWorldInverse); a = Math.min(a, q.x); b = Math.max(b, q.x); c = Math.min(c, q.y); d = Math.max(d, q.y); }
241
+ const mx = (a + b) / 2, my = (c + d) / 2, hw = ((b - a) / 2) * MARGIN_FACTOR, hh = ((d - c) / 2) * MARGIN_FACTOR;
242
+ const Vh = Math.max(hh, hw / aspect), Vw = Vh * aspect;
243
+ camera.left = mx - Vw; camera.right = mx + Vw; camera.top = my + Vh; camera.bottom = my - Vh; camera.updateProjectionMatrix();
244
+ };
245
+ autoFrame(); new ResizeObserver(autoFrame).observe(mount);
246
+
247
+ let actAmt = 0, actTarget = 0, running = false, mem = 0, shownMem = 0, ambientAcc = 0, clock = 0, prevCompleted = 0;
248
+ let last = performance.now();
249
+ const loop = (now) => {
250
+ requestAnimationFrame(loop);
251
+ const dt = Math.min(0.05, (now - last) / 1000); last = now; clock += dt;
252
+ if (Math.abs(actTarget - actAmt) > 0.001) actAmt += (actTarget - actAmt) * 0.06;
253
+ // signpost cross-fade: total-tokens "before" → memories "after" once extraction is engaged
254
+ tokSign.sp.material.opacity = Math.max(0, 1 - 1.6 * actAmt);
255
+ memSign.sp.material.opacity = Math.max(0, (actAmt - 0.12) / 0.88);
256
+ // per-building: a faint breathing shimmer while running, a POP (vertical stretch + bright flash) on
257
+ // the building whose conversation just completed, and a lasting "processed" glow that accumulates.
258
+ for (const it of items) {
259
+ it.pop *= 0.88;
260
+ const k = 1 + 0.2 * it.pop;
261
+ it.mesh.scale.set(1, k, 1);
262
+ it.mesh.position.y = TRAY_TOP + (it.o.h * k) / 2 - 3;
263
+ if (it.L) it.L.sp.position.y = TRAY_TOP + it.o.h * k - 3 + 6 + 0.36 * it.L.hw;
264
+ const breathe = running ? 0.05 * (0.5 + 0.5 * Math.sin(clock * 1.6 + it.o.x * 0.02)) : 0;
265
+ it.mesh.material.emissiveIntensity = breathe + 0.8 * it.pop + 0.2 * it.lit;
266
+ }
267
+ if (Math.abs(mem - shownMem) > 0.4) { shownMem += (mem - shownMem) * 0.1; drawMem(shownMem); }
268
+ // continuous ambient flow while a conversation is being processed
269
+ if (running) { ambientAcc += dt; while (ambientAcc > 0.11) { ambientAcc -= 0.11; launch(pickWeighted(), false); } }
270
+ for (const p of motes) {
271
+ if (p.life <= 0) continue;
272
+ p.t += dt / p.dur; const tt = p.t;
273
+ if (tt >= 1) { p.life = 0; p.m.visible = false; signPulse = Math.max(signPulse, 0.5); continue; }
274
+ const e = tt * tt * (3 - 2 * tt);
275
+ p.m.position.lerpVectors(p.p0, p.tgt, e); p.m.position.y += Math.sin(Math.PI * tt) * ARC;
276
+ const op = tt < 0.14 ? tt / 0.14 : (tt > 0.8 ? (1 - tt) / 0.2 : 1); p.m.material.opacity = op * 0.95;
277
+ const s = p.sz * (0.55 + 0.6 * Math.sin(Math.PI * Math.min(1, tt * 1.05))); p.m.scale.set(s, s, s);
278
+ }
279
+ if (signPulse > 0.002) { const k = 1 + 0.06 * signPulse; memSign.sp.scale.set(150 * k, 70 * k, 1); signPulse *= 0.9; }
280
+ else memSign.sp.scale.set(150, 70, 1);
281
+ renderer.render(scene, camera);
282
+ };
283
+ requestAnimationFrame((t) => { last = t; loop(t); });
284
+
285
+ // Drive the animation from real extraction progress: one burst per completed conversation, aimed at that
286
+ // conversation's repo building (p.latestRepo); the count is the real memory total (extracted). The city
287
+ // stays standing. Idle/pre-extract → the full city + the total-tokens signpost.
288
+ (async function pollProgress() {
289
+ if (!nonce) return;
290
+ for (;;) {
291
+ try {
292
+ const res = await fetch('/progress?nonce=' + encodeURIComponent(nonce), { credentials: 'omit' });
293
+ if (res.ok) {
294
+ const p = await res.json(); const status = p.status || 'idle';
295
+ const completed = p.completed || 0;
296
+ actTarget = (status === 'starting' || status === 'preparing' || status === 'running' || status === 'completed' || status === 'failed') ? 1 : 0;
297
+ running = status === 'running';
298
+ mem = p.extracted || 0;
299
+ if (status === 'idle') prevCompleted = 0;
300
+ if (completed > prevCompleted) {
301
+ const delta = completed - prevCompleted; prevCompleted = completed;
302
+ const src = (p.latestRepo && byName[p.latestRepo]) ? byName[p.latestRepo] : null;
303
+ for (let k = 0; k < delta; k++) fireBurst(src || pickWeighted(), 18);
304
+ }
305
+ }
306
+ } catch (_) { /* keep last */ }
307
+ await new Promise((r) => setTimeout(r, 1000));
308
+ }
309
+ })();
310
+ }
311
+
312
+ function separate(items, iters = 400) {
313
+ for (let k = 0; k < iters; k++) {
314
+ let moved = false;
315
+ for (let i = 0; i < items.length; i++) for (let j = i + 1; j < items.length; j++) {
316
+ const a = items[i], b = items[j];
317
+ const hx = (a.w + b.w) / 2 + GAP, hz = (a.d + b.d) / 2 + GAP, dx = b.x - a.x, dz = b.z - a.z;
318
+ const ox = hx - Math.abs(dx), oz = hz - Math.abs(dz);
319
+ if (ox > 0 && oz > 0) {
320
+ moved = true;
321
+ if (ox < oz) { const s = dx < 0 ? -1 : 1; if (a.pin && b.pin) {} else if (a.pin) b.x += ox * s; else if (b.pin) a.x -= ox * s; else { a.x -= ox * 0.5 * s; b.x += ox * 0.5 * s; } }
322
+ else { const s = dz < 0 ? -1 : 1; if (a.pin && b.pin) {} else if (a.pin) b.z += oz * s; else if (b.pin) a.z -= oz * s; else { a.z -= oz * 0.5 * s; b.z += oz * 0.5 * s; } }
323
+ }
324
+ }
325
+ if (!moved) break;
326
+ }
327
+ }
328
+ </script>
329
+ </body>
330
+ </html>
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ // Repo City share-card generator.
3
+ // node generate-echo-city-only.mjs → render echo-ai-city-only.html from card-data.json
4
+ // node generate-echo-city-only.mjs --refresh → (dev only) re-pull the scalar metrics from
5
+ // buildForensicReport() into card-data.json, then render
6
+ //
7
+ // Pipeline: echo-ai-city-only.template.html + card-data.json → echo-ai-city-only.html
8
+ // PURE data→html transform: same card-data.json → same html (no Date.now/random/hidden state).
9
+ // The 3-D city geometry + the persona block are still hand-authored design (not yet data-derived) —
10
+ // see docs/repo-city-setup-integration-plan.md for the path to rendering any user's scan.
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const DIR = path.dirname(fileURLToPath(import.meta.url));
17
+ const CARD = path.join(DIR, "card-data.json");
18
+ const TPL = path.join(DIR, "echo-ai-city-only.template.html");
19
+ const OUT = path.join(DIR, "echo-ai-city-only.html");
20
+
21
+ const MONTHS = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"];
22
+ // fail-loud: a null/invalid date must throw, not silently bake "JAN 1" / "undefined NaN".
23
+ // (new Date(null) === epoch, a *valid* date → "JAN 1", so null must be rejected before the Date.)
24
+ const fmtDate = (iso) => {
25
+ if (iso == null) throw new Error(`fmtDate: missing date (${iso})`);
26
+ const d = new Date(iso);
27
+ if (Number.isNaN(d.getTime())) throw new Error(`fmtDate: invalid date ${JSON.stringify(iso)}`);
28
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
29
+ };
30
+
31
+ async function refresh(card) {
32
+ // DEV-ONLY CLI: re-pull the scalar metrics from the live forensic report on this machine.
33
+ // The persona block (primary/rank/rhythm/sides/verdict) is hand-authored design, not in the
34
+ // report, so it is left untouched here. (For the setup web flow this whole path is replaced by a
35
+ // pure renderFromReport(r) that derives every field — see the integration plan doc.)
36
+ const { buildForensicReport } = await import("../packages/mcp-server/dist/forensics.js");
37
+ const r = buildForensicReport();
38
+
39
+ // Classify each model's tokens into the Codex/Claude split. Fail loud on an unrecognized
40
+ // provider instead of silently dumping it into the Claude bucket.
41
+ let codexTok = 0, claudeTok = 0;
42
+ for (const [model, v] of Object.entries(r.cost?.byModel || {})) {
43
+ const tok = v.total || 0;
44
+ if (/claude|opus|sonnet|haiku|anthropic/i.test(model)) claudeTok += tok;
45
+ else if (/gpt|codex|openai|o\d/i.test(model)) codexTok += tok;
46
+ else throw new Error(`refresh: unrecognized model provider ${JSON.stringify(model)} — cannot classify the Codex/Claude split`);
47
+ }
48
+ const totTok = codexTok + claudeTok || 1;
49
+ const codexPct = Math.round(codexTok / totTok * 100); // derive the other so they always sum to 100
50
+
51
+ card.computeCost = "$" + Math.round(r.cost.total).toLocaleString("en-US");
52
+ card.attentionHours = Number(r.userWorkingHours?.userAttentionHours ?? 0).toFixed(1);
53
+ card.split = { codexPct, claudePct: 100 - codexPct };
54
+ card.dirty = { pct: Number(r.cleanliness.staleReadSharePct).toFixed(1) };
55
+ if (r.counterfactual) {
56
+ card.counterfactual = {
57
+ ...card.counterfactual,
58
+ reachDate: fmtDate(r.counterfactual.counterfactualDate),
59
+ daysEarlier: Math.round(r.counterfactual.daysGained),
60
+ };
61
+ }
62
+ fs.writeFileSync(CARD, JSON.stringify(card, null, 2) + "\n");
63
+ console.log("↻ refreshed card-data.json from buildForensicReport()");
64
+ return card;
65
+ }
66
+
67
+ const card = JSON.parse(fs.readFileSync(CARD, "utf8"));
68
+ if (process.argv.includes("--refresh")) await refresh(card);
69
+
70
+ const personaVerdict = card.persona.verdict;
71
+
72
+ // "Jun 2" → month "JUN", day "2" for the counterfactual calendar icon. Guard the shape.
73
+ const reachParts = String(card.counterfactual.reachDate).trim().split(/\s+/);
74
+ if (reachParts.length !== 2) {
75
+ throw new Error(`counterfactual.reachDate must be "MON DD", got ${JSON.stringify(card.counterfactual.reachDate)}`);
76
+ }
77
+ const [cfReachMonth, cfReachDay] = reachParts;
78
+
79
+ // Coding Persona Card: side-trait chips (uniform — palette is limited to ink + the one red 85%).
80
+ const personaSides = card.persona.sides
81
+ .map((s) => `<span class="ct">${s}</span>`)
82
+ .join("");
83
+
84
+ // Exactly the tokens the template substitutes — nothing more (orphans are a static-analysis defect).
85
+ const tokens = {
86
+ computeCost: card.computeCost,
87
+ attentionHours: card.attentionHours,
88
+ codexPct: card.split.codexPct,
89
+ claudePct: card.split.claudePct,
90
+ personaPrimary: card.persona.primary,
91
+ personaRankRhythm: `${card.persona.rank} · ${card.persona.rhythm}`,
92
+ personaSides,
93
+ personaVerdict,
94
+ dirtyPct: card.dirty.pct,
95
+ cfReachMonth: cfReachMonth.toUpperCase(),
96
+ cfReachDay,
97
+ cfReachDate: card.counterfactual.reachDate,
98
+ cfInsteadOf: card.counterfactual.insteadOf,
99
+ cfDaysEarlier: card.counterfactual.daysEarlier,
100
+ };
101
+
102
+ // Substitute {{token}}. The detector matches ANY {{...}} (not just \w+) so a malformed placeholder
103
+ // can't slip through the fail-loud guard.
104
+ const template = fs.readFileSync(TPL, "utf8");
105
+ const html = template.replace(/\{\{([^}]+)\}\}/g, (_, raw) => {
106
+ const key = raw.trim();
107
+ if (!(key in tokens)) throw new Error(`Unknown template token: {{${key}}}`);
108
+ return String(tokens[key]);
109
+ });
110
+
111
+ fs.writeFileSync(OUT, html);
112
+ console.log("✓ wrote", path.relative(process.cwd(), OUT));