diff --git a/app.js b/app.js
index 34a0bafb..19fe69ab 100644
--- a/app.js
+++ b/app.js
@@ -3520,6 +3520,122 @@ init().then(() => {
// Gravity well clustering — attract related crystals, bake positions (issue #1175)
SpatialMemory.runGravityLayout();
+
+ // ═══ SPATIAL SEARCH (Mnemosyne #1170) ═══
+ (() => {
+ const input = document.getElementById('spatial-search-input');
+ const resultsDiv = document.getElementById('spatial-search-results');
+ if (!input || !resultsDiv) return;
+
+ let searchTimeout = null;
+ let currentMatches = [];
+
+ function runSearch(query) {
+ if (!query.trim()) {
+ SpatialMemory.clearSearch();
+ resultsDiv.classList.remove('visible');
+ resultsDiv.innerHTML = '';
+ currentMatches = [];
+ return;
+ }
+
+ const matches = SpatialMemory.searchContent(query);
+ currentMatches = matches;
+
+ if (matches.length === 0) {
+ SpatialMemory.clearSearch();
+ resultsDiv.innerHTML = '
No matches
';
+ resultsDiv.classList.add('visible');
+ return;
+ }
+
+ SpatialMemory.highlightSearchResults(matches);
+
+ // Build results list
+ const allMems = SpatialMemory.getAllMemories();
+ let html = `${matches.length} match${matches.length > 1 ? 'es' : ''}
`;
+ matches.forEach(id => {
+ const mem = allMems.find(m => m.id === id);
+ if (mem) {
+ const label = (mem.content || id).slice(0, 60);
+ const region = mem.category || '?';
+ html += `
+ [${region}]${label}
+
`;
+ }
+ });
+ resultsDiv.innerHTML = html;
+ resultsDiv.classList.add('visible');
+
+ // Click handler for result items
+ resultsDiv.querySelectorAll('.spatial-search-result-item').forEach(el => {
+ el.addEventListener('click', () => {
+ const memId = el.getAttribute('data-mem-id');
+ flyToMemory(memId);
+ });
+ });
+
+ // Fly camera to first match
+ if (matches.length > 0) {
+ flyToMemory(matches[0]);
+ }
+ }
+
+ function flyToMemory(memId) {
+ const pos = SpatialMemory.getSearchMatchPosition(memId);
+ if (!pos) return;
+
+ // Smooth camera fly-to: place camera above and in front of crystal
+ const targetPos = new THREE.Vector3(pos.x, pos.y + 4, pos.z + 6);
+
+ // Use simple lerp animation over ~800ms
+ const startPos = playerPos.clone();
+ const startTime = performance.now();
+ const duration = 800;
+
+ function animateCamera(now) {
+ const elapsed = now - startTime;
+ const t = Math.min(1, elapsed / duration);
+ // Ease out cubic
+ const ease = 1 - Math.pow(1 - t, 3);
+
+ playerPos.lerpVectors(startPos, targetPos, ease);
+ camera.position.copy(playerPos);
+
+ // Look at crystal
+ const lookTarget = pos.clone();
+ lookTarget.y += 1.5;
+ camera.lookAt(lookTarget);
+
+ if (t < 1) {
+ requestAnimationFrame(animateCamera);
+ } else {
+ SpatialMemory.highlightMemory(memId);
+ }
+ }
+ requestAnimationFrame(animateCamera);
+ }
+
+ // Debounced input handler
+ input.addEventListener('input', () => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => runSearch(input.value), 200);
+ });
+
+ // Escape clears search
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ input.value = '';
+ SpatialMemory.clearSearch();
+ resultsDiv.classList.remove('visible');
+ resultsDiv.innerHTML = '';
+ currentMatches = [];
+ input.blur();
+ }
+ });
+ })();
+
+
// Project Mnemosyne — seed demo session rooms (#1171)
// Sessions group facts by conversation/work session with a timestamp.
const demoSessions = [
diff --git a/index.html b/index.html
index abc7cebd..28c3261d 100644
--- a/index.html
+++ b/index.html
@@ -66,6 +66,14 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
+
+
+
+
diff --git a/nexus/components/spatial-memory.js b/nexus/components/spatial-memory.js
index 5f117afa..9d656988 100644
--- a/nexus/components/spatial-memory.js
+++ b/nexus/components/spatial-memory.js
@@ -825,13 +825,86 @@ const SpatialMemory = (() => {
});
}
+
+ // ─── SPATIAL SEARCH (issue #1170) ────────────────────
+ let _searchOriginalState = {}; // memId -> { emissiveIntensity, opacity } for restore
+
+ function searchContent(query) {
+ if (!query || !query.trim()) return [];
+ const q = query.toLowerCase().trim();
+ const matches = [];
+
+ Object.values(_memoryObjects).forEach(obj => {
+ const d = obj.data;
+ const searchable = [
+ d.content || '',
+ d.id || '',
+ d.category || '',
+ d.source || '',
+ ...(d.connections || [])
+ ].join(' ').toLowerCase();
+
+ if (searchable.includes(q)) {
+ matches.push(d.id);
+ }
+ });
+
+ return matches;
+ }
+
+ function highlightSearchResults(matchIds) {
+ // Save original state and apply search highlighting
+ _searchOriginalState = {};
+ const matchSet = new Set(matchIds);
+
+ Object.entries(_memoryObjects).forEach(([id, obj]) => {
+ const mat = obj.mesh.material;
+ _searchOriginalState[id] = {
+ emissiveIntensity: mat.emissiveIntensity,
+ opacity: mat.opacity
+ };
+
+ if (matchSet.has(id)) {
+ // Match: bright white glow
+ mat.emissive.setHex(0xffffff);
+ mat.emissiveIntensity = 5.0;
+ mat.opacity = 1.0;
+ } else {
+ // Non-match: dim to 10% opacity
+ mat.opacity = 0.1;
+ mat.emissiveIntensity = 0.2;
+ }
+ });
+ }
+
+ function clearSearch() {
+ Object.entries(_memoryObjects).forEach(([id, obj]) => {
+ const mat = obj.mesh.material;
+ const saved = _searchOriginalState[id];
+ if (saved) {
+ // Restore original emissive color from region
+ const region = REGIONS[obj.region] || REGIONS.working;
+ mat.emissive.copy(region.color);
+ mat.emissiveIntensity = saved.emissiveIntensity;
+ mat.opacity = saved.opacity;
+ }
+ });
+ _searchOriginalState = {};
+ }
+
+ function getSearchMatchPosition(matchId) {
+ const obj = _memoryObjects[matchId];
+ return obj ? obj.mesh.position.clone() : null;
+ }
+
return {
init, placeMemory, removeMemory, update, updateMemoryVisual,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
exportIndex, importIndex, exportToFile, importFromFile, searchNearby, REGIONS,
saveToStorage, loadFromStorage, clearStorage,
- runGravityLayout
+ runGravityLayout,
+ searchContent, highlightSearchResults, clearSearch, getSearchMatchPosition
};
})();
diff --git a/style.css b/style.css
index 1a459a7e..aace36b3 100644
--- a/style.css
+++ b/style.css
@@ -1880,3 +1880,84 @@ canvas#nexus-canvas {
text-transform: uppercase;
}
+
+/* ═══ SPATIAL SEARCH OVERLAY (Mnemosyne #1170) ═══ */
+.spatial-search-overlay {
+ position: fixed;
+ top: 12px;
+ right: 12px;
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.spatial-search-input {
+ width: 260px;
+ padding: 8px 14px;
+ background: rgba(0, 0, 0, 0.65);
+ border: 1px solid rgba(74, 240, 192, 0.3);
+ border-radius: 6px;
+ color: #e0f0ff;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 13px;
+ outline: none;
+ backdrop-filter: blur(8px);
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.spatial-search-input:focus {
+ border-color: rgba(74, 240, 192, 0.7);
+ box-shadow: 0 0 12px rgba(74, 240, 192, 0.15);
+}
+
+.spatial-search-input::placeholder {
+ color: rgba(224, 240, 255, 0.35);
+}
+
+.spatial-search-results {
+ margin-top: 4px;
+ max-height: 200px;
+ overflow-y: auto;
+ background: rgba(0, 0, 0, 0.55);
+ border: 1px solid rgba(74, 240, 192, 0.15);
+ border-radius: 4px;
+ font-size: 11px;
+ color: #a0c0d0;
+ width: 260px;
+ backdrop-filter: blur(8px);
+ display: none;
+}
+
+.spatial-search-results.visible {
+ display: block;
+}
+
+.spatial-search-result-item {
+ padding: 5px 10px;
+ cursor: pointer;
+ border-bottom: 1px solid rgba(74, 240, 192, 0.08);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.spatial-search-result-item:hover {
+ background: rgba(74, 240, 192, 0.1);
+ color: #e0f0ff;
+}
+
+.spatial-search-result-item .result-region {
+ color: #4af0c0;
+ font-size: 9px;
+ margin-right: 6px;
+}
+
+.spatial-search-count {
+ padding: 4px 10px;
+ color: rgba(74, 240, 192, 0.6);
+ font-size: 10px;
+ border-bottom: 1px solid rgba(74, 240, 192, 0.1);
+}
+