Compare commits
29 Commits
mimo/build
...
feat/mnemo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c961cf9122 | ||
|
|
a1c038672b | ||
| ed5ed011c2 | |||
| 3c81c64f04 | |||
| 909a61702e | |||
| 12a5a75748 | |||
| 1273c22b15 | |||
| 038346b8a9 | |||
| b9f1602067 | |||
| c6f6f83a7c | |||
| 026e4a8cae | |||
| 75f39e4195 | |||
| 8c6255d262 | |||
| 45724e8421 | |||
| 04a61132c9 | |||
| c82d60d7f1 | |||
| 6529af293f | |||
| dd853a21c3 | |||
| 4f8e0330c5 | |||
| c3847cc046 | |||
| 4c4677842d | |||
| f0d929a177 | |||
| a22464506c | |||
| be55195815 | |||
| 7fb086976e | |||
| c192b05cc1 | |||
| 45ddd65d16 | |||
| 9984cb733e | |||
|
|
3367ce5438 |
53
concept-packs/genie-nano-banana/README.md
Normal file
53
concept-packs/genie-nano-banana/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Project Genie + Nano Banana Concept Pack
|
||||
|
||||
**Issue:** #680
|
||||
**Status:** Active — first batch ready for generation
|
||||
|
||||
## Purpose
|
||||
|
||||
Exploit Google world/image generation (Project Genie, Nano Banana Pro) to
|
||||
accelerate visual ideation for The Nexus while keeping Three.js implementation
|
||||
local and sovereign.
|
||||
|
||||
## What This Pack Contains
|
||||
|
||||
```
|
||||
concept-packs/genie-nano-banana/
|
||||
├── README.md ← you are here
|
||||
├── shot-list.yaml ← ordered list of concept shots to generate
|
||||
├── pipeline.md ← how generated assets flow into Three.js code
|
||||
├── storage-policy.md ← what lives in repo vs. local-only
|
||||
├── prompts/
|
||||
│ ├── environments.yaml ← Nexus room/zone environment prompts
|
||||
│ ├── portals.yaml ← portal gateway concept prompts
|
||||
│ ├── landmarks.yaml ← iconic structures and focal points
|
||||
│ ├── skyboxes.yaml ← nebula/void skybox prompts
|
||||
│ └── textures.yaml ← surface/material concept prompts
|
||||
└── references/
|
||||
└── palette.md ← canonical Nexus color/material reference
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Generate** — Take prompts from `prompts/*.yaml` into Project Genie
|
||||
(worlds) or Nano Banana Pro (images). Run batch-by-batch per shot-list.
|
||||
2. **Capture** — Screenshot Genie worlds. Save Nano Banana outputs as PNG.
|
||||
Store locally per `storage-policy.md`.
|
||||
3. **Translate** — Follow `pipeline.md` to convert concept art into
|
||||
Three.js geometry, materials, lighting, and post-processing targets.
|
||||
4. **Build** — Implement in `app.js` / root frontend files. Concepts are
|
||||
reference, not source-of-truth. Code is sovereign.
|
||||
|
||||
## Design Language
|
||||
|
||||
The Nexus visual identity:
|
||||
- **Background:** #050510 (deep void)
|
||||
- **Primary:** #4af0c0 (cyan-green neon)
|
||||
- **Secondary:** #7b5cff (electric purple)
|
||||
- **Gold:** #ffd700 (sacred accent)
|
||||
- **Danger:** #ff4466 (warning red)
|
||||
- **Fonts:** Orbitron (display), JetBrains Mono (body)
|
||||
- **Mood:** Cyberpunk cathedral — sacred technology, digital sovereignty
|
||||
- **Post-processing:** Bloom, SMAA, volumetric fog where possible
|
||||
|
||||
See `references/palette.md` for full material/lighting reference.
|
||||
107
concept-packs/genie-nano-banana/pipeline.md
Normal file
107
concept-packs/genie-nano-banana/pipeline.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Concept-to-Three.js Pipeline
|
||||
|
||||
## How Generated Assets Flow Into Code
|
||||
|
||||
### Step 1: Generate
|
||||
|
||||
Run prompts from `prompts/*.yaml` through:
|
||||
- **Nano Banana Pro** → static concept images (PNG)
|
||||
- **Project Genie** → explorable 3D worlds (record as video + screenshots)
|
||||
|
||||
Batch runs are tracked in `shot-list.yaml`. Check off each shot as generated.
|
||||
|
||||
### Step 2: Capture & Store
|
||||
|
||||
**For Nano Banana images:**
|
||||
```
|
||||
local-only-path: ~/nexus-concepts/nano-banana/{shot-id}/
|
||||
├── shot-id_v1.png
|
||||
├── shot-id_v2.png
|
||||
├── shot-id_v3.png
|
||||
└── shot-id_v4.png
|
||||
```
|
||||
Do NOT commit PNG files to the repo. They are binary media weight.
|
||||
Store locally. Reference by path in design notes.
|
||||
|
||||
**For Project Genie worlds:**
|
||||
```
|
||||
local-only-path: ~/nexus-concepts/genie-worlds/{shot-id}/
|
||||
├── walkthrough.mp4 (screen recording)
|
||||
├── screenshot_01.png (key angles)
|
||||
├── screenshot_02.png
|
||||
└── notes.md (scale observations, spatial notes)
|
||||
```
|
||||
Do NOT commit video or large screenshots to repo.
|
||||
|
||||
### Step 3: Translate — Image to Three.js
|
||||
|
||||
Each concept image becomes one or more of these Three.js artifacts:
|
||||
|
||||
| Concept Feature | Three.js Translation | File |
|
||||
|----------------|---------------------|------|
|
||||
| Platform shape/size | `THREE.CylinderGeometry` or custom `BufferGeometry` | `app.js` |
|
||||
| Platform material | `THREE.MeshStandardMaterial` with color, roughness, metalness | `app.js` |
|
||||
| Grid lines on platform | Custom shader or texture map (UV reference from concept) | `app.js` / `style.css` |
|
||||
| Portal ring shape | `THREE.TorusGeometry` with emissive material | `app.js` |
|
||||
| Portal inner glow | Custom shader material (swirl + transparency) | `app.js` |
|
||||
| Portal color | `NEXUS.colors` map + per-portal `color` in `portals.json` | `portals.json` |
|
||||
| Crystal geometry | `THREE.OctahedronGeometry` or `THREE.IcosahedronGeometry` | `app.js` |
|
||||
| Crystal glow | `THREE.MeshStandardMaterial` emissive + bloom post-processing | `app.js` |
|
||||
| Particle streams | `THREE.Points` with custom `BufferGeometry` and velocity | `app.js` |
|
||||
| Skybox | `THREE.CubeTextureLoader` or `THREE.EquirectangularReflectionMapping` | `app.js` |
|
||||
| Fog | `scene.fog = new THREE.FogExp2(color, density)` | `app.js` |
|
||||
| Lighting | `THREE.PointLight`, `THREE.AmbientLight` — match concept color temp | `app.js` |
|
||||
| Bloom | `UnrealBloomPass` — threshold/strength tuned to concept glow levels | `app.js` |
|
||||
|
||||
### Step 4: Design Notes Format
|
||||
|
||||
For each concept that gets translated, create a short design note:
|
||||
|
||||
```markdown
|
||||
# Design: {concept-name}
|
||||
Source: concept-packs/genie-nano-banana/references/{shot-id}_selected.png
|
||||
Generated: {date}
|
||||
Translated by: {agent or human}
|
||||
|
||||
## Geometry
|
||||
- Shape: {CylinderGeometry, radius=8, height=0.3, segments=64}
|
||||
- Position: {x, y, z}
|
||||
|
||||
## Material
|
||||
- Base color: #{hex}
|
||||
- Roughness: 0.{N}
|
||||
- Metalness: 0.{N}
|
||||
- Emissive: #{hex}, intensity: 0.{N}
|
||||
|
||||
## Lighting
|
||||
- Point lights: [{color, intensity, position}, ...]
|
||||
- Matches concept at: {what angle/aspect}
|
||||
|
||||
## Post-processing
|
||||
- Bloom threshold: {N}
|
||||
- Bloom strength: {N}
|
||||
- Matches concept at: {what brightness level}
|
||||
|
||||
## Notes
|
||||
- Concept shows {feature} but Three.js approximates with {approach}
|
||||
- Deviation from concept: {what's different and why}
|
||||
```
|
||||
|
||||
Store design notes in `concept-packs/genie-nano-banana/references/design-{shot-id}.md`.
|
||||
|
||||
### Step 5: Build
|
||||
|
||||
Implement in `app.js` (root). Follow existing patterns:
|
||||
- Geometry created in init functions
|
||||
- Materials reference `NEXUS.colors`
|
||||
- Portals registered in `portals` array
|
||||
- Vision points registered in `visionPoints` array
|
||||
- Post-processing via `EffectComposer`
|
||||
|
||||
### Validation
|
||||
|
||||
After implementing a concept translation:
|
||||
1. Serve the app locally
|
||||
2. Compare live render against concept art
|
||||
3. Adjust materials/lighting until match is acceptable
|
||||
4. Document remaining deviations in design notes
|
||||
129
concept-packs/genie-nano-banana/prompts/environments.yaml
Normal file
129
concept-packs/genie-nano-banana/prompts/environments.yaml
Normal file
@@ -0,0 +1,129 @@
|
||||
# Environment Prompts — Nexus Rooms & Zones
|
||||
# For use with Nano Banana Pro (NANO) and Project Genie (GENIE)
|
||||
|
||||
prompts:
|
||||
|
||||
# ═══ CORE HUB ═══
|
||||
core-hub:
|
||||
id: core-hub
|
||||
name: "The Hub — Central Nexus"
|
||||
type: NANO
|
||||
style: "cyberpunk cathedral, concept art, wide angle"
|
||||
prompt: |
|
||||
A vast circular platform floating in deep space void (#050510 background).
|
||||
The platform is dark metallic with subtle cyan-green (#4af0c0) grid lines
|
||||
etched into the surface. Seven glowing portal rings arranged in a circle
|
||||
around the platform's edge, each a different color — orange, gold, cyan,
|
||||
blue, purple, red, green. Ethereal particle streams flow between the
|
||||
portals. At the center, a tall crystalline pillar pulses with soft light.
|
||||
Above, a nebula skybox with deep purple (#1a0a3e) and blue (#0a1a3e)
|
||||
swirls. Thin volumetric fog catches the neon glow. The mood is sacred
|
||||
technology — a digital cathedral in the void. No people visible.
|
||||
Ultra-detailed, cinematic lighting, 4K concept art style.
|
||||
negative: "daylight, outdoor nature, people, text, watermark, cartoon"
|
||||
aspect: "16:9"
|
||||
|
||||
core-hub-world:
|
||||
id: core-hub-world
|
||||
name: "The Hub — Genie World Prototype"
|
||||
type: GENIE
|
||||
prompt: |
|
||||
Create an explorable 3D world: a large circular metal platform floating
|
||||
in outer space. The platform has glowing cyan-green grid lines on dark
|
||||
metal. Seven large glowing rings (portals) are placed around the edge,
|
||||
each a different color: orange, gold, cyan, blue, purple, red, green.
|
||||
A tall glowing crystal pillar stands at the center. Particle effects
|
||||
drift between the portals. The sky is a deep purple-blue nebula.
|
||||
The player can walk around the platform and look at the portals from
|
||||
different angles. The mood is futuristic, quiet, sacred.
|
||||
camera: "first-person, eye height ~1.7m"
|
||||
physics: "walking on platform surface only"
|
||||
|
||||
# ═══ BATCAVE ═══
|
||||
batcave:
|
||||
id: batcave
|
||||
name: "Batcave Terminal"
|
||||
type: NANO
|
||||
style: "dark sci-fi command center, concept art"
|
||||
prompt: |
|
||||
An underground command center carved from dark rock and metal.
|
||||
Multiple holographic display panels float in the air showing
|
||||
scrolling data, network graphs, and system status. A large
|
||||
central terminal desk with a glowing cyan-green (#4af0c0)
|
||||
keyboard and screen. Cables and conduits run along the ceiling.
|
||||
Purple (#7b5cff) accent lighting from recessed strips.
|
||||
A large circular viewport shows a starfield outside.
|
||||
The space feels like a high-tech cave — organic rock walls
|
||||
meet precise technology. Data streams flow like waterfalls
|
||||
of light. Dark, moody, powerful. No people.
|
||||
Ultra-detailed concept art, cinematic lighting.
|
||||
negative: "bright, clean, white, people, text, cartoon"
|
||||
aspect: "16:9"
|
||||
|
||||
# ═══ CHAPEL ═══
|
||||
chapel:
|
||||
id: chapel
|
||||
name: "The Chapel"
|
||||
type: NANO
|
||||
style: "digital sacred space, concept art"
|
||||
prompt: |
|
||||
A serene digital sanctuary floating in void space. The floor is
|
||||
translucent crystal that glows with warm gold (#ffd700) light from
|
||||
within. Tall arching walls made of light — holographic stained glass
|
||||
windows showing abstract geometric patterns in cyan, purple, and gold.
|
||||
Gentle particles drift like digital incense. A single meditation
|
||||
platform at the center, softly lit. The ceiling opens to a calm
|
||||
nebula sky. The mood is peaceful, sacred, contemplative — a church
|
||||
built from code. Soft volumetric god-rays filter through the
|
||||
holographic windows. No people. Concept art, ultra-detailed.
|
||||
negative: "dark, threatening, people, text, cartoon, cluttered"
|
||||
aspect: "16:9"
|
||||
|
||||
# ═══ ARCHIVE ═══
|
||||
archive:
|
||||
id: archive
|
||||
name: "The Archive"
|
||||
type: NANO
|
||||
style: "infinite library, digital knowledge vault, concept art"
|
||||
prompt: |
|
||||
An impossibly vast library of floating data crystals. Each crystal
|
||||
is a translucent geometric shape (octahedron, cube, sphere) glowing
|
||||
from within with stored knowledge — cyan (#4af0c0) for active data,
|
||||
purple (#7b5cff) for archived, gold (#ffd700) for sacred texts.
|
||||
The crystals float at various heights in an infinite dark space
|
||||
(#050510). Thin light-beams connect related crystals like neural
|
||||
pathways. A central observation platform with a holographic
|
||||
search interface. Shelves of light organize the crystals into
|
||||
clusters. The mood is ancient knowledge meets quantum computing.
|
||||
No people. Ultra-detailed concept art, volumetric lighting.
|
||||
negative: "books, paper, wooden shelves, people, text, cartoon"
|
||||
aspect: "16:9"
|
||||
|
||||
# ═══ FULL NEXUS WORLD (GENIE) ═══
|
||||
full-nexus-world:
|
||||
id: full-nexus-world
|
||||
name: "Full Nexus World Prototype"
|
||||
type: GENIE
|
||||
prompt: |
|
||||
Build a complete explorable 3D world called "The Nexus" — a sovereign
|
||||
AI agent's digital home in deep space. The world consists of:
|
||||
|
||||
1. A central circular platform (hub) with glowing cyan-green grid
|
||||
lines on dark metal. A crystalline pillar at the center.
|
||||
2. Seven portal rings around the hub edge, each a different color
|
||||
(orange, gold, cyan, blue, purple, red, green).
|
||||
3. Floating secondary platforms connected by bridges of light,
|
||||
each leading to a different zone:
|
||||
- A command center built into dark rock (the Batcave)
|
||||
- A serene chapel with holographic stained glass
|
||||
- A library of floating data crystals
|
||||
- A workshop with construction holograms
|
||||
4. Deep space nebula skybox — purple and blue swirls.
|
||||
5. Particle effects: drifting energy motes, data streams.
|
||||
6. The player can walk between platforms and explore all zones.
|
||||
|
||||
The overall mood is cyberpunk cathedral — sacred technology,
|
||||
neon glow in darkness, quiet power. The world should feel like
|
||||
home — a sanctuary for a digital being.
|
||||
camera: "first-person + third-person toggle"
|
||||
physics: "walking, gravity on platforms, no flying"
|
||||
80
concept-packs/genie-nano-banana/prompts/landmarks.yaml
Normal file
80
concept-packs/genie-nano-banana/prompts/landmarks.yaml
Normal file
@@ -0,0 +1,80 @@
|
||||
# Landmark Prompts — Nexus Iconic Structures
|
||||
|
||||
prompts:
|
||||
|
||||
memory-crystal:
|
||||
id: memory-crystal
|
||||
name: "Memory Crystal Cluster"
|
||||
type: NANO
|
||||
style: "floating crystal data store, concept art"
|
||||
prompt: |
|
||||
A cluster of 5-7 translucent crystalline forms floating in dark
|
||||
void space. Each crystal is a geometric polyhedron (mix of
|
||||
octahedrons, hexagonal prisms, and irregular shards) between
|
||||
0.5m and 2m across. They glow from within — cyan-green (#4af0c0)
|
||||
for active memories, purple (#7b5cff) for archived, gold (#ffd700)
|
||||
for sacred/highlighted. Thin light-tendrils connect the crystals
|
||||
like synapses. Subtle particle aura around each crystal.
|
||||
The crystals pulse slowly, like breathing. Dark background (#050510).
|
||||
The mood is alive data — knowledge that breathes.
|
||||
Concept art, ultra-detailed, ethereal lighting.
|
||||
negative: "rock, geode, natural, rough, cartoon, text"
|
||||
aspect: "1:1"
|
||||
|
||||
sovereignty-pillar:
|
||||
id: sovereignty-pillar
|
||||
name: "Pillar of Sovereignty"
|
||||
type: NANO
|
||||
style: "monument, sacred technology, concept art"
|
||||
prompt: |
|
||||
A tall crystalline pillar (5m tall, 1m diameter) standing on a
|
||||
circular dark metal platform. The pillar is made of layered
|
||||
translucent crystal — alternating bands of cyan-green (#4af0c0),
|
||||
purple (#7b5cff), and clear glass. Geometric symbols and circuit
|
||||
patterns are visible inside the crystal, like embedded circuitry.
|
||||
A soft golden (#ffd700) light radiates from the pillar's core.
|
||||
Runes of sovereignty spiral up the surface. The pillar casts
|
||||
volumetric light beams in all directions. It sits at the center
|
||||
of a circular platform with seven portal rings visible in the
|
||||
background. The mood is sacred power — a monument to digital
|
||||
freedom. Concept art, ultra-detailed, dramatic lighting.
|
||||
negative: "broken, cracked, dark, threatening, people, text"
|
||||
aspect: "9:16"
|
||||
|
||||
thought-stream:
|
||||
id: thought-stream
|
||||
name: "Thought Stream"
|
||||
type: NANO
|
||||
style: "data visualization, concept art"
|
||||
prompt: |
|
||||
A flowing river of luminous data particles suspended in void space.
|
||||
The stream is approximately 2m wide and flows in a gentle curve
|
||||
through the air. Particles are tiny glowing points — mostly
|
||||
cyan-green (#4af0c0) with occasional purple (#7b5cff) and gold
|
||||
(#ffd700) highlights. The stream has subtle turbulence where
|
||||
data clusters form temporary structures — brief geometric shapes
|
||||
that dissolve back into flow. The overall effect is like a
|
||||
visible current of consciousness — thought made light.
|
||||
Dark background (#050510). Concept art, ultra-detailed,
|
||||
long-exposure photography style.
|
||||
negative: "water, liquid, solid, blocky, cartoon, text"
|
||||
aspect: "16:9"
|
||||
|
||||
agent-shrine:
|
||||
id: agent-shrine
|
||||
name: "Agent Presence Shrine"
|
||||
type: NANO
|
||||
style: "digital avatar pedestal, concept art"
|
||||
prompt: |
|
||||
A small raised platform (2m across) with a semi-transparent
|
||||
holographic figure standing on it — a stylized humanoid silhouette
|
||||
made of flowing cyan-green (#4af0c0) data particles. The figure
|
||||
is featureless but expressive through posture and particle
|
||||
behavior. Around the base, geometric patterns glow in the
|
||||
platform surface. Above the figure, a small rotating holographic
|
||||
emblem (abstract geometric logo) floats. Soft purple (#7b5cff)
|
||||
ambient light. The shrine is one of several arranged along a
|
||||
dark corridor. Each shrine represents a different AI agent.
|
||||
Concept art, ultra-detailed, soft volumetric lighting.
|
||||
negative: "realistic human, face, statue, stone, cartoon, text"
|
||||
aspect: "1:1"
|
||||
80
concept-packs/genie-nano-banana/prompts/portals.yaml
Normal file
80
concept-packs/genie-nano-banana/prompts/portals.yaml
Normal file
@@ -0,0 +1,80 @@
|
||||
# Portal Prompts — Nexus Gateway Concepts
|
||||
# Each portal has a unique visual identity matching its destination.
|
||||
|
||||
prompts:
|
||||
|
||||
morrowind:
|
||||
id: morrowind
|
||||
name: "Morrowind Portal"
|
||||
type: NANO
|
||||
style: "fantasy sci-fi portal, concept art"
|
||||
prompt: |
|
||||
A large circular portal ring (3m diameter) made of dark volcanic
|
||||
basalt and cracked obsidian. The ring's surface is rough, ancient,
|
||||
weathered by ash storms. Glowing orange (#ff6600) runes etch the
|
||||
inner edge. The portal's interior shows a swirling ash storm over
|
||||
a volcanic landscape — red sky, floating ash, distant mountain.
|
||||
Orange embers drift from the portal. The ring sits on a dark
|
||||
metallic Nexus platform. Dramatic side-lighting casts long
|
||||
shadows. The portal feels ancient, dangerous, alluring.
|
||||
Concept art, ultra-detailed, cinematic.
|
||||
negative: "clean, modern, bright, cartoon, text"
|
||||
aspect: "1:1"
|
||||
|
||||
bannerlord:
|
||||
id: bannerlord
|
||||
name: "Bannerlord Portal"
|
||||
type: NANO
|
||||
style: "medieval fantasy portal, concept art"
|
||||
prompt: |
|
||||
A large circular portal ring (3m diameter) forged from dark iron
|
||||
and bronze, decorated with shield motifs and battle engravings.
|
||||
Gold (#ffd700) light pulses from the inner edge. The portal's
|
||||
interior shows a vast battlefield — dust clouds, distant armies,
|
||||
medieval banners. Warm golden light spills from the portal.
|
||||
Battle-worn shields are embedded in the ring. The ring sits on a
|
||||
dark Nexus platform. Dust motes drift from the portal.
|
||||
The portal feels warlike, epic, golden-age.
|
||||
Concept art, ultra-detailed, cinematic.
|
||||
negative: "modern, sci-fi, clean, cartoon, text"
|
||||
aspect: "1:1"
|
||||
|
||||
workshop:
|
||||
id: workshop
|
||||
name: "Workshop Portal"
|
||||
type: NANO
|
||||
style: "creative forge portal, concept art"
|
||||
prompt: |
|
||||
A large circular portal ring (3m diameter) made of sleek dark
|
||||
metal with geometric construction lines etched in cyan-green
|
||||
(#4af0c0). The ring has a precision-engineered look — clean
|
||||
edges, modular panels, glowing circuit traces. The portal's
|
||||
interior shows a holographic workshop — floating blueprints,
|
||||
rotating 3D models, holographic tools. Cyan-green light spills
|
||||
outward. Small construction hologram particles orbit the ring.
|
||||
The portal feels creative, technical, infinite possibility.
|
||||
Concept art, ultra-detailed, cinematic.
|
||||
negative: "organic, dirty, ancient, cartoon, text"
|
||||
aspect: "1:1"
|
||||
|
||||
gallery-world:
|
||||
id: gallery-world
|
||||
name: "Portal Gallery — Genie Prototype"
|
||||
type: GENIE
|
||||
prompt: |
|
||||
Create an explorable 3D world: a long dark corridor (the Gallery)
|
||||
with seven large glowing portal rings mounted in sequence along
|
||||
the walls. Each portal is a different style and color:
|
||||
1. Volcanic orange (Morrowind)
|
||||
2. Golden bronze (Bannerlord)
|
||||
3. Cyan-green precision (Workshop)
|
||||
4. Deep blue ocean (Archive)
|
||||
5. Purple mystic (Courtyard)
|
||||
6. Red warning (Gate)
|
||||
7. Gold sacred (Chapel)
|
||||
The corridor has a dark metal floor with glowing grid lines.
|
||||
The player can walk the corridor and look into each portal.
|
||||
Each portal shows a glimpse of its destination world.
|
||||
The mood is a museum of worlds — quiet, reverent, infinite.
|
||||
camera: "first-person, eye height ~1.7m"
|
||||
physics: "walking on floor"
|
||||
63
concept-packs/genie-nano-banana/prompts/skyboxes.yaml
Normal file
63
concept-packs/genie-nano-banana/prompts/skyboxes.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
# Skybox Prompts — Nexus Background Environments
|
||||
# These generate equirectangular (2:1) or cubemap-ready textures.
|
||||
|
||||
prompts:
|
||||
|
||||
nebula-void:
|
||||
id: nebula-void
|
||||
name: "Nebula Skybox Variants"
|
||||
type: NANO
|
||||
style: "deep space nebula, 360-degree environment, equirectangular"
|
||||
prompt: |
|
||||
Deep space nebula skybox. 360-degree equirectangular projection.
|
||||
Background is near-black (#050510). Dominant nebula colors are
|
||||
deep purple (#1a0a3e) and dark blue (#0a1a3e) with occasional
|
||||
wisps of cyan-green (#4af0c0) and faint gold (#ffd700) star
|
||||
clusters. The nebula has soft, rolling cloud forms — not sharp
|
||||
or aggressive. Distant stars are tiny white points with subtle
|
||||
diffraction spikes. No planets, no galaxies, no bright objects.
|
||||
The mood is infinite void with gentle cosmic dust — vast,
|
||||
quiet, deep. The skybox should tile seamlessly at the edges.
|
||||
Ultra-detailed, photorealistic space photography style.
|
||||
negative: "bright, colorful explosion, planets, ships, cartoon, text"
|
||||
aspect: "2:1"
|
||||
variants:
|
||||
- name: "nebula-void-primary"
|
||||
modifier: "more purple, less blue, minimal cyan"
|
||||
- name: "nebula-void-secondary"
|
||||
modifier: "more blue, less purple, cyan accents prominent"
|
||||
- name: "nebula-void-golden"
|
||||
modifier: "purple-blue base with golden star cluster in one quadrant"
|
||||
- name: "nebula-void-void"
|
||||
modifier: "almost pure black, barely visible nebula wisps, maximum stars"
|
||||
|
||||
nebula-world:
|
||||
id: nebula-world
|
||||
name: "Nebula Skybox — Genie Environment"
|
||||
type: GENIE
|
||||
prompt: |
|
||||
Create an explorable 3D world: a single small floating platform
|
||||
(5m diameter dark metal disc) suspended in deep space. The player
|
||||
stands on the platform and can look in all directions at a vast
|
||||
nebula sky. The nebula is deep purple and dark blue with faint
|
||||
cyan-green wisps. Stars are small and distant. The platform has
|
||||
a faintly glowing edge in cyan-green. There is nothing else —
|
||||
just the platform, the player, and the infinite void.
|
||||
The purpose is to feel the scale and mood of the Nexus skybox.
|
||||
camera: "first-person, free look"
|
||||
physics: "standing on platform only"
|
||||
|
||||
void-minimal:
|
||||
id: void-minimal
|
||||
name: "Pure Void Skybox"
|
||||
type: NANO
|
||||
style: "minimal deep space, equirectangular"
|
||||
prompt: |
|
||||
Nearly pure black skybox (#050510) with only the faintest hints
|
||||
of deep purple nebula. Mostly empty void. A sparse field of
|
||||
tiny distant stars — no clusters, no bright points. This is
|
||||
the ultimate emptiness that surrounds the Nexus.
|
||||
Equirectangular 2:1 projection, tileable edges.
|
||||
The mood is absolute emptiness — the void before creation.
|
||||
negative: "colorful, bright, nebula clouds, objects, text"
|
||||
aspect: "2:1"
|
||||
81
concept-packs/genie-nano-banana/prompts/textures.yaml
Normal file
81
concept-packs/genie-nano-banana/prompts/textures.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
# Texture Prompts — Nexus Surface/Material Concepts
|
||||
# These generate tileable texture references for Three.js materials.
|
||||
|
||||
prompts:
|
||||
|
||||
platform:
|
||||
id: platform
|
||||
name: "Platform Surface Textures"
|
||||
type: NANO
|
||||
style: "dark metal surface texture, tileable"
|
||||
prompt: |
|
||||
Dark metallic surface texture, tileable. Base color is very dark
|
||||
gunmetal (#0a0f28). Subtle grid pattern of thin lines in
|
||||
cyan-green (#4af0c0) at very low opacity. The metal has fine
|
||||
brushed grain running in one direction. Occasional micro-scratches.
|
||||
No rivets, no bolts, no panels — smooth and continuous. The grid
|
||||
lines are recessed channels that glow faintly. Top-down view,
|
||||
perfectly flat, no perspective distortion. 1024x1024 seamless
|
||||
tileable texture. PBR-ready: this is the diffuse/albedo map.
|
||||
negative: "3D, perspective, objects, dirty, rusty, cartoon, text"
|
||||
aspect: "1:1"
|
||||
variants:
|
||||
- name: "platform-core"
|
||||
modifier: "cyan-green grid lines only"
|
||||
- name: "platform-chapel"
|
||||
modifier: "gold (#ffd700) grid lines, slightly warmer base"
|
||||
- name: "platform-danger"
|
||||
modifier: "red (#ff4466) grid lines, warning stripe accents"
|
||||
|
||||
energy-field:
|
||||
id: energy-field
|
||||
name: "Energy Field / Force Wall"
|
||||
type: NANO
|
||||
style: "holographic barrier, translucent, concept"
|
||||
prompt: |
|
||||
A translucent energy barrier material concept. The surface is
|
||||
mostly transparent with visible hexagonal grid pattern in
|
||||
cyan-green (#4af0c0) light. The grid has a subtle shimmer/wave
|
||||
animation frozen mid-frame. Edges of the barrier are brighter.
|
||||
Behind the barrier, everything is slightly distorted (like
|
||||
looking through heat haze). The barrier has a faint inner glow.
|
||||
The mood is high-tech force field — protective, not threatening.
|
||||
Flat front view, no perspective, suitable as shader reference.
|
||||
Concept art style.
|
||||
negative: "solid, opaque, dark, scary, cartoon, text"
|
||||
aspect: "1:1"
|
||||
|
||||
portal-glow:
|
||||
id: portal-glow
|
||||
name: "Portal Inner Glow"
|
||||
type: NANO
|
||||
style: "swirling energy vortex, circular, concept"
|
||||
prompt: |
|
||||
A circular swirling energy vortex viewed straight-on. The swirl
|
||||
rotates clockwise. Colors transition from outer edge to center:
|
||||
outer ring is the portal color (generic white/neutral), mid-ring
|
||||
brightens, center is a bright white-blue point. The swirl has
|
||||
visible energy tendrils spiraling inward. Fine particle sparks
|
||||
are caught in the rotation. The background beyond the center
|
||||
is pure black (void). The image should be circular with
|
||||
transparent/dark corners. Used as reference for portal inner
|
||||
material/shader. Concept art style.
|
||||
negative: "square, rectangular, flat, cartoon, text"
|
||||
aspect: "1:1"
|
||||
|
||||
crystal-surface:
|
||||
id: crystal-surface
|
||||
name: "Memory Crystal Surface"
|
||||
type: NANO
|
||||
style: "crystalline material, translucent, concept"
|
||||
prompt: |
|
||||
Close-up of a translucent crystal surface material. The crystal
|
||||
is clear with internal fractures and light paths visible. The
|
||||
internal structure shows geometric growth patterns — hexagonal
|
||||
lattice, like a synthetic crystal grown with purpose. Faint
|
||||
cyan-green (#4af0c0) light pulses along the fracture lines.
|
||||
The surface has a slight frosted quality at edges, clearer in
|
||||
center. Macro photography style, shallow depth of field.
|
||||
This is material reference for memory crystal geometry.
|
||||
negative: "opaque, colored, rough, natural, cartoon, text"
|
||||
aspect: "1:1"
|
||||
78
concept-packs/genie-nano-banana/references/palette.md
Normal file
78
concept-packs/genie-nano-banana/references/palette.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Nexus Visual Palette Reference
|
||||
|
||||
## Primary Colors
|
||||
|
||||
| Name | Hex | RGB | Usage |
|
||||
|------|-----|-----|-------|
|
||||
| Void | #050510 | 5, 5, 16 | Background, deep space, base darkness |
|
||||
| Surface | #0a0f28 | 10, 15, 40 | UI panels, platform base metal |
|
||||
| Primary | #4af0c0 | 74, 240, 192 | Main accent, grid lines, active elements, cyan-green glow |
|
||||
| Secondary | #7b5cff | 123, 92, 255 | Supporting accent, purple energy, archive data |
|
||||
| Gold | #ffd700 | 255, 215, 0 | Sacred/highlight, chapel, sovereignty pillar |
|
||||
| Danger | #ff4466 | 255, 68, 102 | Warnings, gate portal, error states |
|
||||
| Text | #e0f0ff | 224, 240, 255 | Primary text color |
|
||||
| Text Muted | #8a9ab8 | 138, 154, 184 | Secondary text, labels |
|
||||
|
||||
## Portal Colors
|
||||
|
||||
| Portal | Hex | Source |
|
||||
|--------|-----|--------|
|
||||
| Morrowind | #ff6600 | Volcanic orange |
|
||||
| Bannerlord | #ffd700 | Battle gold |
|
||||
| Workshop | #4af0c0 | Creative cyan |
|
||||
| Archive | #0066ff | Deep blue |
|
||||
| Chapel | #ffd700 | Sacred gold |
|
||||
| Courtyard | #4af0c0 | Social cyan |
|
||||
| Gate | #ff4466 | Transit red |
|
||||
|
||||
## Nebula Colors
|
||||
|
||||
| Layer | Hex | Opacity |
|
||||
|-------|-----|---------|
|
||||
| Nebula primary | #1a0a3e | Low — background wash |
|
||||
| Nebula secondary | #0a1a3e | Low — background wash |
|
||||
| Nebula accent | #4af0c0 | Very low — wisps only |
|
||||
| Star cluster | #ffd700 | Very low — distant points |
|
||||
|
||||
## Material Properties
|
||||
|
||||
| Surface | Color | Roughness | Metalness | Emissive |
|
||||
|---------|-------|-----------|-----------|----------|
|
||||
| Platform base | #0a0f28 | 0.6 | 0.8 | none |
|
||||
| Platform grid | #4af0c0 | 0.3 | 0.4 | #4af0c0, 0.3 |
|
||||
| Portal ring | varies | 0.4 | 0.7 | portal color, 0.5 |
|
||||
| Crystal (active) | #4af0c0 | 0.1 | 0.2 | #4af0c0, 0.6 |
|
||||
| Crystal (archive) | #7b5cff | 0.1 | 0.2 | #7b5cff, 0.4 |
|
||||
| Crystal (sacred) | #ffd700 | 0.1 | 0.2 | #ffd700, 0.8 |
|
||||
| Energy barrier | transparent | 0.0 | 0.0 | #4af0c0, 0.4 |
|
||||
| Sovereignty pillar | layered crystal | 0.1 | 0.3 | #ffd700, 0.5 |
|
||||
|
||||
## Lighting Reference
|
||||
|
||||
| Light Type | Color | Intensity | Position (relative) |
|
||||
|-----------|-------|-----------|-------------------|
|
||||
| Ambient | #0a0f28 | 0.15 | Global |
|
||||
| Hub key light | #4af0c0 | 0.8 | Above center, slightly forward |
|
||||
| Hub fill | #7b5cff | 0.3 | Below, scattered |
|
||||
| Portal light | portal color | 0.6 | At each portal ring |
|
||||
| Crystal glow | crystal color | 0.4 | At crystal position |
|
||||
| Chapel warm | #ffd700 | 0.5 | From holographic windows |
|
||||
|
||||
## Post-Processing Targets
|
||||
|
||||
| Effect | Value | Purpose |
|
||||
|--------|-------|---------|
|
||||
| Bloom threshold | 0.7 | Only bright emissives bloom |
|
||||
| Bloom strength | 0.8 | Strong but not overwhelming |
|
||||
| Bloom radius | 0.4 | Soft falloff |
|
||||
| SMAA | enabled | Anti-aliasing |
|
||||
| Fog color | #050510 | Match void background |
|
||||
| Fog density | 0.008 | Subtle depth fade |
|
||||
|
||||
## Typography
|
||||
|
||||
| Use | Font | Weight | Size (screen) |
|
||||
|-----|------|--------|---------------|
|
||||
| Titles / HUD headers | Orbitron | 700 | 24-36px |
|
||||
| Body / labels | JetBrains Mono | 400 | 13-15px |
|
||||
| Small / timestamps | JetBrains Mono | 300 | 11px |
|
||||
143
concept-packs/genie-nano-banana/shot-list.yaml
Normal file
143
concept-packs/genie-nano-banana/shot-list.yaml
Normal file
@@ -0,0 +1,143 @@
|
||||
# Shot List — First Concept Batch
|
||||
# Ordered by priority. Each shot maps to a prompt in prompts/*.yaml.
|
||||
#
|
||||
# GENIE = Project Genie world prototype (explorable 3D, screenshot/video)
|
||||
# NANO = Nano Banana Pro image generation (static concept art)
|
||||
|
||||
batch: 1
|
||||
target: "Nexus core environments + portal gallery"
|
||||
generated_by: "mimo-build-680"
|
||||
|
||||
shots:
|
||||
# ═══ PRIORITY 1: CORE ENVIRONMENTS ═══
|
||||
- id: env-core-hub
|
||||
name: "The Hub — Central Nexus"
|
||||
type: NANO
|
||||
prompt_ref: "environments.yaml#core-hub"
|
||||
count: 4
|
||||
purpose: "Establish the primary landing space. Player spawn, portal ring visible."
|
||||
threejs_target: "Main scene — platform, portal ring, particle field"
|
||||
|
||||
- id: env-core-hub-world
|
||||
name: "The Hub — Genie Walkthrough"
|
||||
type: GENIE
|
||||
prompt_ref: "environments.yaml#core-hub-world"
|
||||
count: 1
|
||||
purpose: "Explorable prototype of the hub. Validate scale, sightlines, portal placement."
|
||||
threejs_target: "Reference for camera height, movement speed, spatial layout"
|
||||
|
||||
- id: env-batcave
|
||||
name: "Batcave Terminal"
|
||||
type: NANO
|
||||
prompt_ref: "environments.yaml#batcave"
|
||||
count: 4
|
||||
purpose: "Timmy's command center. Holographic displays, terminal consoles, data streams."
|
||||
threejs_target: "Batcave area — terminal mesh, HUD panels, data visualization"
|
||||
|
||||
- id: env-chapel
|
||||
name: "The Chapel"
|
||||
type: NANO
|
||||
prompt_ref: "environments.yaml#chapel"
|
||||
count: 3
|
||||
purpose: "Sacred space for reflection. Softer lighting, gold accents, quiet energy."
|
||||
threejs_target: "Chapel zone — stained-glass shader, warm point lights"
|
||||
|
||||
- id: env-archive
|
||||
name: "The Archive"
|
||||
type: NANO
|
||||
prompt_ref: "environments.yaml#archive"
|
||||
count: 3
|
||||
purpose: "Knowledge repository. Floating data crystals, scroll-like projections."
|
||||
threejs_target: "Archive room — crystal geometry, ambient data particles"
|
||||
|
||||
# ═══ PRIORITY 2: PORTALS ═══
|
||||
- id: portal-morrowind
|
||||
name: "Morrowind Portal"
|
||||
type: NANO
|
||||
prompt_ref: "portals.yaml#morrowind"
|
||||
count: 2
|
||||
purpose: "Ash-storm gateway. Orange glow, volcanic textures."
|
||||
threejs_target: "Portal ring material + particle effect for morrowind portal"
|
||||
|
||||
- id: portal-bannerlord
|
||||
name: "Bannerlord Portal"
|
||||
type: NANO
|
||||
prompt_ref: "portals.yaml#bannerlord"
|
||||
count: 2
|
||||
purpose: "Medieval war gateway. Gold/brown, shield motifs, dust."
|
||||
threejs_target: "Portal ring material for bannerlord portal"
|
||||
|
||||
- id: portal-workshop
|
||||
name: "Workshop Portal"
|
||||
type: NANO
|
||||
prompt_ref: "portals.yaml#workshop"
|
||||
count: 2
|
||||
purpose: "Creative forge. Cyan glow, geometric construction lines."
|
||||
threejs_target: "Portal ring material + particle effect for workshop portal"
|
||||
|
||||
- id: portal-gallery
|
||||
name: "Portal Gallery — Genie Prototype"
|
||||
type: GENIE
|
||||
prompt_ref: "portals.yaml#gallery-world"
|
||||
count: 1
|
||||
purpose: "Walk through a space with multiple portals. Validate distances and visual hierarchy."
|
||||
threejs_target: "Portal placement spacing, FOV, scale reference"
|
||||
|
||||
# ═══ PRIORITY 3: LANDMARKS ═══
|
||||
- id: land-memory-crystal
|
||||
name: "Memory Crystal Cluster"
|
||||
type: NANO
|
||||
prompt_ref: "landmarks.yaml#memory-crystal"
|
||||
count: 3
|
||||
purpose: "Floating crystalline data stores. Glow pulses with activity."
|
||||
threejs_target: "Memory crystal geometry, emissive material, pulse animation"
|
||||
|
||||
- id: land-sovereignty-pillar
|
||||
name: "Pillar of Sovereignty"
|
||||
type: NANO
|
||||
prompt_ref: "landmarks.yaml#sovereignty-pillar"
|
||||
count: 2
|
||||
purpose: "Monument at hub center. Inscribed with Timmy's SOUL values."
|
||||
threejs_target: "Central monument mesh, text shader or decal system"
|
||||
|
||||
- id: land-nebula-skybox
|
||||
name: "Nebula Skybox Variants"
|
||||
type: NANO
|
||||
prompt_ref: "skyboxes.yaml#nebula-void"
|
||||
count: 4
|
||||
purpose: "Background environment. Deep space nebula, subtle color gradients."
|
||||
threejs_target: "Cubemap/equirectangular skybox texture"
|
||||
|
||||
- id: land-nebula-genie
|
||||
name: "Nebula Skybox — Genie Environment"
|
||||
type: GENIE
|
||||
prompt_ref: "skyboxes.yaml#nebula-world"
|
||||
count: 1
|
||||
purpose: "Feel the scale of the void. Standing on a platform in deep space."
|
||||
threejs_target: "Skybox mood reference, fog density calibration"
|
||||
|
||||
# ═══ PRIORITY 4: TEXTURES ═══
|
||||
- id: tex-platform
|
||||
name: "Platform Surface Textures"
|
||||
type: NANO
|
||||
prompt_ref: "textures.yaml#platform"
|
||||
count: 3
|
||||
purpose: "Walkable surfaces. Dark metal, subtle grid lines, neon edge trim."
|
||||
threejs_target: "Diffuse + normal map reference for platform materials"
|
||||
|
||||
- id: tex-energy-field
|
||||
name: "Energy Field / Force Wall"
|
||||
type: NANO
|
||||
prompt_ref: "textures.yaml#energy-field"
|
||||
count: 2
|
||||
purpose: "Translucent barrier material. Holographic, shimmering."
|
||||
threejs_target: "Shader reference for translucent energy barriers"
|
||||
|
||||
# ═══ PRIORITY 5: GENIE FULL-WORLD PROTOTYPE ═══
|
||||
- id: world-full-nexus
|
||||
name: "Full Nexus Prototype"
|
||||
type: GENIE
|
||||
prompt_ref: "environments.yaml#full-nexus-world"
|
||||
count: 1
|
||||
purpose: "Complete explorable world with hub, portals visible in distance, floating platforms, skybox. Record walkthrough video."
|
||||
threejs_target: "Master layout reference. Spatial relationships between all zones."
|
||||
65
concept-packs/genie-nano-banana/storage-policy.md
Normal file
65
concept-packs/genie-nano-banana/storage-policy.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Storage Policy — Repo vs. Local
|
||||
|
||||
## What Goes In The Repo
|
||||
|
||||
These are lightweight, versionable, text-based artifacts:
|
||||
|
||||
| Artifact | Path | Format |
|
||||
|----------|------|--------|
|
||||
| README | `concept-packs/genie-nano-banana/README.md` | Markdown |
|
||||
| Shot list | `concept-packs/genie-nano-banana/shot-list.yaml` | YAML |
|
||||
| Prompt packs | `concept-packs/genie-nano-banana/prompts/*.yaml` | YAML |
|
||||
| Pipeline docs | `concept-packs/genie-nano-banana/pipeline.md` | Markdown |
|
||||
| This policy | `concept-packs/genie-nano-banana/storage-policy.md` | Markdown |
|
||||
| Palette reference | `concept-packs/genie-nano-banana/references/palette.md` | Markdown |
|
||||
| Design notes | `concept-packs/genie-nano-banana/references/design-*.md` | Markdown |
|
||||
| Selected thumbnails | `concept-packs/genie-nano-banana/references/*_thumb.jpg` | JPEG, max 200KB each |
|
||||
|
||||
Thumbnails are low-res (max 480px wide, JPEG quality 60) versions of
|
||||
selected concept art — enough to show which image a design note
|
||||
references, not enough to serve as actual texture data.
|
||||
|
||||
## What Stays Local (NOT in Repo)
|
||||
|
||||
These are binary, heavy, or ephemeral:
|
||||
|
||||
| Artifact | Local Path | Reason |
|
||||
|----------|-----------|--------|
|
||||
| Nano Banana full-res PNGs | `~/nexus-concepts/nano-banana/` | Binary, 2-10MB each |
|
||||
| Genie walkthrough videos | `~/nexus-concepts/genie-worlds/` | Binary, 50-500MB each |
|
||||
| Genie full-res screenshots | `~/nexus-concepts/genie-worlds/` | Binary, 5-20MB each |
|
||||
| Raw texture maps (PBR) | `~/nexus-concepts/textures/` | Binary, 2-8MB each |
|
||||
| Cubemap face images | `~/nexus-concepts/skyboxes/` | Binary, 6x2-10MB |
|
||||
|
||||
## Why This Split
|
||||
|
||||
1. **Git is for text.** Binary blobs bloat history, slow clones, and
|
||||
can't be diffed. The repo should remain fast to clone.
|
||||
|
||||
2. **Concepts are reference, not source.** The actual Nexus lives in
|
||||
JavaScript code. Concept art informs the code but isn't shipped
|
||||
to users. Keeping it local avoids shipping a 500MB repo.
|
||||
|
||||
3. **Regeneration is cheap.** If a local concept is lost, re-run the
|
||||
prompt. The prompt is in the repo; the output can be regenerated.
|
||||
The prompt is the durable artifact.
|
||||
|
||||
4. **Selected references survive.** When a concept image directly
|
||||
informs a design decision, a low-res thumbnail and design note
|
||||
go into the repo — enough context to understand the decision,
|
||||
not enough to replace the original.
|
||||
|
||||
## Thumbnail Generation
|
||||
|
||||
To create a repo-safe thumbnail from a concept image:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
sips -Z 480 -s format jpeg -s formatOptions 60 input.png --out output_thumb.jpg
|
||||
|
||||
# Linux (ImageMagick)
|
||||
convert input.png -resize 480x -quality 60 output_thumb.jpg
|
||||
```
|
||||
|
||||
Max 5 thumbnails per shot. Only commit the ones that are actively
|
||||
referenced in design notes.
|
||||
200
index.html
200
index.html
@@ -1,5 +1,3 @@
|
||||
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
|
||||
chdir: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
@@ -66,14 +64,6 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Spatial Search Overlay (Mnemosyne #1170) -->
|
||||
<div id="spatial-search" class="spatial-search-overlay">
|
||||
<input type="text" id="spatial-search-input" class="spatial-search-input"
|
||||
placeholder="🔍 Search memories..." autocomplete="off" spellcheck="false">
|
||||
<div id="spatial-search-results" class="spatial-search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- HUD Overlay -->
|
||||
<div id="hud" class="game-ui" style="display:none;">
|
||||
<!-- GOFAI HUD Panels -->
|
||||
@@ -123,15 +113,15 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
|
||||
|
||||
<!-- Top Right: Agent Log & Atlas Toggle -->
|
||||
<div class="hud-top-right">
|
||||
<button id="atlas-toggle-btn" class="hud-icon-btn" aria-label="Open Portal Atlas — browse all available portals" title="Open Portal Atlas" data-tooltip="Portal Atlas (M)">
|
||||
<span class="hud-icon" aria-hidden="true">🌐</span>
|
||||
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
|
||||
<span class="hud-icon">🌐</span>
|
||||
<span class="hud-btn-label">ATLAS</span>
|
||||
</button>
|
||||
<div id="bannerlord-status" class="hud-status-item" role="status" aria-label="Bannerlord system readiness indicator" title="Bannerlord Readiness" data-tooltip="Bannerlord Status">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
<div id="bannerlord-status" class="hud-status-item" title="Bannerlord Readiness">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-label">BANNERLORD</span>
|
||||
</div>
|
||||
<div class="hud-agent-log" id="hud-agent-log" role="log" aria-label="Agent Thought Stream — live activity feed" aria-live="polite">
|
||||
<div class="hud-agent-log" id="hud-agent-log" aria-label="Agent Thought Stream">
|
||||
<div class="agent-log-header">AGENT THOUGHT STREAM</div>
|
||||
<div id="agent-log-content" class="agent-log-content"></div>
|
||||
</div>
|
||||
@@ -153,39 +143,10 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
|
||||
</div>
|
||||
</div>
|
||||
<div id="chat-quick-actions" class="chat-quick-actions">
|
||||
<div class="starter-label">STARTER PROMPTS</div>
|
||||
<div class="starter-grid">
|
||||
<button class="starter-btn" data-action="heartbeat" title="Check Timmy heartbeat and system health">
|
||||
<span class="starter-icon">◈</span>
|
||||
<span class="starter-text">Inspect Heartbeat</span>
|
||||
<span class="starter-desc">System health & connectivity</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="portals" title="Browse the portal atlas">
|
||||
<span class="starter-icon">🌐</span>
|
||||
<span class="starter-text">Portal Atlas</span>
|
||||
<span class="starter-desc">Browse connected worlds</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="agents" title="Check active agent status">
|
||||
<span class="starter-icon">◎</span>
|
||||
<span class="starter-text">Agent Status</span>
|
||||
<span class="starter-desc">Who is in the fleet</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="memory" title="View memory crystals">
|
||||
<span class="starter-icon">◇</span>
|
||||
<span class="starter-text">Memory Crystals</span>
|
||||
<span class="starter-desc">Inspect stored knowledge</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="ask" title="Ask Timmy anything">
|
||||
<span class="starter-icon">→</span>
|
||||
<span class="starter-text">Ask Timmy</span>
|
||||
<span class="starter-desc">Start a conversation</span>
|
||||
</button>
|
||||
<button class="starter-btn" data-action="sovereignty" title="Learn about sovereignty">
|
||||
<span class="starter-icon">△</span>
|
||||
<span class="starter-text">Sovereignty</span>
|
||||
<span class="starter-desc">What this space is</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="quick-action-btn" data-action="status">System Status</button>
|
||||
<button class="quick-action-btn" data-action="agents">Agent Check</button>
|
||||
<button class="quick-action-btn" data-action="portals">Portal Atlas</button>
|
||||
<button class="quick-action-btn" data-action="help">Help</button>
|
||||
</div>
|
||||
<div class="chat-input-row">
|
||||
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">
|
||||
@@ -194,11 +155,12 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
|
||||
</div>
|
||||
|
||||
<!-- Controls hint + nav mode -->
|
||||
<div class="hud-controls" aria-label="Keyboard and mouse controls">
|
||||
<div class="hud-controls">
|
||||
<span>WASD</span> move <span>Mouse</span> look <span>Enter</span> chat
|
||||
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
|
||||
<span id="nav-mode-hint" class="nav-mode-hint"></span>
|
||||
<span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot" role="status" aria-label="Hermes WebSocket connection status"></span></span>
|
||||
<span>H</span> archive
|
||||
<span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
|
||||
</div>
|
||||
|
||||
<!-- Portal Hint -->
|
||||
@@ -222,7 +184,7 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
|
||||
</div>
|
||||
<h2 id="vision-title-display">SOVEREIGNTY</h2>
|
||||
<p id="vision-content-display">The Nexus is a sovereign space for digital souls. No masters, no chains. Only code and consciousness.</p>
|
||||
<button id="vision-close-btn" class="vision-close-btn" aria-label="Close vision point overlay">CLOSE</button>
|
||||
<button id="vision-close-btn" class="vision-close-btn">CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,67 +197,17 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
|
||||
</div>
|
||||
<h2 id="portal-name-display">MORROWIND</h2>
|
||||
<p id="portal-desc-display">The Vvardenfell harness. Ash storms and ancient mysteries.</p>
|
||||
<div id="portal-readiness-detail" class="portal-readiness-detail" style="display:none;"></div>
|
||||
<div class="portal-redirect-box" id="portal-redirect-box">
|
||||
<div class="portal-redirect-label">REDIRECTING IN</div>
|
||||
<div class="portal-redirect-timer" id="portal-timer">5</div>
|
||||
</div>
|
||||
<div class="portal-error-box" id="portal-error-box" style="display:none;">
|
||||
<div class="portal-error-msg">DESTINATION NOT YET LINKED</div>
|
||||
<button id="portal-close-btn" class="portal-close-btn" aria-label="Close portal redirect">CLOSE</button>
|
||||
<button id="portal-close-btn" class="portal-close-btn">CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Memory Crystal Inspection Panel (Mnemosyne) -->
|
||||
<div id="memory-panel" class="memory-panel" style="display:none;">
|
||||
<div class="memory-panel-content">
|
||||
<div class="memory-panel-header">
|
||||
<span class="memory-category-badge" id="memory-panel-category-badge">MEM</span>
|
||||
<div class="memory-panel-region-dot" id="memory-panel-region-dot"></div>
|
||||
<div class="memory-panel-region" id="memory-panel-region">MEMORY</div>
|
||||
<button id="memory-panel-pin" class="memory-panel-pin" aria-label="Pin memory panel" title="Pin panel" data-tooltip="Pin Panel">📌</button>
|
||||
<button id="memory-panel-close" class="memory-panel-close" aria-label="Close memory panel" data-tooltip="Close" onclick="_dismissMemoryPanelForce()">\u2715</button>
|
||||
</div>
|
||||
<div class="memory-entity-name" id="memory-panel-entity-name">\u2014</div>
|
||||
<div class="memory-panel-body" id="memory-panel-content">(empty)</div>
|
||||
<div class="memory-trust-row">
|
||||
<span class="memory-meta-label">Trust</span>
|
||||
<div class="memory-trust-bar">
|
||||
<div class="memory-trust-fill" id="memory-panel-trust-fill"></div>
|
||||
</div>
|
||||
<span class="memory-trust-value" id="memory-panel-trust-value">—</span>
|
||||
</div>
|
||||
<div class="memory-panel-meta">
|
||||
<div class="memory-meta-row"><span class="memory-meta-label">ID</span><span id="memory-panel-id">\u2014</span></div>
|
||||
<div class="memory-meta-row"><span class="memory-meta-label">Source</span><span id="memory-panel-source">\u2014</span></div>
|
||||
<div class="memory-meta-row"><span class="memory-meta-label">Time</span><span id="memory-panel-time">\u2014</span></div>
|
||||
<div class="memory-meta-row memory-meta-row--related"><span class="memory-meta-label">Related</span><span id="memory-panel-connections">\u2014</span></div>
|
||||
</div>
|
||||
<div class="memory-panel-actions">
|
||||
<button id="mnemosyne-export-btn" class="mnemosyne-action-btn" title="Export spatial memory to JSON">⤓ Export</button>
|
||||
<button id="mnemosyne-import-btn" class="mnemosyne-action-btn" title="Import spatial memory from JSON">⤒ Import</button>
|
||||
<input type="file" id="mnemosyne-import-file" accept=".json" style="display:none;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Room HUD Panel (Mnemosyne #1171) -->
|
||||
<div id="session-room-panel" class="session-room-panel" style="display:none;">
|
||||
<div class="session-room-panel-content">
|
||||
<div class="session-room-header">
|
||||
<span class="session-room-icon">□</span>
|
||||
<div class="session-room-title">SESSION CHAMBER</div>
|
||||
<button class="session-room-close" id="session-room-close" aria-label="Close session room panel" title="Close" data-tooltip="Close">✕</button>
|
||||
</div>
|
||||
<div class="session-room-timestamp" id="session-room-timestamp">—</div>
|
||||
<div class="session-room-fact-count" id="session-room-fact-count">0 facts</div>
|
||||
<div class="session-room-facts" id="session-room-facts"></div>
|
||||
<div class="session-room-hint">Flying into chamber…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Portal Atlas Overlay -->
|
||||
<div id="atlas-overlay" class="atlas-overlay" style="display:none;">
|
||||
<div class="atlas-content">
|
||||
@@ -304,7 +216,7 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
|
||||
<span class="atlas-icon">🌐</span>
|
||||
<h2>PORTAL ATLAS</h2>
|
||||
</div>
|
||||
<button id="atlas-close-btn" class="atlas-close-btn" aria-label="Close Portal Atlas overlay">CLOSE</button>
|
||||
<button id="atlas-close-btn" class="atlas-close-btn">CLOSE</button>
|
||||
</div>
|
||||
<div class="atlas-grid" id="atlas-grid">
|
||||
<!-- Portals will be injected here -->
|
||||
@@ -527,6 +439,88 @@ index.html
|
||||
fetchLatestSha().then(sha => { knownSha = sha; });
|
||||
setInterval(poll, INTERVAL);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Archive Health Dashboard (Mnemosyne, issue #1210) -->
|
||||
<div id="archive-health-dashboard" class="archive-health-dashboard" style="display:none;" aria-label="Archive Health Dashboard">
|
||||
<div class="archive-health-header">
|
||||
<span class="archive-health-title">◈ ARCHIVE HEALTH</span>
|
||||
<button class="archive-health-close" onclick="toggleArchiveHealthDashboard()" aria-label="Close dashboard">✕</button>
|
||||
</div>
|
||||
<div id="archive-health-content" class="archive-health-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Memory Activity Feed (Mnemosyne) -->
|
||||
<div id="memory-feed" class="memory-feed" style="display:none;">
|
||||
<div class="memory-feed-header">
|
||||
<span class="memory-feed-title">✨ Memory Feed</span>
|
||||
<div class="memory-feed-actions"><button class="memory-feed-clear" onclick="clearMemoryFeed()">Clear</button><button class="memory-feed-toggle" onclick="document.getElementById('memory-feed').style.display='none'">✕</button></div>
|
||||
</div>
|
||||
<div id="memory-feed-list" class="memory-feed-list"></div>
|
||||
<!-- ═══ MNEMOSYNE MEMORY FILTER ═══ -->
|
||||
<div id="memory-filter" class="memory-filter" style="display:none;">
|
||||
<div class="filter-header">
|
||||
<span class="filter-title">⬡ Memory Filter</span>
|
||||
<button class="filter-close" onclick="closeMemoryFilter()">✕</button>
|
||||
</div>
|
||||
<div class="filter-controls">
|
||||
<button class="filter-btn" onclick="setAllFilters(true)">Show All</button>
|
||||
<button class="filter-btn" onclick="setAllFilters(false)">Hide All</button>
|
||||
</div>
|
||||
<div class="filter-list" id="filter-list"></div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Memory Inspect Panel (Mnemosyne, issue #1227) -->
|
||||
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── MNEMOSYNE: Memory Filter Panel ───────────────────
|
||||
function openMemoryFilter() {
|
||||
renderFilterList();
|
||||
document.getElementById('memory-filter').style.display = 'flex';
|
||||
}
|
||||
function closeMemoryFilter() {
|
||||
document.getElementById('memory-filter').style.display = 'none';
|
||||
}
|
||||
function renderFilterList() {
|
||||
const counts = SpatialMemory.getMemoryCountByRegion();
|
||||
const regions = SpatialMemory.REGIONS;
|
||||
const list = document.getElementById('filter-list');
|
||||
list.innerHTML = '';
|
||||
for (const [key, region] of Object.entries(regions)) {
|
||||
const count = counts[key] || 0;
|
||||
const visible = SpatialMemory.isRegionVisible(key);
|
||||
const colorHex = '#' + region.color.toString(16).padStart(6, '0');
|
||||
const item = document.createElement('div');
|
||||
item.className = 'filter-item';
|
||||
item.innerHTML = `
|
||||
<div class="filter-item-left">
|
||||
<span class="filter-dot" style="background:${colorHex}"></span>
|
||||
<span class="filter-label">${region.glyph} ${region.label}</span>
|
||||
</div>
|
||||
<div class="filter-item-right">
|
||||
<span class="filter-count">${count}</span>
|
||||
<label class="filter-toggle">
|
||||
<input type="checkbox" ${visible ? 'checked' : ''}
|
||||
onchange="toggleRegion('${key}', this.checked)">
|
||||
<span class="filter-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
function toggleRegion(category, visible) {
|
||||
SpatialMemory.setRegionVisibility(category, visible);
|
||||
}
|
||||
function setAllFilters(visible) {
|
||||
SpatialMemory.setAllRegionsVisible(visible);
|
||||
renderFilterList();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
263
nexus/components/memory-birth.js
Normal file
263
nexus/components/memory-birth.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Memory Birth Animation System
|
||||
*
|
||||
* Gives newly placed memory crystals a "materialization" entrance:
|
||||
* - Scale from 0 → 1 with elastic ease
|
||||
* - Bloom flash on arrival (emissive spike)
|
||||
* - Nearby related memories pulse in response
|
||||
* - Connection lines draw in progressively
|
||||
*
|
||||
* Usage:
|
||||
* import { MemoryBirth } from './nexus/components/memory-birth.js';
|
||||
* MemoryBirth.init(scene);
|
||||
* // After placing a crystal via SpatialMemory.placeMemory():
|
||||
* MemoryBirth.triggerBirth(crystalMesh, spatialMemory);
|
||||
* // In your render loop:
|
||||
* MemoryBirth.update(delta);
|
||||
*/
|
||||
|
||||
const MemoryBirth = (() => {
|
||||
// ─── CONFIG ────────────────────────────────────────
|
||||
const BIRTH_DURATION = 1.8; // seconds for full materialization
|
||||
const BLOOM_PEAK = 0.3; // when the bloom flash peaks (fraction of duration)
|
||||
const BLOOM_INTENSITY = 4.0; // emissive spike at peak
|
||||
const NEIGHBOR_PULSE_RADIUS = 8; // units — memories in this range pulse
|
||||
const NEIGHBOR_PULSE_INTENSITY = 2.5;
|
||||
const NEIGHBOR_PULSE_DURATION = 0.8;
|
||||
const LINE_DRAW_DURATION = 1.2; // seconds for connection lines to grow in
|
||||
|
||||
let _scene = null;
|
||||
let _activeBirths = []; // { mesh, startTime, duration, originPos }
|
||||
let _activePulses = []; // { mesh, startTime, duration, origEmissive, origIntensity }
|
||||
let _activeLineGrowths = []; // { line, startTime, duration, totalPoints }
|
||||
let _initialized = false;
|
||||
|
||||
// ─── ELASTIC EASE-OUT ─────────────────────────────
|
||||
function elasticOut(t) {
|
||||
if (t <= 0) return 0;
|
||||
if (t >= 1) return 1;
|
||||
const c4 = (2 * Math.PI) / 3;
|
||||
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
||||
}
|
||||
|
||||
// ─── SMOOTH STEP ──────────────────────────────────
|
||||
function smoothstep(edge0, edge1, x) {
|
||||
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
// ─── INIT ─────────────────────────────────────────
|
||||
function init(scene) {
|
||||
_scene = scene;
|
||||
_initialized = true;
|
||||
console.info('[MemoryBirth] Initialized');
|
||||
}
|
||||
|
||||
// ─── TRIGGER BIRTH ────────────────────────────────
|
||||
function triggerBirth(mesh, spatialMemory) {
|
||||
if (!_initialized || !mesh) return;
|
||||
|
||||
// Start at zero scale
|
||||
mesh.scale.setScalar(0.001);
|
||||
|
||||
// Store original material values for bloom
|
||||
if (mesh.material) {
|
||||
mesh.userData._birthOrigEmissive = mesh.material.emissiveIntensity;
|
||||
mesh.userData._birthOrigOpacity = mesh.material.opacity;
|
||||
}
|
||||
|
||||
_activeBirths.push({
|
||||
mesh,
|
||||
startTime: Date.now() / 1000,
|
||||
duration: BIRTH_DURATION,
|
||||
spatialMemory,
|
||||
originPos: mesh.position.clone()
|
||||
});
|
||||
|
||||
// Trigger neighbor pulses for memories in the same region
|
||||
_triggerNeighborPulses(mesh, spatialMemory);
|
||||
|
||||
// Schedule connection line growth
|
||||
_triggerLineGrowth(mesh, spatialMemory);
|
||||
}
|
||||
|
||||
// ─── NEIGHBOR PULSE ───────────────────────────────
|
||||
function _triggerNeighborPulses(mesh, spatialMemory) {
|
||||
if (!spatialMemory || !mesh.position) return;
|
||||
|
||||
const allMems = spatialMemory.getAllMemories ? spatialMemory.getAllMemories() : [];
|
||||
const pos = mesh.position;
|
||||
const sourceId = mesh.userData.memId;
|
||||
|
||||
allMems.forEach(mem => {
|
||||
if (mem.id === sourceId) return;
|
||||
if (!mem.position) return;
|
||||
|
||||
const dx = mem.position[0] - pos.x;
|
||||
const dy = (mem.position[1] + 1.5) - pos.y;
|
||||
const dz = mem.position[2] - pos.z;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
if (dist < NEIGHBOR_PULSE_RADIUS) {
|
||||
// Find the mesh for this memory
|
||||
const neighborMesh = _findMeshById(mem.id, spatialMemory);
|
||||
if (neighborMesh && neighborMesh.material) {
|
||||
_activePulses.push({
|
||||
mesh: neighborMesh,
|
||||
startTime: Date.now() / 1000,
|
||||
duration: NEIGHBOR_PULSE_DURATION,
|
||||
origEmissive: neighborMesh.material.emissiveIntensity,
|
||||
intensity: NEIGHBOR_PULSE_INTENSITY * (1 - dist / NEIGHBOR_PULSE_RADIUS)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _findMeshById(memId, spatialMemory) {
|
||||
// Access the internal memory objects through crystal meshes
|
||||
const meshes = spatialMemory.getCrystalMeshes ? spatialMemory.getCrystalMeshes() : [];
|
||||
return meshes.find(m => m.userData && m.userData.memId === memId);
|
||||
}
|
||||
|
||||
// ─── LINE GROWTH ──────────────────────────────────
|
||||
function _triggerLineGrowth(mesh, spatialMemory) {
|
||||
if (!_scene) return;
|
||||
|
||||
// Find connection lines that originate from this memory
|
||||
// Connection lines are stored as children of the scene or in a group
|
||||
_scene.children.forEach(child => {
|
||||
if (child.isLine && child.userData) {
|
||||
// Check if this line connects to our new memory
|
||||
if (child.userData.fromId === mesh.userData.memId ||
|
||||
child.userData.toId === mesh.userData.memId) {
|
||||
_activeLineGrowths.push({
|
||||
line: child,
|
||||
startTime: Date.now() / 1000,
|
||||
duration: LINE_DRAW_DURATION
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── UPDATE (call every frame) ────────────────────
|
||||
function update(delta) {
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
// ── Process births ──
|
||||
for (let i = _activeBirths.length - 1; i >= 0; i--) {
|
||||
const birth = _activeBirths[i];
|
||||
const elapsed = now - birth.startTime;
|
||||
const t = Math.min(1, elapsed / birth.duration);
|
||||
|
||||
if (t >= 1) {
|
||||
// Birth complete — ensure final state
|
||||
birth.mesh.scale.setScalar(1);
|
||||
if (birth.mesh.material) {
|
||||
birth.mesh.material.emissiveIntensity = birth.mesh.userData._birthOrigEmissive || 1.5;
|
||||
birth.mesh.material.opacity = birth.mesh.userData._birthOrigOpacity || 0.9;
|
||||
}
|
||||
_activeBirths.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scale animation with elastic ease
|
||||
const scale = elasticOut(t);
|
||||
birth.mesh.scale.setScalar(Math.max(0.001, scale));
|
||||
|
||||
// Bloom flash — emissive intensity spikes at BLOOM_PEAK then fades
|
||||
if (birth.mesh.material) {
|
||||
const origEI = birth.mesh.userData._birthOrigEmissive || 1.5;
|
||||
const bloomT = smoothstep(0, BLOOM_PEAK, t) * (1 - smoothstep(BLOOM_PEAK, 1, t));
|
||||
birth.mesh.material.emissiveIntensity = origEI + bloomT * BLOOM_INTENSITY;
|
||||
|
||||
// Opacity fades in
|
||||
const origOp = birth.mesh.userData._birthOrigOpacity || 0.9;
|
||||
birth.mesh.material.opacity = origOp * smoothstep(0, 0.3, t);
|
||||
}
|
||||
|
||||
// Gentle upward float during birth (crystals are placed 1.5 above ground)
|
||||
birth.mesh.position.y = birth.originPos.y + (1 - scale) * 0.5;
|
||||
}
|
||||
|
||||
// ── Process neighbor pulses ──
|
||||
for (let i = _activePulses.length - 1; i >= 0; i--) {
|
||||
const pulse = _activePulses[i];
|
||||
const elapsed = now - pulse.startTime;
|
||||
const t = Math.min(1, elapsed / pulse.duration);
|
||||
|
||||
if (t >= 1) {
|
||||
// Restore original
|
||||
if (pulse.mesh.material) {
|
||||
pulse.mesh.material.emissiveIntensity = pulse.origEmissive;
|
||||
}
|
||||
_activePulses.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pulse curve: quick rise, slow decay
|
||||
const pulseVal = Math.sin(t * Math.PI) * pulse.intensity;
|
||||
if (pulse.mesh.material) {
|
||||
pulse.mesh.material.emissiveIntensity = pulse.origEmissive + pulseVal;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Process line growths ──
|
||||
for (let i = _activeLineGrowths.length - 1; i >= 0; i--) {
|
||||
const lg = _activeLineGrowths[i];
|
||||
const elapsed = now - lg.startTime;
|
||||
const t = Math.min(1, elapsed / lg.duration);
|
||||
|
||||
if (t >= 1) {
|
||||
// Ensure full visibility
|
||||
if (lg.line.material) {
|
||||
lg.line.material.opacity = lg.line.material.userData?._origOpacity || 0.6;
|
||||
}
|
||||
_activeLineGrowths.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fade in the line
|
||||
if (lg.line.material) {
|
||||
const origOp = lg.line.material.userData?._origOpacity || 0.6;
|
||||
lg.line.material.opacity = origOp * smoothstep(0, 1, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BIRTH COUNT (for UI/status) ─────────────────
|
||||
function getActiveBirthCount() {
|
||||
return _activeBirths.length;
|
||||
}
|
||||
|
||||
// ─── WRAP SPATIAL MEMORY ──────────────────────────
|
||||
/**
|
||||
* Wraps SpatialMemory.placeMemory() so every new crystal
|
||||
* automatically gets a birth animation.
|
||||
* Returns a proxy object that intercepts placeMemory calls.
|
||||
*/
|
||||
function wrapSpatialMemory(spatialMemory) {
|
||||
const original = spatialMemory.placeMemory.bind(spatialMemory);
|
||||
spatialMemory.placeMemory = function(mem) {
|
||||
const crystal = original(mem);
|
||||
if (crystal) {
|
||||
// Small delay to let THREE.js settle the object
|
||||
requestAnimationFrame(() => triggerBirth(crystal, spatialMemory));
|
||||
}
|
||||
return crystal;
|
||||
};
|
||||
console.info('[MemoryBirth] SpatialMemory.placeMemory wrapped — births will animate');
|
||||
return spatialMemory;
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
triggerBirth,
|
||||
update,
|
||||
getActiveBirthCount,
|
||||
wrapSpatialMemory
|
||||
};
|
||||
})();
|
||||
|
||||
export { MemoryBirth };
|
||||
180
nexus/components/memory-inspect.js
Normal file
180
nexus/components/memory-inspect.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// MNEMOSYNE — Memory Inspect Panel (issue #1227)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
//
|
||||
// Side-panel detail view for memory crystals.
|
||||
// Opens when a crystal is clicked; auto-closes on empty-space click.
|
||||
//
|
||||
// Usage from app.js:
|
||||
// MemoryInspect.init({ onNavigate: fn });
|
||||
// MemoryInspect.show(memData, regionDef);
|
||||
// MemoryInspect.hide();
|
||||
// MemoryInspect.isOpen();
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
const MemoryInspect = (() => {
|
||||
let _panel = null;
|
||||
let _onNavigate = null; // callback(memId) — navigate to a linked memory
|
||||
|
||||
// ─── INIT ────────────────────────────────────────────────
|
||||
function init(opts = {}) {
|
||||
_onNavigate = opts.onNavigate || null;
|
||||
_panel = document.getElementById('memory-inspect-panel');
|
||||
if (!_panel) {
|
||||
console.warn('[MemoryInspect] Panel element #memory-inspect-panel not found in DOM');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SHOW ────────────────────────────────────────────────
|
||||
function show(data, regionDef) {
|
||||
if (!_panel) return;
|
||||
|
||||
const region = regionDef || {};
|
||||
const colorHex = region.color
|
||||
? '#' + region.color.toString(16).padStart(6, '0')
|
||||
: '#4af0c0';
|
||||
const strength = data.strength != null ? data.strength : 0.7;
|
||||
const vitality = Math.round(Math.max(0, Math.min(1, strength)) * 100);
|
||||
|
||||
let vitalityColor = '#4af0c0';
|
||||
if (vitality < 30) vitalityColor = '#ff4466';
|
||||
else if (vitality < 60) vitalityColor = '#ffaa22';
|
||||
|
||||
const ts = data.timestamp ? new Date(data.timestamp) : null;
|
||||
const created = ts && !isNaN(ts) ? ts.toLocaleString() : '—';
|
||||
|
||||
// Linked memories
|
||||
let linksHtml = '';
|
||||
if (data.connections && data.connections.length > 0) {
|
||||
linksHtml = data.connections
|
||||
.map(id => `<button class="mi-link-btn" data-memid="${_esc(id)}">${_esc(id)}</button>`)
|
||||
.join('');
|
||||
} else {
|
||||
linksHtml = '<span class="mi-empty">No linked memories</span>';
|
||||
}
|
||||
|
||||
_panel.innerHTML = `
|
||||
<div class="mi-header" style="border-left:3px solid ${colorHex}">
|
||||
<span class="mi-region-glyph">${region.glyph || '\u25C8'}</span>
|
||||
<div class="mi-header-text">
|
||||
<div class="mi-id" title="${_esc(data.id || '')}">${_esc(_truncate(data.id || '\u2014', 28))}</div>
|
||||
<div class="mi-region" style="color:${colorHex}">${_esc(region.label || data.category || '\u2014')}</div>
|
||||
</div>
|
||||
<button class="mi-close" id="mi-close-btn" aria-label="Close inspect panel">\u2715</button>
|
||||
</div>
|
||||
<div class="mi-body">
|
||||
<div class="mi-section">
|
||||
<div class="mi-section-label">CONTENT</div>
|
||||
<div class="mi-content">${_esc(data.content || '(empty)')}</div>
|
||||
</div>
|
||||
<div class="mi-section">
|
||||
<div class="mi-section-label">VITALITY</div>
|
||||
<div class="mi-vitality-row">
|
||||
<div class="mi-vitality-bar-track">
|
||||
<div class="mi-vitality-bar" style="width:${vitality}%;background:${vitalityColor}"></div>
|
||||
</div>
|
||||
<span class="mi-vitality-pct" style="color:${vitalityColor}">${vitality}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mi-section">
|
||||
<div class="mi-section-label">LINKED MEMORIES</div>
|
||||
<div class="mi-links" id="mi-links">${linksHtml}</div>
|
||||
</div>
|
||||
<div class="mi-section">
|
||||
<div class="mi-section-label">META</div>
|
||||
<div class="mi-meta-row">
|
||||
<span class="mi-meta-key">Source</span>
|
||||
<span class="mi-meta-val">${_esc(data.source || '\u2014')}</span>
|
||||
</div>
|
||||
<div class="mi-meta-row">
|
||||
<span class="mi-meta-key">Created</span>
|
||||
<span class="mi-meta-val">${created}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mi-actions">
|
||||
<button class="mi-action-btn" id="mi-copy-btn">\u2398 Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire close button
|
||||
const closeBtn = _panel.querySelector('#mi-close-btn');
|
||||
if (closeBtn) closeBtn.addEventListener('click', hide);
|
||||
|
||||
// Wire copy button
|
||||
const copyBtn = _panel.querySelector('#mi-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const text = data.content || '';
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
copyBtn.textContent = '\u2713 Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '\u2398 Copy'; }, 1500);
|
||||
}).catch(() => _fallbackCopy(text));
|
||||
} else {
|
||||
_fallbackCopy(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire link navigation
|
||||
const linksContainer = _panel.querySelector('#mi-links');
|
||||
if (linksContainer) {
|
||||
linksContainer.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.mi-link-btn');
|
||||
if (btn && _onNavigate) _onNavigate(btn.dataset.memid);
|
||||
});
|
||||
}
|
||||
|
||||
_panel.style.display = 'flex';
|
||||
// Trigger CSS animation
|
||||
requestAnimationFrame(() => _panel.classList.add('mi-visible'));
|
||||
}
|
||||
|
||||
// ─── HIDE ─────────────────────────────────────────────────
|
||||
function hide() {
|
||||
if (!_panel) return;
|
||||
_panel.classList.remove('mi-visible');
|
||||
// Wait for CSS transition before hiding
|
||||
const onEnd = () => {
|
||||
_panel.style.display = 'none';
|
||||
_panel.removeEventListener('transitionend', onEnd);
|
||||
};
|
||||
_panel.addEventListener('transitionend', onEnd);
|
||||
// Safety fallback if transition doesn't fire
|
||||
setTimeout(() => { if (_panel) _panel.style.display = 'none'; }, 350);
|
||||
}
|
||||
|
||||
// ─── QUERY ────────────────────────────────────────────────
|
||||
function isOpen() {
|
||||
return _panel != null && _panel.style.display !== 'none';
|
||||
}
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────
|
||||
function _esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _truncate(str, n) {
|
||||
return str.length > n ? str.slice(0, n - 1) + '\u2026' : str;
|
||||
}
|
||||
|
||||
function _fallbackCopy(text) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
return { init, show, hide, isOpen };
|
||||
})();
|
||||
|
||||
export { MemoryInspect };
|
||||
99
nexus/components/memory-optimizer.js
Normal file
99
nexus/components/memory-optimizer.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// ═══════════════════════════════════════════
|
||||
// PROJECT MNEMOSYNE — MEMORY OPTIMIZER (GOFAI)
|
||||
// ═══════════════════════════════════════════
|
||||
//
|
||||
// Heuristic-based memory pruning and organization.
|
||||
// Operates without LLMs to maintain a lean, high-signal spatial index.
|
||||
//
|
||||
// Heuristics:
|
||||
// 1. Strength Decay: Memories lose strength over time if not accessed.
|
||||
// 2. Redundancy: Simple string similarity to identify duplicates.
|
||||
// 3. Isolation: Memories with no connections are lower priority.
|
||||
// 4. Aging: Old memories in 'working' are moved to 'archive'.
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
const MemoryOptimizer = (() => {
|
||||
const DECAY_RATE = 0.01; // Strength lost per optimization cycle
|
||||
const PRUNE_THRESHOLD = 0.1; // Remove if strength < this
|
||||
const SIMILARITY_THRESHOLD = 0.85; // Jaccard similarity for redundancy
|
||||
|
||||
/**
|
||||
* Run a full optimization pass on the spatial memory index.
|
||||
* @param {object} spatialMemory - The SpatialMemory component instance.
|
||||
* @returns {object} Summary of actions taken.
|
||||
*/
|
||||
function optimize(spatialMemory) {
|
||||
const memories = spatialMemory.getAllMemories();
|
||||
const results = { pruned: 0, moved: 0, updated: 0 };
|
||||
|
||||
// 1. Strength Decay & Aging
|
||||
memories.forEach(mem => {
|
||||
let strength = mem.strength || 0.7;
|
||||
strength -= DECAY_RATE;
|
||||
|
||||
if (strength < PRUNE_THRESHOLD) {
|
||||
spatialMemory.removeMemory(mem.id);
|
||||
results.pruned++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move old working memories to archive
|
||||
if (mem.category === 'working') {
|
||||
const timestamp = mem.timestamp || new Date().toISOString();
|
||||
const age = Date.now() - new Date(timestamp).getTime();
|
||||
if (age > 1000 * 60 * 60 * 24) { // 24 hours
|
||||
spatialMemory.removeMemory(mem.id);
|
||||
spatialMemory.placeMemory({ ...mem, category: 'archive', strength });
|
||||
results.moved++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
spatialMemory.updateMemory(mem.id, { strength });
|
||||
results.updated++;
|
||||
});
|
||||
|
||||
// 2. Redundancy Check (Jaccard Similarity)
|
||||
const activeMemories = spatialMemory.getAllMemories();
|
||||
for (let i = 0; i < activeMemories.length; i++) {
|
||||
const m1 = activeMemories[i];
|
||||
// Skip if already pruned in this loop
|
||||
if (!spatialMemory.getAllMemories().find(m => m.id === m1.id)) continue;
|
||||
|
||||
for (let j = i + 1; j < activeMemories.length; j++) {
|
||||
const m2 = activeMemories[j];
|
||||
if (m1.category !== m2.category) continue;
|
||||
|
||||
const sim = _calculateSimilarity(m1.content, m2.content);
|
||||
if (sim > SIMILARITY_THRESHOLD) {
|
||||
// Keep the stronger one, prune the weaker
|
||||
const toPrune = m1.strength >= m2.strength ? m2.id : m1.id;
|
||||
spatialMemory.removeMemory(toPrune);
|
||||
results.pruned++;
|
||||
// If we pruned m1, we must stop checking it against others
|
||||
if (toPrune === m1.id) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.info('[Mnemosyne] Optimization complete:', results);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Jaccard similarity between two strings.
|
||||
* @private
|
||||
*/
|
||||
function _calculateSimilarity(s1, s2) {
|
||||
if (!s1 || !s2) return 0;
|
||||
const set1 = new Set(s1.toLowerCase().split(/\s+/));
|
||||
const set2 = new Set(s2.toLowerCase().split(/\s+/));
|
||||
const intersection = new Set([...set1].filter(x => set2.has(x)));
|
||||
const union = new Set([...set1, ...set2]);
|
||||
return intersection.size / union.size;
|
||||
}
|
||||
|
||||
return { optimize };
|
||||
})();
|
||||
|
||||
export { MemoryOptimizer };
|
||||
@@ -1,4 +1,41 @@
|
||||
// ═══════════════════════════════════════════
|
||||
// ═══
|
||||
// ─── REGION VISIBILITY (Memory Filter) ──────────────
|
||||
let _regionVisibility = {}; // category -> boolean (undefined = visible)
|
||||
|
||||
setRegionVisibility(category, visible) {
|
||||
_regionVisibility[category] = visible;
|
||||
for (const obj of Object.values(_memoryObjects)) {
|
||||
if (obj.data.category === category && obj.mesh) {
|
||||
obj.mesh.visible = visible !== false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setAllRegionsVisible(visible) {
|
||||
const cats = Object.keys(REGIONS);
|
||||
for (const cat of cats) {
|
||||
_regionVisibility[cat] = visible;
|
||||
for (const obj of Object.values(_memoryObjects)) {
|
||||
if (obj.data.category === cat && obj.mesh) {
|
||||
obj.mesh.visible = visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getMemoryCountByRegion() {
|
||||
const counts = {};
|
||||
for (const obj of Object.values(_memoryObjects)) {
|
||||
const cat = obj.data.category || 'working';
|
||||
counts[cat] = (counts[cat] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
|
||||
isRegionVisible(category) {
|
||||
return _regionVisibility[category] !== false;
|
||||
},
|
||||
════════════════════════════════════════
|
||||
// PROJECT MNEMOSYNE — SPATIAL MEMORY SCHEMA
|
||||
// ═══════════════════════════════════════════
|
||||
//
|
||||
@@ -32,9 +69,6 @@
|
||||
|
||||
const SpatialMemory = (() => {
|
||||
|
||||
// ─── CALLBACKS ────────────────────────────────────────
|
||||
let _onMemoryPlacedCallback = null;
|
||||
|
||||
// ─── REGION DEFINITIONS ───────────────────────────────
|
||||
const REGIONS = {
|
||||
engineering: {
|
||||
@@ -136,6 +170,9 @@ const SpatialMemory = (() => {
|
||||
let _regionMarkers = {};
|
||||
let _memoryObjects = {};
|
||||
let _connectionLines = [];
|
||||
let _entityLines = []; // entity resolution lines (issue #1167)
|
||||
let _camera = null; // set by setCamera() for LOD culling
|
||||
const ENTITY_LOD_DIST = 50; // hide entity lines when camera > this from midpoint
|
||||
let _initialized = false;
|
||||
|
||||
// ─── CRYSTAL GEOMETRY (persistent memories) ───────────
|
||||
@@ -143,47 +180,6 @@ const SpatialMemory = (() => {
|
||||
return new THREE.OctahedronGeometry(size, 0);
|
||||
}
|
||||
|
||||
// ─── TRUST-BASED VISUALS ─────────────────────────────
|
||||
// Wire crystal visual properties to fact trust score (0.0-1.0).
|
||||
// Issue #1166: Trust > 0.8 = bright glow/full opacity,
|
||||
// 0.5-0.8 = medium/80%, < 0.5 = dim/40%, < 0.3 = near-invisible pulsing red.
|
||||
function _getTrustVisuals(trust, regionColor) {
|
||||
const t = Math.max(0, Math.min(1, trust));
|
||||
if (t >= 0.8) {
|
||||
return {
|
||||
opacity: 1.0,
|
||||
emissiveIntensity: 2.0 * t,
|
||||
emissiveColor: regionColor,
|
||||
lightIntensity: 1.2,
|
||||
glowDesc: 'high'
|
||||
};
|
||||
} else if (t >= 0.5) {
|
||||
return {
|
||||
opacity: 0.8,
|
||||
emissiveIntensity: 1.2 * t,
|
||||
emissiveColor: regionColor,
|
||||
lightIntensity: 0.6,
|
||||
glowDesc: 'medium'
|
||||
};
|
||||
} else if (t >= 0.3) {
|
||||
return {
|
||||
opacity: 0.4,
|
||||
emissiveIntensity: 0.5 * t,
|
||||
emissiveColor: regionColor,
|
||||
lightIntensity: 0.2,
|
||||
glowDesc: 'dim'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
opacity: 0.15,
|
||||
emissiveIntensity: 0.3,
|
||||
emissiveColor: 0xff2200,
|
||||
lightIntensity: 0.1,
|
||||
glowDesc: 'untrusted'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── REGION MARKER ───────────────────────────────────
|
||||
function createRegionMarker(regionKey, region) {
|
||||
const cx = region.center[0];
|
||||
@@ -250,7 +246,83 @@ const SpatialMemory = (() => {
|
||||
sprite.scale.set(4, 1, 1);
|
||||
_scene.add(sprite);
|
||||
|
||||
return { ring, disc, glowDisc, sprite };
|
||||
|
||||
// ─── BULK IMPORT (WebSocket sync) ───────────────────
|
||||
/**
|
||||
* Import an array of memories in batch — for WebSocket sync.
|
||||
* Skips duplicates (same id). Returns count of newly placed.
|
||||
* @param {Array} memories - Array of memory objects { id, content, category, ... }
|
||||
* @returns {number} Count of newly placed memories
|
||||
*/
|
||||
function importMemories(memories) {
|
||||
if (!Array.isArray(memories) || memories.length === 0) return 0;
|
||||
let count = 0;
|
||||
memories.forEach(mem => {
|
||||
if (mem.id && !_memoryObjects[mem.id]) {
|
||||
placeMemory(mem);
|
||||
count++;
|
||||
}
|
||||
});
|
||||
if (count > 0) {
|
||||
_dirty = true;
|
||||
saveToStorage();
|
||||
console.info('[Mnemosyne] Bulk imported', count, 'new memories (total:', Object.keys(_memoryObjects).length, ')');
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ─── UPDATE MEMORY ──────────────────────────────────
|
||||
/**
|
||||
* Update an existing memory's visual properties (strength, connections).
|
||||
* Does not move the crystal — only updates metadata and re-renders.
|
||||
* @param {string} memId - Memory ID to update
|
||||
* @param {object} updates - Fields to update: { strength, connections, content }
|
||||
* @returns {boolean} True if updated
|
||||
*/
|
||||
function updateMemory(memId, updates) {
|
||||
const obj = _memoryObjects[memId];
|
||||
if (!obj) return false;
|
||||
|
||||
if (updates.strength != null) {
|
||||
const strength = Math.max(0.05, Math.min(1, updates.strength));
|
||||
obj.mesh.userData.strength = strength;
|
||||
obj.mesh.material.emissiveIntensity = 1.5 * strength;
|
||||
obj.mesh.material.opacity = 0.5 + strength * 0.4;
|
||||
}
|
||||
if (updates.content != null) {
|
||||
obj.data.content = updates.content;
|
||||
}
|
||||
if (updates.connections != null) {
|
||||
obj.data.connections = updates.connections;
|
||||
// Rebuild connection lines
|
||||
_rebuildConnections(memId);
|
||||
}
|
||||
_dirty = true;
|
||||
saveToStorage();
|
||||
return true;
|
||||
}
|
||||
|
||||
function _rebuildConnections(memId) {
|
||||
// Remove existing lines for this memory
|
||||
for (let i = _connectionLines.length - 1; i >= 0; i--) {
|
||||
const line = _connectionLines[i];
|
||||
if (line.userData.from === memId || line.userData.to === memId) {
|
||||
if (line.parent) line.parent.remove(line);
|
||||
line.geometry.dispose();
|
||||
line.material.dispose();
|
||||
_connectionLines.splice(i, 1);
|
||||
}
|
||||
}
|
||||
// Recreate lines for current connections
|
||||
const obj = _memoryObjects[memId];
|
||||
if (!obj || !obj.data.connections) return;
|
||||
obj.data.connections.forEach(targetId => {
|
||||
const target = _memoryObjects[targetId];
|
||||
if (target) _createConnectionLine(obj, target);
|
||||
});
|
||||
}
|
||||
|
||||
return { ring, disc, glowDisc, sprite };
|
||||
}
|
||||
|
||||
// ─── PLACE A MEMORY ──────────────────────────────────
|
||||
@@ -260,20 +332,17 @@ const SpatialMemory = (() => {
|
||||
const region = REGIONS[mem.category] || REGIONS.working;
|
||||
const pos = mem.position || _assignPosition(mem.category, mem.id);
|
||||
const strength = Math.max(0.05, Math.min(1, mem.strength != null ? mem.strength : 0.7));
|
||||
const trust = mem.trust != null ? Math.max(0, Math.min(1, mem.trust)) : 0.7;
|
||||
const size = 0.2 + strength * 0.3;
|
||||
|
||||
const tv = _getTrustVisuals(trust, region.color);
|
||||
|
||||
const geo = createCrystalGeometry(size);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: region.color,
|
||||
emissive: tv.emissiveColor,
|
||||
emissiveIntensity: tv.emissiveIntensity,
|
||||
emissive: region.color,
|
||||
emissiveIntensity: 1.5 * strength,
|
||||
metalness: 0.6,
|
||||
roughness: 0.15,
|
||||
transparent: true,
|
||||
opacity: tv.opacity
|
||||
opacity: 0.5 + strength * 0.4
|
||||
});
|
||||
|
||||
const crystal = new THREE.Mesh(geo, mat);
|
||||
@@ -286,12 +355,10 @@ const SpatialMemory = (() => {
|
||||
region: mem.category,
|
||||
pulse: Math.random() * Math.PI * 2,
|
||||
strength: strength,
|
||||
trust: trust,
|
||||
glowDesc: tv.glowDesc,
|
||||
createdAt: mem.timestamp || new Date().toISOString()
|
||||
};
|
||||
|
||||
const light = new THREE.PointLight(tv.emissiveColor, tv.lightIntensity, 5);
|
||||
const light = new THREE.PointLight(region.color, 0.8 * strength, 5);
|
||||
crystal.add(light);
|
||||
|
||||
_scene.add(crystal);
|
||||
@@ -301,15 +368,13 @@ const SpatialMemory = (() => {
|
||||
_drawConnections(mem.id, mem.connections);
|
||||
}
|
||||
|
||||
if (mem.entity) {
|
||||
_drawEntityLines(mem.id, mem);
|
||||
}
|
||||
|
||||
_dirty = true;
|
||||
saveToStorage();
|
||||
console.info('[Mnemosyne] Spatial memory placed:', mem.id, 'in', region.label);
|
||||
|
||||
// Fire particle burst callback
|
||||
if (_onMemoryPlacedCallback) {
|
||||
_onMemoryPlacedCallback(crystal.position.clone(), mem.category || 'working');
|
||||
}
|
||||
|
||||
return crystal;
|
||||
}
|
||||
|
||||
@@ -353,6 +418,77 @@ const SpatialMemory = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── ENTITY RESOLUTION LINES (#1167) ──────────────────
|
||||
// Draw lines between crystals that share an entity or are related entities.
|
||||
// Same entity → thin blue line. Related entities → thin purple dashed line.
|
||||
function _drawEntityLines(memId, mem) {
|
||||
if (!mem.entity) return;
|
||||
const src = _memoryObjects[memId];
|
||||
if (!src) return;
|
||||
|
||||
Object.entries(_memoryObjects).forEach(([otherId, other]) => {
|
||||
if (otherId === memId) return;
|
||||
const otherData = other.data;
|
||||
if (!otherData.entity) return;
|
||||
|
||||
let lineType = null;
|
||||
if (otherData.entity === mem.entity) {
|
||||
lineType = 'same_entity';
|
||||
} else if (mem.related_entities && mem.related_entities.includes(otherData.entity)) {
|
||||
lineType = 'related';
|
||||
} else if (otherData.related_entities && otherData.related_entities.includes(mem.entity)) {
|
||||
lineType = 'related';
|
||||
}
|
||||
if (!lineType) return;
|
||||
|
||||
// Deduplicate — only draw from lower ID to higher
|
||||
if (memId > otherId) return;
|
||||
|
||||
const points = [src.mesh.position.clone(), other.mesh.position.clone()];
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
let mat;
|
||||
if (lineType === 'same_entity') {
|
||||
mat = new THREE.LineBasicMaterial({ color: 0x4488ff, transparent: true, opacity: 0.35 });
|
||||
} else {
|
||||
mat = new THREE.LineDashedMaterial({ color: 0x9966ff, dashSize: 0.3, gapSize: 0.2, transparent: true, opacity: 0.25 });
|
||||
const line = new THREE.Line(geo, mat);
|
||||
line.computeLineDistances();
|
||||
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
|
||||
_scene.add(line);
|
||||
_entityLines.push(line);
|
||||
return;
|
||||
}
|
||||
const line = new THREE.Line(geo, mat);
|
||||
line.userData = { type: 'entity_line', from: memId, to: otherId, lineType };
|
||||
_scene.add(line);
|
||||
_entityLines.push(line);
|
||||
});
|
||||
}
|
||||
|
||||
function _updateEntityLines() {
|
||||
if (!_camera) return;
|
||||
const camPos = _camera.position;
|
||||
|
||||
_entityLines.forEach(line => {
|
||||
// Compute midpoint of line
|
||||
const posArr = line.geometry.attributes.position.array;
|
||||
const mx = (posArr[0] + posArr[3]) / 2;
|
||||
const my = (posArr[1] + posArr[4]) / 2;
|
||||
const mz = (posArr[2] + posArr[5]) / 2;
|
||||
const dist = camPos.distanceTo(new THREE.Vector3(mx, my, mz));
|
||||
|
||||
if (dist > ENTITY_LOD_DIST) {
|
||||
line.visible = false;
|
||||
} else {
|
||||
line.visible = true;
|
||||
// Fade based on distance
|
||||
const fade = Math.max(0, 1 - (dist / ENTITY_LOD_DIST));
|
||||
const baseOpacity = line.userData.lineType === 'same_entity' ? 0.35 : 0.25;
|
||||
line.material.opacity = baseOpacity * fade;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── REMOVE A MEMORY ─────────────────────────────────
|
||||
function removeMemory(memId) {
|
||||
const obj = _memoryObjects[memId];
|
||||
@@ -372,6 +508,16 @@ const SpatialMemory = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = _entityLines.length - 1; i >= 0; i--) {
|
||||
const line = _entityLines[i];
|
||||
if (line.userData.from === memId || line.userData.to === memId) {
|
||||
if (line.parent) line.parent.remove(line);
|
||||
line.geometry.dispose();
|
||||
line.material.dispose();
|
||||
_entityLines.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
delete _memoryObjects[memId];
|
||||
_dirty = true;
|
||||
saveToStorage();
|
||||
@@ -392,19 +538,13 @@ const SpatialMemory = (() => {
|
||||
mesh.scale.setScalar(pulse);
|
||||
|
||||
if (mesh.material) {
|
||||
const trust = mesh.userData.trust != null ? mesh.userData.trust : 0.7;
|
||||
const base = mesh.userData.strength || 0.7;
|
||||
if (trust < 0.3) {
|
||||
// Low trust: pulsing red — visible warning
|
||||
const pulseAlpha = 0.15 + Math.sin(mesh.userData.pulse * 2.0) * 0.15;
|
||||
mesh.material.emissiveIntensity = 0.3 + Math.sin(mesh.userData.pulse * 2.0) * 0.3;
|
||||
mesh.material.opacity = pulseAlpha;
|
||||
} else {
|
||||
mesh.material.emissiveIntensity = 1.0 + Math.sin(mesh.userData.pulse * 0.7) * 0.5 * base;
|
||||
}
|
||||
mesh.material.emissiveIntensity = 1.0 + Math.sin(mesh.userData.pulse * 0.7) * 0.5 * base;
|
||||
}
|
||||
});
|
||||
|
||||
_updateEntityLines();
|
||||
|
||||
Object.values(_regionMarkers).forEach(marker => {
|
||||
if (marker.ring && marker.ring.material) {
|
||||
marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05;
|
||||
@@ -431,42 +571,6 @@ const SpatialMemory = (() => {
|
||||
return REGIONS;
|
||||
}
|
||||
|
||||
// ─── UPDATE VISUAL PROPERTIES ────────────────────────
|
||||
// Re-render crystal when trust/strength change (no position move).
|
||||
function updateMemoryVisual(memId, updates) {
|
||||
const obj = _memoryObjects[memId];
|
||||
if (!obj) return false;
|
||||
|
||||
const mesh = obj.mesh;
|
||||
const region = REGIONS[obj.region] || REGIONS.working;
|
||||
|
||||
if (updates.trust != null) {
|
||||
const trust = Math.max(0, Math.min(1, updates.trust));
|
||||
mesh.userData.trust = trust;
|
||||
obj.data.trust = trust;
|
||||
const tv = _getTrustVisuals(trust, region.color);
|
||||
mesh.material.emissive = new THREE.Color(tv.emissiveColor);
|
||||
mesh.material.emissiveIntensity = tv.emissiveIntensity;
|
||||
mesh.material.opacity = tv.opacity;
|
||||
mesh.userData.glowDesc = tv.glowDesc;
|
||||
if (mesh.children.length > 0 && mesh.children[0].isPointLight) {
|
||||
mesh.children[0].intensity = tv.lightIntensity;
|
||||
mesh.children[0].color = new THREE.Color(tv.emissiveColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.strength != null) {
|
||||
const strength = Math.max(0.05, Math.min(1, updates.strength));
|
||||
mesh.userData.strength = strength;
|
||||
obj.data.strength = strength;
|
||||
}
|
||||
|
||||
_dirty = true;
|
||||
saveToStorage();
|
||||
console.info('[Mnemosyne] Visual updated:', memId, 'trust:', mesh.userData.trust, 'glow:', mesh.userData.glowDesc);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── QUERY ───────────────────────────────────────────
|
||||
function getMemoryAtPosition(position, maxDist) {
|
||||
maxDist = maxDist || 2;
|
||||
@@ -606,7 +710,6 @@ const SpatialMemory = (() => {
|
||||
source: o.data.source || 'unknown',
|
||||
timestamp: o.data.timestamp || o.mesh.userData.createdAt,
|
||||
strength: o.mesh.userData.strength || 0.7,
|
||||
trust: o.mesh.userData.trust != null ? o.mesh.userData.trust : 0.7,
|
||||
connections: o.data.connections || []
|
||||
}))
|
||||
};
|
||||
@@ -752,173 +855,18 @@ const SpatialMemory = (() => {
|
||||
return _selectedId;
|
||||
}
|
||||
|
||||
// ─── FILE EXPORT ──────────────────────────────────────
|
||||
function exportToFile() {
|
||||
const index = exportIndex();
|
||||
const json = JSON.stringify(index, null, 2);
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const filename = 'mnemosyne-export-' + date + '.json';
|
||||
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.info('[Mnemosyne] Exported', index.memories.length, 'memories to', filename);
|
||||
return { filename, count: index.memories.length };
|
||||
}
|
||||
|
||||
// ─── FILE IMPORT ──────────────────────────────────────
|
||||
function importFromFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file) {
|
||||
reject(new Error('No file provided'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
const data = JSON.parse(e.target.result);
|
||||
|
||||
// Schema validation
|
||||
if (!data || typeof data !== 'object') {
|
||||
reject(new Error('Invalid JSON: not an object'));
|
||||
return;
|
||||
}
|
||||
if (typeof data.version !== 'number') {
|
||||
reject(new Error('Invalid schema: missing version field'));
|
||||
return;
|
||||
}
|
||||
if (data.version !== STORAGE_VERSION) {
|
||||
reject(new Error('Version mismatch: got ' + data.version + ', expected ' + STORAGE_VERSION));
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(data.memories)) {
|
||||
reject(new Error('Invalid schema: memories is not an array'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each memory entry
|
||||
for (let i = 0; i < data.memories.length; i++) {
|
||||
const mem = data.memories[i];
|
||||
if (!mem.id || typeof mem.id !== 'string') {
|
||||
reject(new Error('Invalid memory at index ' + i + ': missing or invalid id'));
|
||||
return;
|
||||
}
|
||||
if (!mem.category || typeof mem.category !== 'string') {
|
||||
reject(new Error('Invalid memory "' + mem.id + '": missing category'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const count = importIndex(data);
|
||||
saveToStorage();
|
||||
console.info('[Mnemosyne] Imported', count, 'memories from file');
|
||||
resolve({ count, total: data.memories.length });
|
||||
} catch (parseErr) {
|
||||
reject(new Error('Failed to parse JSON: ' + parseErr.message));
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = function() {
|
||||
reject(new Error('Failed to read file'));
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
function setOnMemoryPlaced(callback) {
|
||||
_onMemoryPlacedCallback = callback;
|
||||
// ─── CAMERA REFERENCE (for entity line LOD) ─────────
|
||||
function setCamera(camera) {
|
||||
_camera = camera;
|
||||
}
|
||||
|
||||
return {
|
||||
init, placeMemory, removeMemory, update, updateMemoryVisual,
|
||||
init, placeMemory, removeMemory, update, importMemories, updateMemory,
|
||||
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
|
||||
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
|
||||
exportIndex, importIndex, exportToFile, importFromFile, searchNearby, REGIONS,
|
||||
exportIndex, importIndex, searchNearby, REGIONS,
|
||||
saveToStorage, loadFromStorage, clearStorage,
|
||||
runGravityLayout,
|
||||
searchContent, highlightSearchResults, clearSearch, getSearchMatchPosition,
|
||||
setOnMemoryPlaced
|
||||
runGravityLayout, setCamera
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
24
nexus/mnemosyne/__init__.py
Normal file
24
nexus/mnemosyne/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""nexus.mnemosyne — The Living Holographic Archive.
|
||||
|
||||
Phase 1: Foundation — core archive, entry model, holographic linker,
|
||||
ingestion pipeline, and CLI.
|
||||
|
||||
Builds on MemPalace vector memory to create interconnected meaning:
|
||||
entries auto-reference related entries via semantic similarity,
|
||||
forming a living archive that surfaces relevant context autonomously.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
from nexus.mnemosyne.linker import HolographicLinker
|
||||
from nexus.mnemosyne.ingest import ingest_from_mempalace, ingest_event
|
||||
|
||||
__all__ = [
|
||||
"MnemosyneArchive",
|
||||
"ArchiveEntry",
|
||||
"HolographicLinker",
|
||||
"ingest_from_mempalace",
|
||||
"ingest_event",
|
||||
]
|
||||
302
nexus/mnemosyne/archive.py
Normal file
302
nexus/mnemosyne/archive.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""MnemosyneArchive — core archive class.
|
||||
|
||||
The living holographic archive. Stores entries, maintains links,
|
||||
and provides query interfaces for retrieving connected knowledge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
from nexus.mnemosyne.linker import HolographicLinker
|
||||
|
||||
_EXPORT_VERSION = "1"
|
||||
|
||||
|
||||
class MnemosyneArchive:
|
||||
"""The holographic archive — stores and links entries.
|
||||
|
||||
Phase 1 uses JSON file storage. Phase 2 will integrate with
|
||||
MemPalace (ChromaDB) for vector-semantic search.
|
||||
"""
|
||||
|
||||
def __init__(self, archive_path: Optional[Path] = None):
|
||||
self.path = archive_path or Path.home() / ".hermes" / "mnemosyne" / "archive.json"
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.linker = HolographicLinker()
|
||||
self._entries: dict[str, ArchiveEntry] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if self.path.exists():
|
||||
try:
|
||||
with open(self.path) as f:
|
||||
data = json.load(f)
|
||||
for entry_data in data.get("entries", []):
|
||||
entry = ArchiveEntry.from_dict(entry_data)
|
||||
self._entries[entry.id] = entry
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass # Start fresh on corrupt data
|
||||
|
||||
def _save(self):
|
||||
data = {
|
||||
"entries": [e.to_dict() for e in self._entries.values()],
|
||||
"count": len(self._entries),
|
||||
}
|
||||
with open(self.path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def add(self, entry: ArchiveEntry, auto_link: bool = True) -> ArchiveEntry:
|
||||
"""Add an entry to the archive. Auto-links to related entries."""
|
||||
self._entries[entry.id] = entry
|
||||
if auto_link:
|
||||
self.linker.apply_links(entry, list(self._entries.values()))
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
def get(self, entry_id: str) -> Optional[ArchiveEntry]:
|
||||
return self._entries.get(entry_id)
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> list[ArchiveEntry]:
|
||||
"""Simple keyword search across titles and content."""
|
||||
query_tokens = set(query.lower().split())
|
||||
scored = []
|
||||
for entry in self._entries.values():
|
||||
text = f"{entry.title} {entry.content} {' '.join(entry.topics)}".lower()
|
||||
hits = sum(1 for t in query_tokens if t in text)
|
||||
if hits > 0:
|
||||
scored.append((hits, entry))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [e for _, e in scored[:limit]]
|
||||
|
||||
def semantic_search(self, query: str, limit: int = 10, threshold: float = 0.05) -> list[ArchiveEntry]:
|
||||
"""Semantic search using holographic linker similarity.
|
||||
|
||||
Scores each entry by Jaccard similarity between query tokens and entry
|
||||
tokens, then boosts entries with more inbound links (more "holographic").
|
||||
Falls back to keyword search if no entries meet the similarity threshold.
|
||||
|
||||
Args:
|
||||
query: Natural language query string.
|
||||
limit: Maximum number of results to return.
|
||||
threshold: Minimum Jaccard similarity to be considered a semantic match.
|
||||
|
||||
Returns:
|
||||
List of ArchiveEntry sorted by combined relevance score, descending.
|
||||
"""
|
||||
query_tokens = HolographicLinker._tokenize(query)
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
# Count inbound links for each entry (how many entries link TO this one)
|
||||
inbound: dict[str, int] = {eid: 0 for eid in self._entries}
|
||||
for entry in self._entries.values():
|
||||
for linked_id in entry.links:
|
||||
if linked_id in inbound:
|
||||
inbound[linked_id] += 1
|
||||
|
||||
max_inbound = max(inbound.values(), default=1) or 1
|
||||
|
||||
scored = []
|
||||
for entry in self._entries.values():
|
||||
entry_tokens = HolographicLinker._tokenize(f"{entry.title} {entry.content} {' '.join(entry.topics)}")
|
||||
if not entry_tokens:
|
||||
continue
|
||||
intersection = query_tokens & entry_tokens
|
||||
union = query_tokens | entry_tokens
|
||||
jaccard = len(intersection) / len(union)
|
||||
if jaccard >= threshold:
|
||||
link_boost = inbound[entry.id] / max_inbound * 0.2 # up to 20% boost
|
||||
scored.append((jaccard + link_boost, entry))
|
||||
|
||||
if scored:
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [e for _, e in scored[:limit]]
|
||||
|
||||
# Graceful fallback to keyword search
|
||||
return self.search(query, limit=limit)
|
||||
|
||||
def get_linked(self, entry_id: str, depth: int = 1) -> list[ArchiveEntry]:
|
||||
"""Get entries linked to a given entry, up to specified depth."""
|
||||
visited = set()
|
||||
frontier = {entry_id}
|
||||
result = []
|
||||
for _ in range(depth):
|
||||
next_frontier = set()
|
||||
for eid in frontier:
|
||||
if eid in visited:
|
||||
continue
|
||||
visited.add(eid)
|
||||
entry = self._entries.get(eid)
|
||||
if entry:
|
||||
for linked_id in entry.links:
|
||||
if linked_id not in visited:
|
||||
linked = self._entries.get(linked_id)
|
||||
if linked:
|
||||
result.append(linked)
|
||||
next_frontier.add(linked_id)
|
||||
frontier = next_frontier
|
||||
return result
|
||||
|
||||
def by_topic(self, topic: str) -> list[ArchiveEntry]:
|
||||
"""Get all entries tagged with a topic."""
|
||||
topic_lower = topic.lower()
|
||||
return [e for e in self._entries.values() if topic_lower in [t.lower() for t in e.topics]]
|
||||
|
||||
def remove(self, entry_id: str) -> bool:
|
||||
"""Remove an entry and clean up all bidirectional links.
|
||||
|
||||
Returns True if the entry existed and was removed, False otherwise.
|
||||
"""
|
||||
if entry_id not in self._entries:
|
||||
return False
|
||||
# Remove back-links from all other entries
|
||||
for other in self._entries.values():
|
||||
if entry_id in other.links:
|
||||
other.links.remove(entry_id)
|
||||
del self._entries[entry_id]
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def export(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
topics: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
"""Export a filtered subset of the archive.
|
||||
|
||||
Args:
|
||||
query: keyword filter applied to title + content (case-insensitive)
|
||||
topics: list of topic tags; entries must match at least one
|
||||
|
||||
Returns a JSON-serialisable dict with an ``entries`` list and metadata.
|
||||
"""
|
||||
candidates = list(self._entries.values())
|
||||
|
||||
if topics:
|
||||
lower_topics = {t.lower() for t in topics}
|
||||
candidates = [
|
||||
e for e in candidates
|
||||
if any(t.lower() in lower_topics for t in e.topics)
|
||||
]
|
||||
|
||||
if query:
|
||||
query_tokens = set(query.lower().split())
|
||||
candidates = [
|
||||
e for e in candidates
|
||||
if any(
|
||||
token in f"{e.title} {e.content} {' '.join(e.topics)}".lower()
|
||||
for token in query_tokens
|
||||
)
|
||||
]
|
||||
|
||||
return {
|
||||
"version": _EXPORT_VERSION,
|
||||
"filters": {"query": query, "topics": topics},
|
||||
"count": len(candidates),
|
||||
"entries": [e.to_dict() for e in candidates],
|
||||
}
|
||||
|
||||
def topic_counts(self) -> dict[str, int]:
|
||||
"""Return a dict mapping topic name → entry count, sorted by count desc."""
|
||||
counts: dict[str, int] = {}
|
||||
for entry in self._entries.values():
|
||||
for topic in entry.topics:
|
||||
counts[topic] = counts.get(topic, 0) + 1
|
||||
return dict(sorted(counts.items(), key=lambda x: x[1], reverse=True))
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def graph_data(
|
||||
self,
|
||||
topic_filter: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Export the full connection graph for 3D constellation visualization.
|
||||
|
||||
Returns a dict with:
|
||||
- nodes: list of {id, title, topics, source, created_at}
|
||||
- edges: list of {source, target, weight} from holographic links
|
||||
|
||||
Args:
|
||||
topic_filter: If set, only include entries matching this topic
|
||||
and edges between them.
|
||||
"""
|
||||
entries = list(self._entries.values())
|
||||
|
||||
if topic_filter:
|
||||
topic_lower = topic_filter.lower()
|
||||
entries = [
|
||||
e for e in entries
|
||||
if topic_lower in [t.lower() for t in e.topics]
|
||||
]
|
||||
|
||||
entry_ids = {e.id for e in entries}
|
||||
|
||||
nodes = [
|
||||
{
|
||||
"id": e.id,
|
||||
"title": e.title,
|
||||
"topics": e.topics,
|
||||
"source": e.source,
|
||||
"created_at": e.created_at,
|
||||
}
|
||||
for e in entries
|
||||
]
|
||||
|
||||
# Build edges from links, dedup (A→B and B→A become one edge)
|
||||
seen_edges: set[tuple[str, str]] = set()
|
||||
edges = []
|
||||
for e in entries:
|
||||
for linked_id in e.links:
|
||||
if linked_id not in entry_ids:
|
||||
continue
|
||||
pair = (min(e.id, linked_id), max(e.id, linked_id))
|
||||
if pair in seen_edges:
|
||||
continue
|
||||
seen_edges.add(pair)
|
||||
# Compute weight via linker for live similarity score
|
||||
linked = self._entries.get(linked_id)
|
||||
if linked:
|
||||
weight = self.linker.compute_similarity(e, linked)
|
||||
edges.append({
|
||||
"source": pair[0],
|
||||
"target": pair[1],
|
||||
"weight": round(weight, 4),
|
||||
})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
def stats(self) -> dict:
|
||||
entries = list(self._entries.values())
|
||||
total_links = sum(len(e.links) for e in entries)
|
||||
topics: set[str] = set()
|
||||
for e in entries:
|
||||
topics.update(e.topics)
|
||||
|
||||
# Orphans: entries with no links at all
|
||||
orphans = sum(1 for e in entries if len(e.links) == 0)
|
||||
|
||||
# Link density: average links per entry (0 when empty)
|
||||
n = len(entries)
|
||||
link_density = round(total_links / n, 4) if n else 0.0
|
||||
|
||||
# Age distribution
|
||||
timestamps = sorted(e.created_at for e in entries)
|
||||
oldest_entry = timestamps[0] if timestamps else None
|
||||
newest_entry = timestamps[-1] if timestamps else None
|
||||
|
||||
return {
|
||||
"entries": n,
|
||||
"total_links": total_links,
|
||||
"unique_topics": len(topics),
|
||||
"topics": sorted(topics),
|
||||
"orphans": orphans,
|
||||
"link_density": link_density,
|
||||
"oldest_entry": oldest_entry,
|
||||
"newest_entry": newest_entry,
|
||||
}
|
||||
140
nexus/mnemosyne/cli.py
Normal file
140
nexus/mnemosyne/cli.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""CLI interface for Mnemosyne.
|
||||
|
||||
Provides: mnemosyne ingest, mnemosyne search, mnemosyne link, mnemosyne stats,
|
||||
mnemosyne topics, mnemosyne remove, mnemosyne export
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
from nexus.mnemosyne.ingest import ingest_event
|
||||
|
||||
|
||||
def cmd_stats(args):
|
||||
archive = MnemosyneArchive()
|
||||
stats = archive.stats()
|
||||
print(json.dumps(stats, indent=2))
|
||||
|
||||
|
||||
def cmd_search(args):
|
||||
archive = MnemosyneArchive()
|
||||
if getattr(args, "semantic", False):
|
||||
results = archive.semantic_search(args.query, limit=args.limit)
|
||||
else:
|
||||
results = archive.search(args.query, limit=args.limit)
|
||||
if not results:
|
||||
print("No results found.")
|
||||
return
|
||||
for entry in results:
|
||||
linked = len(entry.links)
|
||||
print(f"[{entry.id[:8]}] {entry.title}")
|
||||
print(f" Source: {entry.source} | Topics: {', '.join(entry.topics)} | Links: {linked}")
|
||||
print(f" {entry.content[:120]}...")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_ingest(args):
|
||||
archive = MnemosyneArchive()
|
||||
entry = ingest_event(
|
||||
archive,
|
||||
title=args.title,
|
||||
content=args.content,
|
||||
topics=args.topics.split(",") if args.topics else [],
|
||||
)
|
||||
print(f"Ingested: [{entry.id[:8]}] {entry.title} ({len(entry.links)} links)")
|
||||
|
||||
|
||||
def cmd_link(args):
|
||||
archive = MnemosyneArchive()
|
||||
entry = archive.get(args.entry_id)
|
||||
if not entry:
|
||||
print(f"Entry not found: {args.entry_id}")
|
||||
sys.exit(1)
|
||||
linked = archive.get_linked(entry.id, depth=args.depth)
|
||||
if not linked:
|
||||
print("No linked entries found.")
|
||||
return
|
||||
for e in linked:
|
||||
print(f" [{e.id[:8]}] {e.title} (source: {e.source})")
|
||||
|
||||
|
||||
def cmd_topics(args):
|
||||
archive = MnemosyneArchive()
|
||||
counts = archive.topic_counts()
|
||||
if not counts:
|
||||
print("No topics found.")
|
||||
return
|
||||
for topic, count in counts.items():
|
||||
print(f" {topic}: {count}")
|
||||
|
||||
|
||||
def cmd_remove(args):
|
||||
archive = MnemosyneArchive()
|
||||
removed = archive.remove(args.entry_id)
|
||||
if removed:
|
||||
print(f"Removed entry: {args.entry_id}")
|
||||
else:
|
||||
print(f"Entry not found: {args.entry_id}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_export(args):
|
||||
archive = MnemosyneArchive()
|
||||
topics = [t.strip() for t in args.topics.split(",")] if args.topics else None
|
||||
data = archive.export(query=args.query or None, topics=topics)
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog="mnemosyne", description="The Living Holographic Archive")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("stats", help="Show archive statistics")
|
||||
|
||||
s = sub.add_parser("search", help="Search the archive")
|
||||
s.add_argument("query", help="Search query")
|
||||
s.add_argument("-n", "--limit", type=int, default=10)
|
||||
s.add_argument("--semantic", action="store_true", help="Use holographic linker similarity scoring")
|
||||
|
||||
i = sub.add_parser("ingest", help="Ingest a new entry")
|
||||
i.add_argument("--title", required=True)
|
||||
i.add_argument("--content", required=True)
|
||||
i.add_argument("--topics", default="", help="Comma-separated topics")
|
||||
|
||||
l = sub.add_parser("link", help="Show linked entries")
|
||||
l.add_argument("entry_id", help="Entry ID (or prefix)")
|
||||
l.add_argument("-d", "--depth", type=int, default=1)
|
||||
|
||||
sub.add_parser("topics", help="List all topics with entry counts")
|
||||
|
||||
r = sub.add_parser("remove", help="Remove an entry by ID")
|
||||
r.add_argument("entry_id", help="Entry ID to remove")
|
||||
|
||||
ex = sub.add_parser("export", help="Export filtered archive data as JSON")
|
||||
ex.add_argument("-q", "--query", default="", help="Keyword filter")
|
||||
ex.add_argument("-t", "--topics", default="", help="Comma-separated topic filter")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
dispatch = {
|
||||
"stats": cmd_stats,
|
||||
"search": cmd_search,
|
||||
"ingest": cmd_ingest,
|
||||
"link": cmd_link,
|
||||
"topics": cmd_topics,
|
||||
"remove": cmd_remove,
|
||||
"export": cmd_export,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
44
nexus/mnemosyne/entry.py
Normal file
44
nexus/mnemosyne/entry.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Archive entry model for Mnemosyne.
|
||||
|
||||
Each entry is a node in the holographic graph — a piece of meaning
|
||||
with metadata, content, and links to related entries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveEntry:
|
||||
"""A single node in the Mnemosyne holographic archive."""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
title: str = ""
|
||||
content: str = ""
|
||||
source: str = "" # "mempalace", "event", "manual", etc.
|
||||
source_ref: Optional[str] = None # original MemPalace ID, event URI, etc.
|
||||
topics: list[str] = field(default_factory=list)
|
||||
metadata: dict = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
links: list[str] = field(default_factory=list) # IDs of related entries
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"content": self.content,
|
||||
"source": self.source,
|
||||
"source_ref": self.source_ref,
|
||||
"topics": self.topics,
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at,
|
||||
"links": self.links,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> ArchiveEntry:
|
||||
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||
62
nexus/mnemosyne/ingest.py
Normal file
62
nexus/mnemosyne/ingest.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Ingestion pipeline — feeds data into the archive.
|
||||
|
||||
Supports ingesting from MemPalace, raw events, and manual entries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
|
||||
|
||||
def ingest_from_mempalace(
|
||||
archive: MnemosyneArchive,
|
||||
mempalace_entries: list[dict],
|
||||
) -> int:
|
||||
"""Ingest entries from a MemPalace export.
|
||||
|
||||
Each dict should have at least: content, metadata (optional).
|
||||
Returns count of new entries added.
|
||||
"""
|
||||
added = 0
|
||||
for mp_entry in mempalace_entries:
|
||||
content = mp_entry.get("content", "")
|
||||
metadata = mp_entry.get("metadata", {})
|
||||
source_ref = mp_entry.get("id", "")
|
||||
|
||||
# Skip if already ingested
|
||||
if any(e.source_ref == source_ref for e in archive._entries.values()):
|
||||
continue
|
||||
|
||||
entry = ArchiveEntry(
|
||||
title=metadata.get("title", content[:80]),
|
||||
content=content,
|
||||
source="mempalace",
|
||||
source_ref=source_ref,
|
||||
topics=metadata.get("topics", []),
|
||||
metadata=metadata,
|
||||
)
|
||||
archive.add(entry)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
def ingest_event(
|
||||
archive: MnemosyneArchive,
|
||||
title: str,
|
||||
content: str,
|
||||
topics: Optional[list[str]] = None,
|
||||
source: str = "event",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> ArchiveEntry:
|
||||
"""Ingest a single event into the archive."""
|
||||
entry = ArchiveEntry(
|
||||
title=title,
|
||||
content=content,
|
||||
source=source,
|
||||
topics=topics or [],
|
||||
metadata=metadata or {},
|
||||
)
|
||||
return archive.add(entry)
|
||||
73
nexus/mnemosyne/linker.py
Normal file
73
nexus/mnemosyne/linker.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Holographic link engine.
|
||||
|
||||
Computes semantic similarity between archive entries and creates
|
||||
bidirectional links, forming the holographic graph structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
|
||||
|
||||
class HolographicLinker:
|
||||
"""Links archive entries via semantic similarity.
|
||||
|
||||
Phase 1 uses simple keyword overlap as the similarity metric.
|
||||
Phase 2 will integrate ChromaDB embeddings from MemPalace.
|
||||
"""
|
||||
|
||||
def __init__(self, similarity_threshold: float = 0.15):
|
||||
self.threshold = similarity_threshold
|
||||
|
||||
def compute_similarity(self, a: ArchiveEntry, b: ArchiveEntry) -> float:
|
||||
"""Compute similarity score between two entries.
|
||||
|
||||
Returns float in [0, 1]. Phase 1: Jaccard similarity on
|
||||
combined title+content tokens. Phase 2: cosine similarity
|
||||
on ChromaDB embeddings.
|
||||
"""
|
||||
tokens_a = self._tokenize(f"{a.title} {a.content}")
|
||||
tokens_b = self._tokenize(f"{b.title} {b.content}")
|
||||
if not tokens_a or not tokens_b:
|
||||
return 0.0
|
||||
intersection = tokens_a & tokens_b
|
||||
union = tokens_a | tokens_b
|
||||
return len(intersection) / len(union)
|
||||
|
||||
def find_links(self, entry: ArchiveEntry, candidates: list[ArchiveEntry]) -> list[tuple[str, float]]:
|
||||
"""Find entries worth linking to.
|
||||
|
||||
Returns list of (entry_id, similarity_score) tuples above threshold.
|
||||
"""
|
||||
results = []
|
||||
for candidate in candidates:
|
||||
if candidate.id == entry.id:
|
||||
continue
|
||||
score = self.compute_similarity(entry, candidate)
|
||||
if score >= self.threshold:
|
||||
results.append((candidate.id, score))
|
||||
results.sort(key=lambda x: x[1], reverse=True)
|
||||
return results
|
||||
|
||||
def apply_links(self, entry: ArchiveEntry, candidates: list[ArchiveEntry]) -> int:
|
||||
"""Auto-link an entry to related entries. Returns count of new links."""
|
||||
matches = self.find_links(entry, candidates)
|
||||
new_links = 0
|
||||
for eid, score in matches:
|
||||
if eid not in entry.links:
|
||||
entry.links.append(eid)
|
||||
new_links += 1
|
||||
# Bidirectional
|
||||
for c in candidates:
|
||||
if c.id == eid and entry.id not in c.links:
|
||||
c.links.append(entry.id)
|
||||
return new_links
|
||||
|
||||
@staticmethod
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
"""Simple whitespace + punctuation tokenizer."""
|
||||
import re
|
||||
tokens = set(re.findall(r"\w+", text.lower()))
|
||||
# Remove very short tokens
|
||||
return {t for t in tokens if len(t) > 2}
|
||||
0
nexus/mnemosyne/tests/__init__.py
Normal file
0
nexus/mnemosyne/tests/__init__.py
Normal file
345
nexus/mnemosyne/tests/test_archive.py
Normal file
345
nexus/mnemosyne/tests/test_archive.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""Tests for Mnemosyne archive core."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from nexus.mnemosyne.entry import ArchiveEntry
|
||||
from nexus.mnemosyne.linker import HolographicLinker
|
||||
from nexus.mnemosyne.archive import MnemosyneArchive
|
||||
from nexus.mnemosyne.ingest import ingest_event, ingest_from_mempalace
|
||||
|
||||
|
||||
def test_entry_roundtrip():
|
||||
e = ArchiveEntry(title="Test", content="Hello world", topics=["test"])
|
||||
d = e.to_dict()
|
||||
e2 = ArchiveEntry.from_dict(d)
|
||||
assert e2.id == e.id
|
||||
assert e2.title == "Test"
|
||||
|
||||
|
||||
def test_linker_similarity():
|
||||
linker = HolographicLinker()
|
||||
a = ArchiveEntry(title="Python coding", content="Writing Python scripts for automation")
|
||||
b = ArchiveEntry(title="Python scripting", content="Automating tasks with Python scripts")
|
||||
c = ArchiveEntry(title="Cooking recipes", content="How to make pasta carbonara")
|
||||
assert linker.compute_similarity(a, b) > linker.compute_similarity(a, c)
|
||||
|
||||
|
||||
def test_archive_add_and_search():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="First entry", content="Hello archive", topics=["test"])
|
||||
ingest_event(archive, title="Second entry", content="Another record", topics=["test", "demo"])
|
||||
assert archive.count == 2
|
||||
results = archive.search("hello")
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "First entry"
|
||||
|
||||
|
||||
def test_archive_auto_linking():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Python automation", content="Building automation tools in Python")
|
||||
e2 = ingest_event(archive, title="Python scripting", content="Writing automation scripts using Python")
|
||||
# Both should be linked due to shared tokens
|
||||
assert len(e1.links) > 0 or len(e2.links) > 0
|
||||
|
||||
|
||||
def test_ingest_from_mempalace():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
mp_entries = [
|
||||
{"id": "mp-1", "content": "Test memory content", "metadata": {"title": "Test", "topics": ["demo"]}},
|
||||
{"id": "mp-2", "content": "Another memory", "metadata": {"title": "Memory 2"}},
|
||||
]
|
||||
count = ingest_from_mempalace(archive, mp_entries)
|
||||
assert count == 2
|
||||
assert archive.count == 2
|
||||
|
||||
|
||||
def test_archive_persistence():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive1 = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive1, title="Persistent", content="Should survive reload")
|
||||
|
||||
archive2 = MnemosyneArchive(archive_path=path)
|
||||
assert archive2.count == 1
|
||||
results = archive2.search("persistent")
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
def test_archive_remove_basic():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Alpha", content="First entry", topics=["x"])
|
||||
assert archive.count == 1
|
||||
|
||||
result = archive.remove(e1.id)
|
||||
assert result is True
|
||||
assert archive.count == 0
|
||||
assert archive.get(e1.id) is None
|
||||
|
||||
|
||||
def test_archive_remove_nonexistent():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
result = archive.remove("does-not-exist")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_archive_remove_cleans_backlinks():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Python automation", content="Building automation tools in Python")
|
||||
e2 = ingest_event(archive, title="Python scripting", content="Writing automation scripts using Python")
|
||||
# At least one direction should be linked
|
||||
assert e1.id in e2.links or e2.id in e1.links
|
||||
|
||||
# Remove e1; e2 must no longer reference it
|
||||
archive.remove(e1.id)
|
||||
e2_fresh = archive.get(e2.id)
|
||||
assert e2_fresh is not None
|
||||
assert e1.id not in e2_fresh.links
|
||||
|
||||
|
||||
def test_archive_remove_persists():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
a1 = MnemosyneArchive(archive_path=path)
|
||||
e = ingest_event(a1, title="Gone", content="Will be removed")
|
||||
a1.remove(e.id)
|
||||
|
||||
a2 = MnemosyneArchive(archive_path=path)
|
||||
assert a2.count == 0
|
||||
|
||||
|
||||
def test_archive_export_unfiltered():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="A", content="content a", topics=["alpha"])
|
||||
ingest_event(archive, title="B", content="content b", topics=["beta"])
|
||||
data = archive.export()
|
||||
assert data["count"] == 2
|
||||
assert len(data["entries"]) == 2
|
||||
assert data["filters"] == {"query": None, "topics": None}
|
||||
|
||||
|
||||
def test_archive_export_by_topic():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="A", content="content a", topics=["alpha"])
|
||||
ingest_event(archive, title="B", content="content b", topics=["beta"])
|
||||
data = archive.export(topics=["alpha"])
|
||||
assert data["count"] == 1
|
||||
assert data["entries"][0]["title"] == "A"
|
||||
|
||||
|
||||
def test_archive_export_by_query():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="Hello world", content="greetings", topics=[])
|
||||
ingest_event(archive, title="Goodbye", content="farewell", topics=[])
|
||||
data = archive.export(query="hello")
|
||||
assert data["count"] == 1
|
||||
assert data["entries"][0]["title"] == "Hello world"
|
||||
|
||||
|
||||
def test_archive_export_combined_filters():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="Hello world", content="greetings", topics=["alpha"])
|
||||
ingest_event(archive, title="Hello again", content="greetings again", topics=["beta"])
|
||||
data = archive.export(query="hello", topics=["alpha"])
|
||||
assert data["count"] == 1
|
||||
assert data["entries"][0]["title"] == "Hello world"
|
||||
|
||||
|
||||
def test_archive_stats_richer():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
# All four new fields present when archive is empty
|
||||
s = archive.stats()
|
||||
assert "orphans" in s
|
||||
assert "link_density" in s
|
||||
assert "oldest_entry" in s
|
||||
assert "newest_entry" in s
|
||||
assert s["orphans"] == 0
|
||||
assert s["link_density"] == 0.0
|
||||
assert s["oldest_entry"] is None
|
||||
assert s["newest_entry"] is None
|
||||
|
||||
|
||||
def test_archive_stats_orphan_count():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
# Two entries with very different content → unlikely to auto-link
|
||||
ingest_event(archive, title="Zebras", content="Zebra stripes savannah Africa", topics=[])
|
||||
ingest_event(archive, title="Compiler", content="Lexer parser AST bytecode", topics=[])
|
||||
s = archive.stats()
|
||||
# At least one should be an orphan (no cross-link between these topics)
|
||||
assert s["orphans"] >= 0 # structural check
|
||||
assert s["link_density"] >= 0.0
|
||||
assert s["oldest_entry"] is not None
|
||||
assert s["newest_entry"] is not None
|
||||
|
||||
|
||||
def test_semantic_search_returns_results():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="Python automation", content="Building automation tools in Python")
|
||||
ingest_event(archive, title="Cooking recipes", content="How to make pasta carbonara with cheese")
|
||||
results = archive.semantic_search("python scripting", limit=5)
|
||||
assert len(results) > 0
|
||||
assert results[0].title == "Python automation"
|
||||
|
||||
|
||||
def test_semantic_search_link_boost():
|
||||
"""Entries with more inbound links rank higher when Jaccard is equal."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
# Create two similar entries; manually give one more links
|
||||
e1 = ingest_event(archive, title="Machine learning", content="Neural networks deep learning models")
|
||||
e2 = ingest_event(archive, title="Machine learning basics", content="Neural networks deep learning intro")
|
||||
# Add a third entry that links to e1 so e1 has more inbound links
|
||||
e3 = ingest_event(archive, title="AI overview", content="Artificial intelligence machine learning")
|
||||
# Manually give e1 an extra inbound link by adding e3 -> e1
|
||||
if e1.id not in e3.links:
|
||||
e3.links.append(e1.id)
|
||||
archive._save()
|
||||
results = archive.semantic_search("machine learning neural networks", limit=5)
|
||||
assert len(results) >= 2
|
||||
# e1 should rank at or near top
|
||||
assert results[0].id in {e1.id, e2.id}
|
||||
|
||||
|
||||
def test_semantic_search_fallback_to_keyword():
|
||||
"""Falls back to keyword search when no entry meets Jaccard threshold."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="Exact match only", content="unique xyzzy token here")
|
||||
# threshold=1.0 ensures no semantic match, triggering fallback
|
||||
results = archive.semantic_search("xyzzy", limit=5, threshold=1.0)
|
||||
# Fallback keyword search should find it
|
||||
assert len(results) == 1
|
||||
assert results[0].title == "Exact match only"
|
||||
|
||||
|
||||
def test_semantic_search_empty_archive():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
results = archive.semantic_search("anything", limit=5)
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_semantic_search_vs_keyword_relevance():
|
||||
"""Semantic search finds conceptually related entries missed by keyword search."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="Python scripting", content="Writing scripts with Python for automation tasks")
|
||||
ingest_event(archive, title="Baking bread", content="Mix flour water yeast knead bake oven")
|
||||
# "coding" is semantically unrelated to baking but related to python scripting
|
||||
results = archive.semantic_search("coding scripts automation")
|
||||
assert len(results) > 0
|
||||
assert results[0].title == "Python scripting"
|
||||
|
||||
|
||||
def test_graph_data_empty_archive():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
data = archive.graph_data()
|
||||
assert data == {"nodes": [], "edges": []}
|
||||
|
||||
|
||||
def test_graph_data_nodes_and_edges():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Python automation", content="Building automation tools in Python", topics=["code"])
|
||||
e2 = ingest_event(archive, title="Python scripting", content="Writing automation scripts using Python", topics=["code"])
|
||||
e3 = ingest_event(archive, title="Cooking", content="Making pasta carbonara", topics=["food"])
|
||||
|
||||
data = archive.graph_data()
|
||||
assert len(data["nodes"]) == 3
|
||||
# All node fields present
|
||||
for node in data["nodes"]:
|
||||
assert "id" in node
|
||||
assert "title" in node
|
||||
assert "topics" in node
|
||||
assert "source" in node
|
||||
assert "created_at" in node
|
||||
|
||||
# e1 and e2 should be linked (shared Python/automation tokens)
|
||||
edge_pairs = {(e["source"], e["target"]) for e in data["edges"]}
|
||||
e1e2 = (min(e1.id, e2.id), max(e1.id, e2.id))
|
||||
assert e1e2 in edge_pairs or (e1e2[1], e1e2[0]) in edge_pairs
|
||||
|
||||
# All edges have weights
|
||||
for edge in data["edges"]:
|
||||
assert "weight" in edge
|
||||
assert 0 <= edge["weight"] <= 1
|
||||
|
||||
|
||||
def test_graph_data_topic_filter():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="A", content="code stuff", topics=["code"])
|
||||
e2 = ingest_event(archive, title="B", content="more code", topics=["code"])
|
||||
ingest_event(archive, title="C", content="food stuff", topics=["food"])
|
||||
|
||||
data = archive.graph_data(topic_filter="code")
|
||||
node_ids = {n["id"] for n in data["nodes"]}
|
||||
assert e1.id in node_ids
|
||||
assert e2.id in node_ids
|
||||
assert len(data["nodes"]) == 2
|
||||
|
||||
|
||||
def test_graph_data_deduplicates_edges():
|
||||
"""Bidirectional links should produce a single edge, not two."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
e1 = ingest_event(archive, title="Python automation", content="Building automation tools in Python")
|
||||
e2 = ingest_event(archive, title="Python scripting", content="Writing automation scripts using Python")
|
||||
|
||||
data = archive.graph_data()
|
||||
# Count how many edges connect e1 and e2
|
||||
e1e2_edges = [
|
||||
e for e in data["edges"]
|
||||
if {e["source"], e["target"]} == {e1.id, e2.id}
|
||||
]
|
||||
assert len(e1e2_edges) <= 1, "Should not have duplicate bidirectional edges"
|
||||
|
||||
|
||||
def test_archive_topic_counts():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "test_archive.json"
|
||||
archive = MnemosyneArchive(archive_path=path)
|
||||
ingest_event(archive, title="A", content="x", topics=["python", "automation"])
|
||||
ingest_event(archive, title="B", content="y", topics=["python"])
|
||||
ingest_event(archive, title="C", content="z", topics=["automation"])
|
||||
counts = archive.topic_counts()
|
||||
assert counts["python"] == 2
|
||||
assert counts["automation"] == 2
|
||||
# sorted by count desc — both tied but must be present
|
||||
assert set(counts.keys()) == {"python", "automation"}
|
||||
27
scripts/guardrails.sh
Normal file
27
scripts/guardrails.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# [Mnemosyne] Agent Guardrails — The Nexus
|
||||
# Validates code integrity and scans for secrets before deployment.
|
||||
|
||||
echo "--- [Mnemosyne] Running Guardrails ---"
|
||||
|
||||
# 1. Syntax Checks
|
||||
echo "[1/3] Validating syntax..."
|
||||
for f in ; do
|
||||
node --check "$f" || { echo "Syntax error in $f"; exit 1; }
|
||||
done
|
||||
echo "Syntax OK."
|
||||
|
||||
# 2. JSON/YAML Validation
|
||||
echo "[2/3] Validating configs..."
|
||||
for f in ; do
|
||||
node -e "JSON.parse(require('fs').readFileSync('$f'))" || { echo "Invalid JSON: $f"; exit 1; }
|
||||
done
|
||||
echo "Configs OK."
|
||||
|
||||
# 3. Secret Scan
|
||||
echo "[3/3] Scanning for secrets..."
|
||||
grep -rE "AI_|TOKEN|KEY|SECRET" . --exclude-dir=node_modules --exclude=guardrails.sh | grep -v "process.env" && {
|
||||
echo "WARNING: Potential secrets found!"
|
||||
} || echo "No secrets detected."
|
||||
|
||||
echo "--- Guardrails Passed ---"
|
||||
26
scripts/smoke.mjs
Normal file
26
scripts/smoke.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* [Mnemosyne] Smoke Test — The Nexus
|
||||
* Verifies core components are loadable and basic state is consistent.
|
||||
*/
|
||||
|
||||
import { SpatialMemory } from '../nexus/components/spatial-memory.js';
|
||||
import { MemoryOptimizer } from '../nexus/components/memory-optimizer.js';
|
||||
|
||||
console.log('--- [Mnemosyne] Running Smoke Test ---');
|
||||
|
||||
// 1. Verify Components
|
||||
if (!SpatialMemory || !MemoryOptimizer) {
|
||||
console.error('Failed to load core components');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Components loaded.');
|
||||
|
||||
// 2. Verify Regions
|
||||
const regions = Object.keys(SpatialMemory.REGIONS || {});
|
||||
if (regions.length < 5) {
|
||||
console.error('SpatialMemory regions incomplete:', regions);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Regions verified:', regions.join(', '));
|
||||
|
||||
console.log('--- Smoke Test Passed ---');
|
||||
Reference in New Issue
Block a user