@hybridlabor-api/bdb-synapse 1.0.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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/synapse +0 -0
  4. package/cmd/rubriceval/main.go +308 -0
  5. package/cmd/synapse/main.go +280 -0
  6. package/cmd/synapse/main_test.go +16 -0
  7. package/go.mod +7 -0
  8. package/go.sum +6 -0
  9. package/internal/adapter/adapter.go +1117 -0
  10. package/internal/adapter/adapter_test.go +518 -0
  11. package/internal/adapter/agy/adapter.go +193 -0
  12. package/internal/adapter/claudecode/adapter.go +415 -0
  13. package/internal/adapter/claudecode/adapter_test.go +260 -0
  14. package/internal/adapter/claudecode/agents.go +387 -0
  15. package/internal/adapter/claudecode/agents_test.go +480 -0
  16. package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
  17. package/internal/adapter/codex/adapter.go +919 -0
  18. package/internal/adapter/codex/adapter_test.go +920 -0
  19. package/internal/adapter/codex/agents.go +401 -0
  20. package/internal/adapter/codex/agents_test.go +610 -0
  21. package/internal/adapter/codex/summary_inputs_test.go +20 -0
  22. package/internal/adapter/pi/adapter.go +483 -0
  23. package/internal/adapter/pi/adapter_test.go +517 -0
  24. package/internal/citymap/builder.go +1124 -0
  25. package/internal/citymap/builder_test.go +818 -0
  26. package/internal/judge/cache.go +180 -0
  27. package/internal/judge/cli.go +240 -0
  28. package/internal/judge/cli_test.go +68 -0
  29. package/internal/judge/fresh_summary_test.go +37 -0
  30. package/internal/judge/input.go +233 -0
  31. package/internal/judge/judge.go +416 -0
  32. package/internal/judge/judge_test.go +288 -0
  33. package/internal/judge/prompt.go +129 -0
  34. package/internal/judge/rubric.go +275 -0
  35. package/internal/judge/rubric_test.go +642 -0
  36. package/internal/model/agent.go +63 -0
  37. package/internal/model/agent_schema_test.go +86 -0
  38. package/internal/model/agent_test.go +64 -0
  39. package/internal/model/model.go +175 -0
  40. package/internal/model/report.go +166 -0
  41. package/internal/model/stats.go +151 -0
  42. package/internal/model/stats_test.go +89 -0
  43. package/internal/model/trace_schema_test.go +67 -0
  44. package/internal/server/analyze.go +286 -0
  45. package/internal/server/analyze_test.go +297 -0
  46. package/internal/server/codex_index_test.go +40 -0
  47. package/internal/server/hardening_test.go +147 -0
  48. package/internal/server/reportindex.go +91 -0
  49. package/internal/server/reportindex_test.go +116 -0
  50. package/internal/server/server.go +1099 -0
  51. package/internal/server/server_test.go +1389 -0
  52. package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
  53. package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
  54. package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
  55. package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
  56. package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
  57. package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
  58. package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
  59. package/internal/server/static/assets/index-C_adLrJr.js +3 -0
  60. package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
  61. package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  62. package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  63. package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
  64. package/internal/server/static/index.html +26 -0
  65. package/internal/server/tracestore.go +173 -0
  66. package/internal/server/tracestore_test.go +51 -0
  67. package/internal/textutil/truncate.go +30 -0
  68. package/internal/textutil/truncate_test.go +39 -0
  69. package/package.json +35 -0
  70. package/web/e2e/agent-lens.spec.ts +688 -0
  71. package/web/index.html +23 -0
  72. package/web/package-lock.json +1933 -0
  73. package/web/package.json +33 -0
  74. package/web/playwright.config.ts +24 -0
  75. package/web/src/App.tsx +876 -0
  76. package/web/src/api/client.ts +74 -0
  77. package/web/src/main.tsx +12 -0
  78. package/web/src/playback/recorder.ts +160 -0
  79. package/web/src/playback/reducer.ts +91 -0
  80. package/web/src/scene/CityScene.tsx +638 -0
  81. package/web/src/scene/TreeScene.tsx +656 -0
  82. package/web/src/scene/dirLabels.ts +145 -0
  83. package/web/src/scene/sceneUtils.ts +144 -0
  84. package/web/src/scene/textures.ts +60 -0
  85. package/web/src/scene/trail.ts +79 -0
  86. package/web/src/scene/treeLayout.ts +169 -0
  87. package/web/src/state/filters.ts +40 -0
  88. package/web/src/state/store.ts +83 -0
  89. package/web/src/styles.css +2565 -0
  90. package/web/src/types.ts +315 -0
  91. package/web/src/ui/AgentsPanel.tsx +376 -0
  92. package/web/src/ui/Dock.tsx +104 -0
  93. package/web/src/ui/Hud.tsx +335 -0
  94. package/web/src/ui/Inspector.tsx +107 -0
  95. package/web/src/ui/LogoMark.tsx +38 -0
  96. package/web/src/ui/ReportPanel.tsx +491 -0
  97. package/web/src/ui/SessionRail.tsx +316 -0
  98. package/web/src/ui/Timeline.tsx +458 -0
  99. package/web/src/ui/ViewPanel.tsx +45 -0
  100. package/web/src/ui/shortcuts.ts +4 -0
  101. package/web/tsconfig.json +21 -0
  102. package/web/vite.config.ts +28 -0
