get-claudia 1.35.2 → 1.36.1

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.
package/bin/index.js CHANGED
@@ -412,13 +412,15 @@ async function main() {
412
412
  runGatewaySetup((gatewayOk) => maybeRunRelay(memoryInstalled, visualizerInstalled, gatewayOk));
413
413
  }
414
414
 
415
- // Helper: auto-install visualizer after memory (if memory was installed), then chain to gateway
415
+ // Helper: auto-install visualizer after memory, then chain to gateway
416
+ // On upgrades, always attempt visualizer install even if memory step had issues,
417
+ // since the database likely already exists from a prior install.
416
418
  function maybeRunVisualizer(memoryInstalled) {
417
- if (memoryInstalled) {
418
- // Visualizer auto-installs when memory is installed (needs the database)
419
+ const memoryDbExists = existsSync(join(homedir(), '.claudia', 'memory'));
420
+ if (memoryInstalled || isUpgrade || memoryDbExists) {
419
421
  runVisualizerSetup((vizOk) => maybeRunGateway(memoryInstalled, vizOk));
420
422
  } else {
421
- // Skip visualizer if no memory system
423
+ // Fresh install with no memory system -- skip visualizer
422
424
  maybeRunGateway(memoryInstalled, false);
423
425
  }
424
426
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get-claudia",
3
- "version": "1.35.2",
3
+ "version": "1.36.1",
4
4
  "description": "An AI assistant who learns how you work.",
5
5
  "keywords": [
6
6
  "claudia",
@@ -0,0 +1,159 @@
1
+ /**
2
+ * DBSCAN clustering on 3D UMAP coordinates.
3
+ * Groups semantically similar nodes into clusters for convex hull visualization.
4
+ */
5
+
6
+ let cachedClusters = null;
7
+ let cacheTimestamp = 0;
8
+ const CACHE_TTL = 60000; // 1 minute (same as UMAP)
9
+
10
+ /**
11
+ * Compute DBSCAN clusters from 3D positions.
12
+ * @param {Object} positions - Map of nodeId -> { x, y, z }
13
+ * @param {Array} nodes - Graph nodes array
14
+ * @returns {Array} clusters - [{ id, label, memberIds, centroid }]
15
+ */
16
+ export function computeClusters(positions, nodes, { eps = 30, minPts = 3 } = {}) {
17
+ if (cachedClusters && Date.now() - cacheTimestamp < CACHE_TTL) {
18
+ return cachedClusters;
19
+ }
20
+
21
+ // Only cluster nodes that have UMAP positions
22
+ const posEntries = [];
23
+ for (const node of nodes) {
24
+ const pos = positions[node.id];
25
+ if (pos) {
26
+ posEntries.push({ id: node.id, node, pos: [pos.x, pos.y, pos.z] });
27
+ }
28
+ }
29
+
30
+ if (posEntries.length < minPts) {
31
+ return [];
32
+ }
33
+
34
+ // DBSCAN
35
+ const labels = new Int32Array(posEntries.length).fill(-1); // -1 = unvisited
36
+ const NOISE = -2;
37
+ let clusterId = 0;
38
+
39
+ for (let i = 0; i < posEntries.length; i++) {
40
+ if (labels[i] !== -1) continue;
41
+
42
+ const neighbors = rangeQuery(posEntries, i, eps);
43
+ if (neighbors.length < minPts) {
44
+ labels[i] = NOISE;
45
+ continue;
46
+ }
47
+
48
+ labels[i] = clusterId;
49
+ const seed = [...neighbors];
50
+
51
+ for (let j = 0; j < seed.length; j++) {
52
+ const q = seed[j];
53
+ if (labels[q] === NOISE) labels[q] = clusterId;
54
+ if (labels[q] !== -1) continue;
55
+
56
+ labels[q] = clusterId;
57
+ const qNeighbors = rangeQuery(posEntries, q, eps);
58
+ if (qNeighbors.length >= minPts) {
59
+ for (const n of qNeighbors) {
60
+ if (!seed.includes(n)) seed.push(n);
61
+ }
62
+ }
63
+ }
64
+ clusterId++;
65
+ }
66
+
67
+ // Build cluster objects
68
+ const clusterMap = new Map();
69
+ for (let i = 0; i < posEntries.length; i++) {
70
+ const cid = labels[i];
71
+ if (cid < 0) continue;
72
+ if (!clusterMap.has(cid)) clusterMap.set(cid, []);
73
+ clusterMap.get(cid).push(posEntries[i]);
74
+ }
75
+
76
+ const clusters = [];
77
+ for (const [id, members] of clusterMap) {
78
+ if (members.length < minPts) continue;
79
+
80
+ const centroid = [0, 0, 0];
81
+ for (const m of members) {
82
+ centroid[0] += m.pos[0];
83
+ centroid[1] += m.pos[1];
84
+ centroid[2] += m.pos[2];
85
+ }
86
+ centroid[0] /= members.length;
87
+ centroid[1] /= members.length;
88
+ centroid[2] /= members.length;
89
+
90
+ clusters.push({
91
+ id,
92
+ label: generateClusterLabel(members.map(m => m.node)),
93
+ memberIds: members.map(m => m.id),
94
+ centroid
95
+ });
96
+ }
97
+
98
+ cachedClusters = clusters;
99
+ cacheTimestamp = Date.now();
100
+ return clusters;
101
+ }
102
+
103
+ /** Euclidean distance range query for DBSCAN */
104
+ function rangeQuery(entries, idx, eps) {
105
+ const neighbors = [];
106
+ const p = entries[idx].pos;
107
+ const eps2 = eps * eps;
108
+
109
+ for (let i = 0; i < entries.length; i++) {
110
+ if (i === idx) continue;
111
+ const q = entries[i].pos;
112
+ const d2 = (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2;
113
+ if (d2 <= eps2) neighbors.push(i);
114
+ }
115
+ return neighbors;
116
+ }
117
+
118
+ /** Generate a label from cluster member nodes using most common entity type/name */
119
+ function generateClusterLabel(nodes) {
120
+ // Count entity types
121
+ const typeCounts = {};
122
+ const entityNames = [];
123
+ for (const n of nodes) {
124
+ if (n.nodeType === 'entity') {
125
+ const t = n.entityType || 'unknown';
126
+ typeCounts[t] = (typeCounts[t] || 0) + 1;
127
+ entityNames.push(n.name);
128
+ }
129
+ }
130
+
131
+ // Most common entity type
132
+ let topType = null;
133
+ let topCount = 0;
134
+ for (const [type, count] of Object.entries(typeCounts)) {
135
+ if (count > topCount) { topType = type; topCount = count; }
136
+ }
137
+
138
+ // Use top entity names (by importance) for label
139
+ const importantNodes = nodes
140
+ .filter(n => n.nodeType === 'entity')
141
+ .sort((a, b) => (b.importance || 0) - (a.importance || 0))
142
+ .slice(0, 2);
143
+
144
+ if (importantNodes.length > 0) {
145
+ const names = importantNodes.map(n => n.name).join(', ');
146
+ return topType ? `${capitalize(topType)}: ${names}` : names;
147
+ }
148
+
149
+ return topType ? `${capitalize(topType)} cluster` : `Cluster`;
150
+ }
151
+
152
+ function capitalize(s) {
153
+ return s.charAt(0).toUpperCase() + s.slice(1);
154
+ }
155
+
156
+ export function invalidateClusterCache() {
157
+ cachedClusters = null;
158
+ cacheTimestamp = 0;
159
+ }
@@ -207,7 +207,49 @@ export function buildGraph({ includeHistorical = false } = {}) {
207
207
  timestamp: new Date().toISOString()
208
208
  };
209
209
 
210
- return { nodes, links, meta };
210
+ // ── Deduplicate bidirectional relationship links ─────────
211
+ // If A→B and B→A both exist, merge into one link with bidirectional: true
212
+ const linkMap = new Map(); // canonical key -> link
213
+ const deduped = [];
214
+
215
+ for (const l of links) {
216
+ if (l.linkType !== 'relationship') {
217
+ deduped.push(l);
218
+ continue;
219
+ }
220
+ const a = l.source < l.target ? l.source : l.target;
221
+ const b = l.source < l.target ? l.target : l.source;
222
+ const key = `${a}_${b}`;
223
+
224
+ if (linkMap.has(key)) {
225
+ // Merge: keep stronger strength, combine labels, mark bidirectional
226
+ const existing = linkMap.get(key);
227
+ existing.bidirectional = true;
228
+ if (l.strength > existing.strength) {
229
+ existing.strength = l.strength;
230
+ existing.width = l.width;
231
+ }
232
+ // Combine labels if different
233
+ if (l.label && l.label !== existing.label) {
234
+ existing.label = `${existing.label} / ${l.label}`;
235
+ }
236
+ } else {
237
+ l.bidirectional = false;
238
+ linkMap.set(key, l);
239
+ deduped.push(l);
240
+ }
241
+ }
242
+
243
+ // Build existing link pairs set for pollination exclusion
244
+ const existingPairs = new Set();
245
+ for (const l of deduped) {
246
+ if (l.linkType === 'relationship') {
247
+ existingPairs.add(`${l.source}_${l.target}`);
248
+ existingPairs.add(`${l.target}_${l.source}`);
249
+ }
250
+ }
251
+
252
+ return { nodes, links: deduped, meta, existingPairs };
211
253
  }
212
254
 
213
255
  // ── Helpers ─────────────────────────────────────────────────
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pollination — discover weak-but-plausible connections between distant nodes.
3
+ * Computes pairwise cosine similarity on entity embeddings, filters to
4
+ * the "interesting" range [0.3, 0.5], and excludes already-linked pairs.
5
+ */
6
+
7
+ import { getDb } from './database.js';
8
+
9
+ let cachedLinks = null;
10
+ let cacheTimestamp = 0;
11
+ const CACHE_TTL = 60000; // 1 minute
12
+
13
+ /**
14
+ * Compute pollination links from entity embeddings.
15
+ * @param {Set} existingPairs - Set of "entityId-entityId" strings for already-linked pairs
16
+ * @returns {Array} [{ source, target, similarity }]
17
+ */
18
+ export function computePollinationLinks(existingPairs) {
19
+ if (cachedLinks && Date.now() - cacheTimestamp < CACHE_TTL) {
20
+ return cachedLinks;
21
+ }
22
+
23
+ const db = getDb();
24
+ let embeddings;
25
+
26
+ try {
27
+ const rows = db.prepare('SELECT entity_id as id, embedding FROM entity_embeddings').all();
28
+ embeddings = rows.map(row => ({
29
+ id: row.id,
30
+ vec: blobToFloat32(row.embedding)
31
+ })).filter(r => r.vec !== null);
32
+ } catch {
33
+ return []; // Table might not exist
34
+ }
35
+
36
+ if (embeddings.length < 2) return [];
37
+
38
+ // Precompute norms
39
+ for (const e of embeddings) {
40
+ let sum = 0;
41
+ for (let i = 0; i < e.vec.length; i++) sum += e.vec[i] * e.vec[i];
42
+ e.norm = Math.sqrt(sum);
43
+ }
44
+
45
+ const links = [];
46
+ const SIM_LOW = 0.3;
47
+ const SIM_HIGH = 0.5;
48
+ const MAX_LINKS = 50;
49
+
50
+ for (let i = 0; i < embeddings.length; i++) {
51
+ for (let j = i + 1; j < embeddings.length; j++) {
52
+ const a = embeddings[i];
53
+ const b = embeddings[j];
54
+
55
+ // Skip already-linked pairs
56
+ const pairKey = `entity-${a.id}_entity-${b.id}`;
57
+ const pairKeyR = `entity-${b.id}_entity-${a.id}`;
58
+ if (existingPairs.has(pairKey) || existingPairs.has(pairKeyR)) continue;
59
+
60
+ // Cosine similarity
61
+ let dot = 0;
62
+ for (let k = 0; k < a.vec.length; k++) dot += a.vec[k] * b.vec[k];
63
+ const sim = a.norm > 0 && b.norm > 0 ? dot / (a.norm * b.norm) : 0;
64
+
65
+ if (sim >= SIM_LOW && sim <= SIM_HIGH) {
66
+ links.push({
67
+ source: `entity-${a.id}`,
68
+ target: `entity-${b.id}`,
69
+ similarity: Math.round(sim * 1000) / 1000
70
+ });
71
+ }
72
+ }
73
+ }
74
+
75
+ // Sort by similarity descending, take top N
76
+ links.sort((a, b) => b.similarity - a.similarity);
77
+ const result = links.slice(0, MAX_LINKS);
78
+
79
+ cachedLinks = result;
80
+ cacheTimestamp = Date.now();
81
+ return result;
82
+ }
83
+
84
+ function blobToFloat32(blob) {
85
+ if (!blob) return null;
86
+ try {
87
+ if (Buffer.isBuffer(blob)) {
88
+ const floats = new Float32Array(blob.buffer, blob.byteOffset, blob.length / 4);
89
+ return Array.from(floats);
90
+ }
91
+ if (typeof blob === 'string') return JSON.parse(blob);
92
+ return null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ export function invalidatePollinationCache() {
99
+ cachedLinks = null;
100
+ cacheTimestamp = 0;
101
+ }
@@ -15,6 +15,8 @@ import { dirname, join } from 'path';
15
15
  import { openDatabase, findDbPath, closeDatabase, getDb, listDatabases, getProjectHash } from './lib/database.js';
16
16
  import { buildGraph } from './lib/graph.js';
17
17
  import { getProjectedPositions } from './lib/projection.js';
18
+ import { computeClusters } from './lib/clusters.js';
19
+ import { computePollinationLinks } from './lib/pollination.js';
18
20
  import { createEventStream, stopPolling } from './lib/events.js';
19
21
 
20
22
  const __filename = fileURLToPath(import.meta.url);
@@ -105,6 +107,29 @@ app.get('/api/graph', async (req, res) => {
105
107
  graph.meta.umapEnabled = false;
106
108
  }
107
109
 
110
+ // Compute clusters from UMAP positions
111
+ try {
112
+ const positions = {};
113
+ for (const node of graph.nodes) {
114
+ if (node.fx !== undefined) {
115
+ positions[node.id] = { x: node.fx, y: node.fy, z: node.fz };
116
+ }
117
+ }
118
+ graph.clusters = computeClusters(positions, graph.nodes);
119
+ } catch {
120
+ graph.clusters = [];
121
+ }
122
+
123
+ // Compute pollination links from entity embeddings
124
+ try {
125
+ graph.pollinationLinks = computePollinationLinks(graph.existingPairs || new Set());
126
+ } catch {
127
+ graph.pollinationLinks = [];
128
+ }
129
+
130
+ // Remove internal-only field before sending to client
131
+ delete graph.existingPairs;
132
+
108
133
  res.json(graph);
109
134
  } catch (err) {
110
135
  console.error('Graph error:', err);
@@ -10,69 +10,99 @@
10
10
  <!-- Three.js container -->
11
11
  <div id="graph-container"></div>
12
12
 
13
+ <!-- Hover tooltip (follows cursor) -->
14
+ <div id="tooltip" class="hidden">
15
+ <div id="tooltip-name"></div>
16
+ <div id="tooltip-type"></div>
17
+ <div id="tooltip-meta"></div>
18
+ </div>
19
+
13
20
  <!-- Top HUD bar -->
14
21
  <div id="hud-bar">
15
- <div id="hud-title">
22
+ <div id="hud-left">
16
23
  <img src="/assets/claudia-logo.png" alt="Claudia" class="hud-logo" />
17
- <span>Claudia Brain</span>
24
+ <span class="hud-brand">Claudia Brain</span>
18
25
  <span id="activity-pulse" class="pulse"></span>
19
26
  </div>
20
27
  <div id="hud-stats">
21
- <div class="stat"><span id="stat-entities">0</span> entities</div>
22
- <div class="stat"><span id="stat-memories">0</span> memories</div>
23
- <div class="stat"><span id="stat-patterns">0</span> patterns</div>
24
- <div class="stat"><span id="stat-relationships">0</span> relationships</div>
28
+ <div class="stat"><span id="stat-entities">0</span><label>entities</label></div>
29
+ <div class="stat"><span id="stat-memories">0</span><label>memories</label></div>
30
+ <div class="stat"><span id="stat-patterns">0</span><label>patterns</label></div>
31
+ <div class="stat"><span id="stat-relationships">0</span><label>relationships</label></div>
25
32
  </div>
26
33
  <div id="hud-controls">
27
- <a href="https://github.com/kbanc85/claudia" target="_blank" rel="noopener"
28
- class="hud-github" title="View on GitHub">
29
- <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
30
- <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
31
- </svg>
32
- </a>
33
34
  <div id="db-selector-container">
34
35
  <select id="db-selector" title="Select Database">
35
36
  <option value="">Loading...</option>
36
37
  </select>
37
38
  </div>
38
- <span id="fps-counter">-- FPS</span>
39
- <span id="engine-info">Three.js</span>
40
39
  <div id="settings-container">
41
40
  <button id="settings-btn" title="Quality Settings">&#x2699;</button>
42
41
  <div id="settings-panel" class="hidden">
43
42
  <div class="settings-title">Quality</div>
44
- <label class="settings-option"><input type="radio" name="quality" value="low"><span>Low</span><span class="settings-desc">No bloom, no particles</span></label>
45
- <label class="settings-option"><input type="radio" name="quality" value="medium"><span>Medium</span><span class="settings-desc">Bloom, particles</span></label>
43
+ <label class="settings-option"><input type="radio" name="quality" value="low"><span>Low</span><span class="settings-desc">No bloom</span></label>
44
+ <label class="settings-option"><input type="radio" name="quality" value="medium"><span>Medium</span><span class="settings-desc">Bloom + particles</span></label>
46
45
  <label class="settings-option"><input type="radio" name="quality" value="high" checked><span>High</span><span class="settings-desc">Full effects</span></label>
47
- <label class="settings-option"><input type="radio" name="quality" value="ultra"><span>Ultra</span><span class="settings-desc">Full-res, max glow</span></label>
46
+ <label class="settings-option"><input type="radio" name="quality" value="ultra"><span>Ultra</span><span class="settings-desc">Max glow</span></label>
47
+ <div class="settings-divider"></div>
48
+ <div class="settings-title">Resolution</div>
49
+ <label class="settings-option"><input type="radio" name="resolution" value="0"><span>Auto</span><span class="settings-desc">Device default</span></label>
50
+ <label class="settings-option"><input type="radio" name="resolution" value="0.5"><span>0.5x</span><span class="settings-desc">Performance</span></label>
51
+ <label class="settings-option"><input type="radio" name="resolution" value="1"><span>1x</span><span class="settings-desc">Balanced</span></label>
52
+ <label class="settings-option"><input type="radio" name="resolution" value="1.5"><span>1.5x</span><span class="settings-desc">Crisp</span></label>
53
+ <label class="settings-option"><input type="radio" name="resolution" value="2" ><span>2x</span><span class="settings-desc">Retina</span></label>
54
+ <div class="settings-divider"></div>
55
+ <div class="settings-meta">
56
+ <span id="fps-counter">-- FPS</span>
57
+ <span id="engine-info">Three.js</span>
58
+ <a href="https://github.com/kbanc85/claudia" target="_blank" rel="noopener" class="hud-github" title="View on GitHub">
59
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
60
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
61
+ </svg>
62
+ </a>
63
+ </div>
48
64
  </div>
49
65
  </div>
50
66
  </div>
51
67
  </div>
52
68
 
53
- <!-- Left sidebar -->
54
- <div id="sidebar">
69
+ <!-- Sidebar (collapsible overlay) -->
70
+ <div id="sidebar" class="collapsed">
71
+ <div id="sidebar-header">
72
+ <span class="sidebar-title">Filters</span>
73
+ <button id="sidebar-close" title="Close (S)">&times;</button>
74
+ </div>
55
75
  <div id="search-container">
56
- <input type="text" id="search-input" placeholder="Search entities & memories..." autocomplete="off">
76
+ <input type="text" id="search-input" placeholder="Search... ( / )" autocomplete="off">
57
77
  <div id="search-results"></div>
58
78
  </div>
59
79
  <div id="filters">
60
- <h3>Node Types</h3>
80
+ <h3>Entity Types</h3>
61
81
  <div id="type-filters"></div>
62
82
  <h3>Memory Types</h3>
63
83
  <div id="memory-filters"></div>
64
84
  </div>
65
- <div id="legend"></div>
85
+ <div id="legend">
86
+ <h3>Shapes</h3>
87
+ </div>
66
88
  </div>
67
89
 
90
+ <!-- Sidebar toggle button (always visible) -->
91
+ <button id="sidebar-toggle" title="Filters (S)">
92
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
93
+ <path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/>
94
+ </svg>
95
+ </button>
96
+
68
97
  <!-- Right detail panel (slides in on node click) -->
69
98
  <div id="detail-panel" class="hidden">
70
- <button id="detail-close">&times;</button>
99
+ <button id="detail-close" title="Close (Esc)">&times;</button>
71
100
  <div id="detail-content"></div>
72
101
  </div>
73
102
 
74
103
  <!-- Bottom timeline -->
75
104
  <div id="timeline-container">
105
+ <div id="timeline-density"></div>
76
106
  <div id="timeline-bar">
77
107
  <input type="range" id="timeline-slider" min="0" max="100" value="100">
78
108
  <div id="timeline-labels">
@@ -85,7 +115,14 @@
85
115
  <button id="timeline-play" title="Play/Pause">&#9654;</button>
86
116
  <button id="timeline-speed" title="Speed">1x</button>
87
117
  </div>
88
- <div id="timeline-density"></div>
118
+ </div>
119
+
120
+ <!-- Keyboard hints (shown briefly on first load) -->
121
+ <div id="keyboard-hints" class="hidden">
122
+ <div class="hint"><kbd>S</kbd> Filters</div>
123
+ <div class="hint"><kbd>/</kbd> Search</div>
124
+ <div class="hint"><kbd>H</kbd> Design</div>
125
+ <div class="hint"><kbd>Esc</kbd> Clear</div>
89
126
  </div>
90
127
 
91
128
  <script type="module" src="/src/main.js"></script>