@@ -0,0 +1,145 @@
1
+ import * as THREE from "three";
2
+ import { labelTexture } from "./textures";
3
+
4
+ // Map-style LOD for directory names, shared by both scenes: a label shows
5
+ // while its subtree spans enough screen pixels — zoom out and only the big
6
+ // forks stay named, push in and deeper directories surface where you're
7
+ // looking. Two colliding labels drop whichever names fewer files.
8
+
9
+ export interface DirLabelEntry {
10
+ name: string;
11
+ x: number;
12
+ z: number;
13
+ /** world-space reach of the directory's subtree, drives the LOD cutoff */
14
+ radius: number;
15
+ fileCount: number;
16
+ depth: number;
17
+ }
18
+
19
+ interface DirLabel {
20
+ sprite: THREE.Sprite;
21
+ entry: DirLabelEntry;
22
+ aspect: number;
23
+ target: number;
24
+ }
25
+
26
+ const LABEL_MIN_SUBTREE_PX = 60;
27
+ const LABEL_BUDGET = 120;
28
+
29
+ export class DirLabelSet {
30
+ private readonly labels: DirLabel[] = [];
31
+ private readonly y: number;
32
+ private dirty = true;
33
+ private readonly lastCamPos = new THREE.Vector3(Infinity, Infinity, Infinity);
34
+ private readonly lastCamQuat = new THREE.Quaternion(0, 0, 0, 0);
35
+ private lastViewW = 0;
36
+ private lastViewH = 0;
37
+ private readonly point = new THREE.Vector3();
38
+
39
+ // sprites start invisible and fade in once the first projection pass runs;
40
+ // alwaysOnTop labels (terrain) skip the depth test so mountains never bury
41
+ // the district names
42
+ constructor(entries: DirLabelEntry[], group: THREE.Group, y: number, alwaysOnTop = false) {
43
+ this.y = y;
44
+ const budget = [...entries].sort((a, b) => b.fileCount - a.fileCount).slice(0, LABEL_BUDGET);
45
+ for (const entry of budget) {
46
+ const { texture, aspect } = labelTexture(entry.name);
47
+ const sprite = new THREE.Sprite(
48
+ new THREE.SpriteMaterial({
49
+ map: texture,
50
+ transparent: true,
51
+ opacity: 0,
52
+ depthWrite: false,
53
+ depthTest: !alwaysOnTop,
54
+ toneMapped: false,
55
+ fog: false
56
+ })
57
+ );
58
+ if (alwaysOnTop) sprite.renderOrder = 20;
59
+ sprite.visible = false;
60
+ sprite.position.set(entry.x, y, entry.z);
61
+ sprite.raycast = () => undefined;
62
+ group.add(sprite);
63
+ this.labels.push({ sprite, entry, aspect, target: 0 });
64
+ }
65
+ }
66
+
67
+ markDirty() {
68
+ this.dirty = true;
69
+ }
70
+
71
+ // reprojects subtrees whenever the camera or viewport moves, picks the
72
+ // labels whose subtree is prominent on screen, drops collision losers
73
+ updateTargets(camera: THREE.PerspectiveCamera, viewW: number, viewH: number) {
74
+ if (this.labels.length === 0 || viewW === 0 || viewH === 0) return;
75
+ const moved =
76
+ this.dirty ||
77
+ !camera.position.equals(this.lastCamPos) ||
78
+ !camera.quaternion.equals(this.lastCamQuat) ||
79
+ viewW !== this.lastViewW ||
80
+ viewH !== this.lastViewH;
81
+ if (!moved) return;
82
+ this.dirty = false;
83
+ this.lastCamPos.copy(camera.position);
84
+ this.lastCamQuat.copy(camera.quaternion);
85
+ this.lastViewW = viewW;
86
+ this.lastViewH = viewH;
87
+
88
+ const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
89
+ const maxDim = Math.max(viewW, viewH);
90
+ interface Candidate {
91
+ label: DirLabel;
92
+ sx: number;
93
+ sy: number;
94
+ pw: number;
95
+ ph: number;
96
+ }
97
+ const candidates: Candidate[] = [];
98
+ for (const label of this.labels) {
99
+ this.point.set(label.entry.x, this.y, label.entry.z);
100
+ const dist = this.point.distanceTo(camera.position);
101
+ const pxPerWorld = viewH / (2 * dist * tanV);
102
+ const subtreePx = label.entry.radius * pxPerWorld;
103
+ this.point.project(camera);
104
+ const sx = ((this.point.x + 1) / 2) * viewW;
105
+ const sy = ((1 - this.point.y) / 2) * viewH;
106
+ const onScreen = this.point.z < 1 && sx > -60 && sx < viewW + 60 && sy > -40 && sy < viewH + 40;
107
+ // too small to matter, or so large we're inside it — either way the
108
+ // name would float over unrelated geometry
109
+ if (!onScreen || subtreePx < LABEL_MIN_SUBTREE_PX || subtreePx > maxDim * 1.6) {
110
+ label.target = 0;
111
+ continue;
112
+ }
113
+ // constant screen-size type, like map labels
114
+ const ph = label.entry.depth <= 1 ? 15 : 13;
115
+ const worldH = ph / pxPerWorld;
116
+ label.sprite.scale.set(worldH * label.aspect, worldH, 1);
117
+ candidates.push({ label, sx, sy, pw: ph * label.aspect, ph });
118
+ }
119
+ candidates.sort((a, b) => b.label.entry.fileCount - a.label.entry.fileCount);
120
+ const kept: Candidate[] = [];
121
+ for (const candidate of candidates) {
122
+ const clash = kept.some(
123
+ (other) =>
124
+ Math.abs(other.sx - candidate.sx) < (other.pw + candidate.pw) / 2 + 14 &&
125
+ Math.abs(other.sy - candidate.sy) < (other.ph + candidate.ph) / 2 + 10
126
+ );
127
+ candidate.label.target = clash ? 0 : 1;
128
+ if (!clash) kept.push(candidate);
129
+ }
130
+ }
131
+
132
+ // labels ease toward their LOD targets each frame
133
+ ease(reduced: boolean) {
134
+ for (const label of this.labels) {
135
+ const material = label.sprite.material as THREE.SpriteMaterial;
136
+ const diff = label.target - material.opacity;
137
+ if (Math.abs(diff) > 0.02) {
138
+ material.opacity = reduced ? label.target : material.opacity + diff * 0.16;
139
+ } else if (material.opacity !== label.target) {
140
+ material.opacity = label.target;
141
+ }
142
+ label.sprite.visible = material.opacity > 0.02;
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,144 @@
1
+ import * as THREE from "three";
2
+ import type { Touch } from "../types";
3
+
4
+ // Shared scene vocabulary. The touch colors are the meaning the HUD legend
5
+ // promises; both scenes must draw them identically. Ambient colors (ground,
6
+ // unvisited, ghost) stay per-scene tuning.
7
+ export const SKY = new THREE.Color("#12151c");
8
+ export const EMBER = new THREE.Color("#ff9e5e");
9
+
10
+ export const touchColors: Record<Touch | "selected", THREE.Color> = {
11
+ hit: new THREE.Color("#8fb45f"),
12
+ // chromatic enough to read as blue on lit terrain columns — a paler tint
13
+ // washed out to white and stopped matching the HUD legend
14
+ read: new THREE.Color("#a5c8f1"),
15
+ edit: new THREE.Color("#f0ad5a"),
16
+ selected: new THREE.Color("#f6ead2")
17
+ };
18
+
19
+ // Distance along `dir` that fits every point inside the camera frustum.
20
+ // Returns null while the viewport has no usable aspect (hidden pane, tab in
21
+ // the background, mid-layout) — fitting then would park the camera at NaN
22
+ // forever; callers must retry once a real resize lands.
23
+ export function fitDistance(
24
+ camera: THREE.PerspectiveCamera,
25
+ dir: THREE.Vector3,
26
+ points: Iterable<THREE.Vector3>
27
+ ): number | null {
28
+ if (!Number.isFinite(camera.aspect) || camera.aspect <= 0) return null;
29
+ const forward = dir.clone().negate();
30
+ const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
31
+ const up = new THREE.Vector3().crossVectors(right, forward);
32
+ const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
33
+ const tanH = tanV * camera.aspect;
34
+ let distance = 0;
35
+ for (const point of points) {
36
+ const depth = point.dot(forward);
37
+ distance = Math.max(
38
+ distance,
39
+ Math.abs(point.dot(right)) / tanH - depth,
40
+ Math.abs(point.dot(up)) / tanV - depth
41
+ );
42
+ }
43
+ return distance;
44
+ }
45
+
46
+ // Pan the camera (position + orbit target together, so the view direction
47
+ // holds) until `world` projects inside the viewport's safe area. The right
48
+ // margin reserves room for the inspector panel, which would otherwise sit
49
+ // exactly on top of the leaf the user just selected.
50
+ export function ensureVisible(
51
+ camera: THREE.PerspectiveCamera,
52
+ controls: { target: THREE.Vector3; update: () => void },
53
+ world: THREE.Vector3,
54
+ viewW: number,
55
+ viewH: number,
56
+ reservedRight: number
57
+ ) {
58
+ if (viewW === 0 || viewH === 0) return;
59
+ const forward = camera.getWorldDirection(new THREE.Vector3());
60
+ const depth = world.clone().sub(camera.position).dot(forward);
61
+ if (depth <= 0) return; // behind the camera: panning math breaks down
62
+ const projected = world.clone().project(camera);
63
+ const sx = ((projected.x + 1) / 2) * viewW;
64
+ const sy = ((1 - projected.y) / 2) * viewH;
65
+ const safeL = 48;
66
+ const safeR = Math.max(safeL + 60, viewW - reservedRight - 48);
67
+ const safeT = 120;
68
+ const safeB = viewH - 100;
69
+ const targetX = Math.min(Math.max(sx, safeL), safeR);
70
+ const targetY = Math.min(Math.max(sy, safeT), safeB);
71
+ if (targetX === sx && targetY === sy) return;
72
+ const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
73
+ const tanH = tanV * camera.aspect;
74
+ const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
75
+ const up = new THREE.Vector3().crossVectors(right, forward);
76
+ // moving the camera right shifts the point left on screen, and vice versa
77
+ const pan = right
78
+ .multiplyScalar(((sx - targetX) * 2 * depth * tanH) / viewW)
79
+ .addScaledVector(up, ((targetY - sy) * 2 * depth * tanV) / viewH);
80
+ camera.position.add(pan);
81
+ controls.target.add(pan);
82
+ controls.update();
83
+ }
84
+
85
+ // One-line hover readout, driven imperatively from the scenes' pointermove
86
+ // handlers so hovering never re-renders the React tree.
87
+ export class SceneTip {
88
+ private readonly el: HTMLDivElement;
89
+ private readonly pathEl: HTMLSpanElement;
90
+ private readonly metaEl: HTMLSpanElement;
91
+
92
+ constructor(private readonly host: HTMLElement) {
93
+ this.el = document.createElement("div");
94
+ this.el.className = "scene-tip";
95
+ this.pathEl = document.createElement("span");
96
+ this.metaEl = document.createElement("span");
97
+ this.metaEl.className = "dim";
98
+ this.el.append(this.pathEl, this.metaEl);
99
+ host.appendChild(this.el);
100
+ }
101
+
102
+ show(path: string, meta: string, clientX: number, clientY: number) {
103
+ this.pathEl.textContent = path;
104
+ this.metaEl.textContent = ` · ${meta}`;
105
+ this.el.style.display = "block";
106
+ const bounds = this.host.getBoundingClientRect();
107
+ const x = clientX - bounds.left;
108
+ const y = clientY - bounds.top;
109
+ const left = Math.min(x + 14, Math.max(0, bounds.width - this.el.offsetWidth - 8));
110
+ const top = Math.min(y + 16, Math.max(0, bounds.height - this.el.offsetHeight - 8));
111
+ this.el.style.left = `${left}px`;
112
+ this.el.style.top = `${top}px`;
113
+ }
114
+
115
+ hide() {
116
+ this.el.style.display = "none";
117
+ }
118
+
119
+ dispose() {
120
+ this.el.remove();
121
+ }
122
+ }
123
+
124
+ export const prefersReducedMotion = () =>
125
+ typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
126
+
127
+ export function disposeGroup(group: THREE.Group) {
128
+ group.traverse((obj) => {
129
+ if (obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Sprite) {
130
+ obj.geometry?.dispose();
131
+ const mat = obj.material as THREE.Material | THREE.Material[];
132
+ if (Array.isArray(mat)) mat.forEach(disposeMaterial);
133
+ else if (mat) disposeMaterial(mat);
134
+ }
135
+ });
136
+ }
137
+
138
+ function disposeMaterial(mat: THREE.Material) {
139
+ // Material.dispose() does not free assigned textures; module-cached
140
+ // textures (fireflyTexture/haloTexture) are marked shared and must survive.
141
+ const map = (mat as THREE.Material & { map?: THREE.Texture | null }).map;
142
+ if (map && !map.userData.shared) map.dispose();
143
+ mat.dispose();
144
+ }
@@ -0,0 +1,60 @@
1
+ import * as THREE from "three";
2
+
3
+ let fireflyMap: THREE.Texture | null = null;
4
+ export function fireflyTexture(): THREE.Texture {
5
+ if (fireflyMap) return fireflyMap;
6
+ const size = 64;
7
+ const canvas = document.createElement("canvas");
8
+ canvas.width = size;
9
+ canvas.height = size;
10
+ const ctx = canvas.getContext("2d")!;
11
+ const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
12
+ g.addColorStop(0, "rgba(255,255,255,1)");
13
+ g.addColorStop(0.25, "rgba(255,210,160,0.55)");
14
+ g.addColorStop(1, "rgba(255,158,94,0)");
15
+ ctx.fillStyle = g;
16
+ ctx.fillRect(0, 0, size, size);
17
+ fireflyMap = new THREE.CanvasTexture(canvas);
18
+ fireflyMap.userData.shared = true; // module cache: disposeGroup must not free it
19
+ return fireflyMap;
20
+ }
21
+
22
+ let haloMap: THREE.Texture | null = null;
23
+ export function haloTexture(): THREE.Texture {
24
+ if (haloMap) return haloMap;
25
+ const size = 128;
26
+ const canvas = document.createElement("canvas");
27
+ canvas.width = size;
28
+ canvas.height = size;
29
+ const ctx = canvas.getContext("2d")!;
30
+ const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
31
+ g.addColorStop(0, "rgba(255,255,255,0.9)");
32
+ g.addColorStop(0.4, "rgba(255,255,255,0.28)");
33
+ g.addColorStop(1, "rgba(255,255,255,0)");
34
+ ctx.fillStyle = g;
35
+ ctx.fillRect(0, 0, size, size);
36
+ haloMap = new THREE.CanvasTexture(canvas);
37
+ haloMap.userData.shared = true; // module cache: disposeGroup must not free it
38
+ return haloMap;
39
+ }
40
+
41
+ export function labelTexture(text: string): { texture: THREE.Texture; aspect: number } {
42
+ const font = '500 30px "Schibsted Grotesk Variable", "PingFang SC", sans-serif';
43
+ const measure = document.createElement("canvas").getContext("2d")!;
44
+ measure.font = font;
45
+ const width = Math.ceil(measure.measureText(text).width) + 24;
46
+ const height = 44;
47
+ const canvas = document.createElement("canvas");
48
+ canvas.width = width * 2;
49
+ canvas.height = height * 2;
50
+ const ctx = canvas.getContext("2d")!;
51
+ ctx.scale(2, 2);
52
+ ctx.font = font;
53
+ ctx.textBaseline = "middle";
54
+ ctx.textAlign = "center";
55
+ ctx.fillStyle = "rgba(197, 205, 222, 0.95)";
56
+ ctx.fillText(text, width / 2, height / 2 + 1);
57
+ const texture = new THREE.CanvasTexture(canvas);
58
+ texture.anisotropy = 4;
59
+ return { texture, aspect: width / height };
60
+ }
@@ -0,0 +1,79 @@
1
+ import * as THREE from "three";
2
+ import { EMBER } from "./sceneUtils";
3
+
4
+ const SAMPLES = 14;
5
+ const MAX_ARCS = 11; // playback keeps 12 recent targets → at most 11 arcs
6
+
7
+ // Fixed-capacity trail: the arc soup changes every playback tick, so the
8
+ // geometry and material are allocated once and only the attribute contents
9
+ // (plus the draw range) are rewritten in place.
10
+ export class TrailRenderer {
11
+ readonly object: THREE.LineSegments;
12
+ private readonly positions: THREE.BufferAttribute;
13
+ private readonly colors: THREE.BufferAttribute;
14
+ private readonly lift: number;
15
+ private readonly arcPoints = Array.from({ length: SAMPLES + 1 }, () => new THREE.Vector3());
16
+ private readonly mid = new THREE.Vector3();
17
+ private readonly curve = new THREE.QuadraticBezierCurve3();
18
+ private readonly color = new THREE.Color();
19
+
20
+ constructor(lift: number) {
21
+ this.lift = lift;
22
+ const vertexCount = MAX_ARCS * SAMPLES * 2;
23
+ const geometry = new THREE.BufferGeometry();
24
+ this.positions = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
25
+ this.positions.setUsage(THREE.DynamicDrawUsage);
26
+ this.colors = new THREE.BufferAttribute(new Float32Array(vertexCount * 3), 3);
27
+ this.colors.setUsage(THREE.DynamicDrawUsage);
28
+ geometry.setAttribute("position", this.positions);
29
+ geometry.setAttribute("color", this.colors);
30
+ geometry.setDrawRange(0, 0);
31
+ const material = new THREE.LineBasicMaterial({
32
+ vertexColors: true,
33
+ transparent: true,
34
+ blending: THREE.AdditiveBlending,
35
+ depthWrite: false,
36
+ fog: false
37
+ });
38
+ this.object = new THREE.LineSegments(geometry, material);
39
+ this.object.frustumCulled = false;
40
+ }
41
+
42
+ // points are the recent fixations, oldest first, y already at arc height
43
+ update(points: THREE.Vector3[]) {
44
+ const arcs = Math.min(Math.max(points.length - 1, 0), MAX_ARCS);
45
+ if (arcs === 0) {
46
+ this.object.geometry.setDrawRange(0, 0);
47
+ return;
48
+ }
49
+ const start = points.length - 1 - arcs;
50
+ let v = 0;
51
+ for (let i = 1; i <= arcs; i++) {
52
+ const a = points[start + i - 1];
53
+ const b = points[start + i];
54
+ this.mid.copy(a).lerp(b, 0.5);
55
+ this.mid.y = Math.max(a.y, b.y) + a.distanceTo(b) * 0.22 + this.lift;
56
+ this.curve.v0.copy(a);
57
+ this.curve.v1.copy(this.mid);
58
+ this.curve.v2.copy(b);
59
+ for (let s = 0; s <= SAMPLES; s++) {
60
+ this.curve.getPoint(s / SAMPLES, this.arcPoints[s]);
61
+ }
62
+ const recency = i / arcs;
63
+ this.color.copy(EMBER).multiplyScalar(0.05 + 0.95 * recency * recency);
64
+ for (let s = 0; s < SAMPLES; s++) {
65
+ const pa = this.arcPoints[s];
66
+ const pb = this.arcPoints[s + 1];
67
+ this.positions.setXYZ(v, pa.x, pa.y, pa.z);
68
+ this.colors.setXYZ(v, this.color.r, this.color.g, this.color.b);
69
+ v++;
70
+ this.positions.setXYZ(v, pb.x, pb.y, pb.z);
71
+ this.colors.setXYZ(v, this.color.r, this.color.g, this.color.b);
72
+ v++;
73
+ }
74
+ }
75
+ this.object.geometry.setDrawRange(0, v);
76
+ this.positions.needsUpdate = true;
77
+ this.colors.needsUpdate = true;
78
+ }
79
+ }
@@ -0,0 +1,169 @@
1
+ import type { CityFile } from "../types";
2
+
3
+ // Deterministic radial tree: root at the origin, directories as branch
4
+ // forks, files as leaf slots. Every leaf gets an equal angular share in
5
+ // path-sorted order, so the same commit always grows the same tree.
6
+
7
+ export interface TreeDir {
8
+ path: string;
9
+ name: string;
10
+ x: number;
11
+ z: number;
12
+ depth: number;
13
+ fileCount: number;
14
+ // how far the subtree's leaves reach from the fork, in layout units —
15
+ // the scene projects this to screen pixels to decide when the directory
16
+ // is prominent enough to deserve its label
17
+ radius: number;
18
+ }
19
+
20
+ export interface TreeEdge {
21
+ childPath?: string;
22
+ childFileId?: number;
23
+ points: { x: number; z: number }[];
24
+ }
25
+
26
+ export interface TreeLayout {
27
+ radius: number;
28
+ leaf: Map<number, { x: number; z: number }>;
29
+ dirs: TreeDir[];
30
+ edges: TreeEdge[];
31
+ }
32
+
33
+ interface Node {
34
+ name: string;
35
+ path: string;
36
+ depth: number;
37
+ children: Map<string, Node>;
38
+ files: CityFile[];
39
+ leafCount: number;
40
+ angle: number;
41
+ }
42
+
43
+ export function computeTreeLayout(files: CityFile[]): TreeLayout {
44
+ const root: Node = { name: "", path: "", depth: 0, children: new Map(), files: [], leafCount: 0, angle: 0 };
45
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : 1));
46
+ for (const file of sorted) {
47
+ const parts = file.path.split("/").filter(Boolean);
48
+ let node = root;
49
+ for (const part of parts.slice(0, -1)) {
50
+ let next = node.children.get(part);
51
+ if (!next) {
52
+ next = {
53
+ name: part,
54
+ path: node.path ? `${node.path}/${part}` : part,
55
+ depth: node.depth + 1,
56
+ children: new Map(),
57
+ files: [],
58
+ leafCount: 0,
59
+ angle: 0
60
+ };
61
+ node.children.set(part, next);
62
+ }
63
+ node = next;
64
+ }
65
+ node.files.push(file);
66
+ }
67
+
68
+ let maxDepth = 1;
69
+ const countLeaves = (node: Node): number => {
70
+ node.leafCount = node.files.length;
71
+ if (node.files.length > 0) maxDepth = Math.max(maxDepth, node.depth + 1);
72
+ for (const child of node.children.values()) {
73
+ node.leafCount += countLeaves(child);
74
+ }
75
+ return node.leafCount;
76
+ };
77
+ countLeaves(root);
78
+
79
+ const total = Math.max(root.leafCount, 1);
80
+ const radius = Math.max(55, Math.sqrt(total) * 4);
81
+ const step = radius / Math.max(maxDepth, 1);
82
+ const slotAngle = (Math.PI * 2) / total;
83
+
84
+ const layout: TreeLayout = { radius, leaf: new Map(), dirs: [], edges: [] };
85
+ const polar = (r: number, angle: number) => ({ x: r * Math.cos(angle), z: r * Math.sin(angle) });
86
+
87
+ const sampleEdge = (r1: number, a1: number, r2: number, a2: number): { x: number; z: number }[] => {
88
+ // straight spokes out of the root, eased sweeps between rings
89
+ const from = r1 < 1e-6 ? a2 : a1;
90
+ let delta = a2 - from;
91
+ while (delta > Math.PI) delta -= Math.PI * 2;
92
+ while (delta < -Math.PI) delta += Math.PI * 2;
93
+ const points: { x: number; z: number }[] = [];
94
+ const SEGMENTS = 8;
95
+ for (let s = 0; s <= SEGMENTS; s++) {
96
+ const t = s / SEGMENTS;
97
+ const ease = t * t * (3 - 2 * t);
98
+ points.push(polar(r1 + (r2 - r1) * t, from + delta * ease));
99
+ }
100
+ return points;
101
+ };
102
+
103
+ let cursor = 0;
104
+ const place = (node: Node) => {
105
+ node.angle = (cursor + node.leafCount / 2) * slotAngle;
106
+ const dirs = [...node.children.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
107
+ const files = [...node.files].sort((a, b) => (a.path < b.path ? -1 : 1));
108
+ for (const child of dirs) {
109
+ place(child);
110
+ }
111
+ for (const file of files) {
112
+ const angle = (cursor + 0.5) * slotAngle;
113
+ cursor += 1;
114
+ const pos = polar((node.depth + 1) * step, angle);
115
+ layout.leaf.set(file.id, pos);
116
+ layout.edges.push({
117
+ childFileId: file.id,
118
+ points: sampleEdge(node.depth * step, node.angle, (node.depth + 1) * step, angle)
119
+ });
120
+ }
121
+ if (node.path !== "") {
122
+ const pos = polar(node.depth * step, node.angle);
123
+ layout.dirs.push({
124
+ path: node.path,
125
+ name: node.name,
126
+ x: pos.x,
127
+ z: pos.z,
128
+ depth: node.depth,
129
+ fileCount: node.leafCount,
130
+ radius: 0
131
+ });
132
+ }
133
+ };
134
+ place(root);
135
+
136
+ // parent→dir edges need final angles, so wire them after placement
137
+ const nodeByPath = new Map<string, Node>();
138
+ const collect = (node: Node) => {
139
+ nodeByPath.set(node.path, node);
140
+ for (const child of node.children.values()) collect(child);
141
+ };
142
+ collect(root);
143
+ for (const dir of layout.dirs) {
144
+ const node = nodeByPath.get(dir.path)!;
145
+ const parentPath = dir.path.includes("/") ? dir.path.slice(0, dir.path.lastIndexOf("/")) : "";
146
+ const parent = nodeByPath.get(parentPath)!;
147
+ layout.edges.push({
148
+ childPath: dir.path,
149
+ points: sampleEdge(parent.depth * step, parent.angle, node.depth * step, node.angle)
150
+ });
151
+ }
152
+
153
+ // subtree reach: every leaf stretches the radius of each ancestor dir
154
+ const dirByPath = new Map(layout.dirs.map((dir) => [dir.path, dir]));
155
+ for (const file of files) {
156
+ const pos = layout.leaf.get(file.id);
157
+ if (!pos) continue;
158
+ const parts = file.path.split("/").filter(Boolean);
159
+ let path = "";
160
+ for (let i = 0; i < parts.length - 1; i++) {
161
+ path = path ? `${path}/${parts[i]}` : parts[i];
162
+ const dir = dirByPath.get(path);
163
+ if (!dir) continue;
164
+ dir.radius = Math.max(dir.radius, Math.hypot(pos.x - dir.x, pos.z - dir.z));
165
+ }
166
+ }
167
+
168
+ return layout;
169
+ }
@@ -0,0 +1,40 @@
1
+ import type { SessionMeta } from "../types";
2
+
3
+ export interface SessionFilters {
4
+ hideEmpty: boolean;
5
+ harness?: string;
6
+ }
7
+
8
+ const STORAGE_KEY = "mindwalk.sessionFilters";
9
+
10
+ // activeKey keeps an explicitly opened session visible even when it has no
11
+ // calls; the harness filter is deliberate user intent, so it still applies
12
+ export function sessionVisible(session: SessionMeta, filters: SessionFilters, activeKey?: string): boolean {
13
+ if (filters.harness && session.harness !== filters.harness) return false;
14
+ if (filters.hideEmpty && session.eventCount === 0 && session.key !== activeKey) return false;
15
+ return true;
16
+ }
17
+
18
+ export function loadFilters(): SessionFilters {
19
+ try {
20
+ const raw = localStorage.getItem(STORAGE_KEY);
21
+ if (raw) {
22
+ const parsed = JSON.parse(raw) as Partial<SessionFilters>;
23
+ return {
24
+ hideEmpty: parsed.hideEmpty !== false,
25
+ harness: typeof parsed.harness === "string" ? parsed.harness : undefined
26
+ };
27
+ }
28
+ } catch {
29
+ // corrupted storage: fall through to defaults
30
+ }
31
+ return { hideEmpty: true };
32
+ }
33
+
34
+ export function saveFilters(filters: SessionFilters): void {
35
+ try {
36
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
37
+ } catch {
38
+ // storage unavailable: filters reset on next load
39
+ }
40
+ }