Compare commits
1 Commits
queue/1124
...
nexusburn/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7924fa3b10 |
@@ -1,21 +0,0 @@
|
||||
"""
|
||||
agent — Cross-session agent memory and lifecycle hooks.
|
||||
|
||||
Provides persistent memory for agents via MemPalace integration.
|
||||
Agents recall context at session start and write diary entries at session end.
|
||||
|
||||
Modules:
|
||||
memory.py — AgentMemory class (recall, remember, diary)
|
||||
memory_hooks.py — Session lifecycle hooks (drop-in integration)
|
||||
"""
|
||||
|
||||
from agent.memory import AgentMemory, MemoryContext, SessionTranscript, create_agent_memory
|
||||
from agent.memory_hooks import MemoryHooks
|
||||
|
||||
__all__ = [
|
||||
"AgentMemory",
|
||||
"MemoryContext",
|
||||
"MemoryHooks",
|
||||
"SessionTranscript",
|
||||
"create_agent_memory",
|
||||
]
|
||||
396
agent/memory.py
396
agent/memory.py
@@ -1,396 +0,0 @@
|
||||
"""
|
||||
agent.memory — Cross-session agent memory via MemPalace.
|
||||
|
||||
Gives agents persistent memory across sessions. On wake-up, agents
|
||||
recall relevant context from past sessions. On session end, they
|
||||
write a diary entry summarizing what happened.
|
||||
|
||||
Architecture:
|
||||
Session Start → memory.recall_context() → inject L0/L1 into prompt
|
||||
During Session → memory.remember() → store important facts
|
||||
Session End → memory.write_diary() → summarize session
|
||||
|
||||
All operations degrade gracefully — if MemPalace is unavailable,
|
||||
the agent continues without memory and logs a warning.
|
||||
|
||||
Usage:
|
||||
from agent.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(agent_name="bezalel", wing="wing_bezalel")
|
||||
|
||||
# Session start — load context
|
||||
context = mem.recall_context("What was I working on last time?")
|
||||
|
||||
# During session — store important decisions
|
||||
mem.remember("Switched CI runner from GitHub Actions to self-hosted", room="forge")
|
||||
|
||||
# Session end — write diary
|
||||
mem.write_diary("Fixed PR #1386, reconciled fleet registry locations")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("agent.memory")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryContext:
|
||||
"""Context loaded at session start from MemPalace."""
|
||||
relevant_memories: list[dict] = field(default_factory=list)
|
||||
recent_diaries: list[dict] = field(default_factory=list)
|
||||
facts: list[dict] = field(default_factory=list)
|
||||
loaded: bool = False
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_prompt_block(self) -> str:
|
||||
"""Format context as a text block to inject into the agent prompt."""
|
||||
if not self.loaded:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
|
||||
if self.recent_diaries:
|
||||
parts.append("=== Recent Session Summaries ===")
|
||||
for d in self.recent_diaries[:3]:
|
||||
ts = d.get("timestamp", "")
|
||||
text = d.get("text", "")
|
||||
parts.append(f"[{ts}] {text[:500]}")
|
||||
|
||||
if self.facts:
|
||||
parts.append("\n=== Known Facts ===")
|
||||
for f in self.facts[:10]:
|
||||
text = f.get("text", "")
|
||||
parts.append(f"- {text[:200]}")
|
||||
|
||||
if self.relevant_memories:
|
||||
parts.append("\n=== Relevant Past Memories ===")
|
||||
for m in self.relevant_memories[:5]:
|
||||
text = m.get("text", "")
|
||||
score = m.get("score", 0)
|
||||
parts.append(f"[{score:.2f}] {text[:300]}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionTranscript:
|
||||
"""A running log of the current session for diary writing."""
|
||||
agent_name: str
|
||||
wing: str
|
||||
started_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
entries: list[dict] = field(default_factory=list)
|
||||
|
||||
def add_user_turn(self, text: str):
|
||||
self.entries.append({
|
||||
"role": "user",
|
||||
"text": text[:2000],
|
||||
"ts": time.time(),
|
||||
})
|
||||
|
||||
def add_agent_turn(self, text: str):
|
||||
self.entries.append({
|
||||
"role": "agent",
|
||||
"text": text[:2000],
|
||||
"ts": time.time(),
|
||||
})
|
||||
|
||||
def add_tool_call(self, tool: str, args: str, result_summary: str):
|
||||
self.entries.append({
|
||||
"role": "tool",
|
||||
"tool": tool,
|
||||
"args": args[:500],
|
||||
"result": result_summary[:500],
|
||||
"ts": time.time(),
|
||||
})
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Generate a compact transcript summary."""
|
||||
if not self.entries:
|
||||
return "Empty session."
|
||||
|
||||
turns = []
|
||||
for e in self.entries[-20:]: # last 20 entries
|
||||
role = e["role"]
|
||||
if role == "user":
|
||||
turns.append(f"USER: {e['text'][:200]}")
|
||||
elif role == "agent":
|
||||
turns.append(f"AGENT: {e['text'][:200]}")
|
||||
elif role == "tool":
|
||||
turns.append(f"TOOL({e.get('tool','')}): {e.get('result','')[:150]}")
|
||||
|
||||
return "\n".join(turns)
|
||||
|
||||
|
||||
class AgentMemory:
|
||||
"""
|
||||
Cross-session memory for an agent.
|
||||
|
||||
Wraps MemPalace with agent-specific conventions:
|
||||
- Each agent has a wing (e.g., "wing_bezalel")
|
||||
- Session summaries go in the "hermes" room
|
||||
- Important decisions go in room-specific closets
|
||||
- Facts go in the "nexus" room
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: str,
|
||||
wing: Optional[str] = None,
|
||||
palace_path: Optional[Path] = None,
|
||||
):
|
||||
self.agent_name = agent_name
|
||||
self.wing = wing or f"wing_{agent_name}"
|
||||
self.palace_path = palace_path
|
||||
self._transcript: Optional[SessionTranscript] = None
|
||||
self._available: Optional[bool] = None
|
||||
|
||||
def _check_available(self) -> bool:
|
||||
"""Check if MemPalace is accessible."""
|
||||
if self._available is not None:
|
||||
return self._available
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import search_memories, add_memory, _get_client
|
||||
from nexus.mempalace.config import MEMPALACE_PATH
|
||||
|
||||
path = self.palace_path or MEMPALACE_PATH
|
||||
_get_client(path)
|
||||
self._available = True
|
||||
logger.info(f"MemPalace available at {path}")
|
||||
except Exception as e:
|
||||
self._available = False
|
||||
logger.warning(f"MemPalace unavailable: {e}")
|
||||
|
||||
return self._available
|
||||
|
||||
def recall_context(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
n_results: int = 5,
|
||||
) -> MemoryContext:
|
||||
"""
|
||||
Load relevant context from past sessions.
|
||||
|
||||
Called at session start to inject L0/L1 memory into the prompt.
|
||||
|
||||
Args:
|
||||
query: What to search for. If None, loads recent diary entries.
|
||||
n_results: Max memories to recall.
|
||||
"""
|
||||
ctx = MemoryContext()
|
||||
|
||||
if not self._check_available():
|
||||
ctx.error = "MemPalace unavailable"
|
||||
return ctx
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import search_memories
|
||||
|
||||
# Load recent diary entries (session summaries)
|
||||
ctx.recent_diaries = [
|
||||
{"text": r.text, "score": r.score, "timestamp": r.metadata.get("timestamp", "")}
|
||||
for r in search_memories(
|
||||
"session summary",
|
||||
palace_path=self.palace_path,
|
||||
wing=self.wing,
|
||||
room="hermes",
|
||||
n_results=3,
|
||||
)
|
||||
]
|
||||
|
||||
# Load known facts
|
||||
ctx.facts = [
|
||||
{"text": r.text, "score": r.score}
|
||||
for r in search_memories(
|
||||
"important facts decisions",
|
||||
palace_path=self.palace_path,
|
||||
wing=self.wing,
|
||||
room="nexus",
|
||||
n_results=5,
|
||||
)
|
||||
]
|
||||
|
||||
# Search for relevant memories if query provided
|
||||
if query:
|
||||
ctx.relevant_memories = [
|
||||
{"text": r.text, "score": r.score, "room": r.room}
|
||||
for r in search_memories(
|
||||
query,
|
||||
palace_path=self.palace_path,
|
||||
wing=self.wing,
|
||||
n_results=n_results,
|
||||
)
|
||||
]
|
||||
|
||||
ctx.loaded = True
|
||||
|
||||
except Exception as e:
|
||||
ctx.error = str(e)
|
||||
logger.warning(f"Failed to recall context: {e}")
|
||||
|
||||
return ctx
|
||||
|
||||
def remember(
|
||||
self,
|
||||
text: str,
|
||||
room: str = "nexus",
|
||||
source_file: str = "",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Store a memory.
|
||||
|
||||
Args:
|
||||
text: The memory content.
|
||||
room: Target room (forge, hermes, nexus, issues, experiments).
|
||||
source_file: Optional source attribution.
|
||||
metadata: Extra metadata.
|
||||
|
||||
Returns:
|
||||
Document ID if stored, None if MemPalace unavailable.
|
||||
"""
|
||||
if not self._check_available():
|
||||
logger.warning("Cannot store memory — MemPalace unavailable")
|
||||
return None
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import add_memory
|
||||
|
||||
doc_id = add_memory(
|
||||
text=text,
|
||||
room=room,
|
||||
wing=self.wing,
|
||||
palace_path=self.palace_path,
|
||||
source_file=source_file,
|
||||
extra_metadata=metadata or {},
|
||||
)
|
||||
logger.debug(f"Stored memory in {room}: {text[:80]}...")
|
||||
return doc_id
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store memory: {e}")
|
||||
return None
|
||||
|
||||
def write_diary(
|
||||
self,
|
||||
summary: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Write a session diary entry to MemPalace.
|
||||
|
||||
Called at session end. If summary is None, auto-generates one
|
||||
from the session transcript.
|
||||
|
||||
Args:
|
||||
summary: Override summary text. If None, generates from transcript.
|
||||
|
||||
Returns:
|
||||
Document ID if stored, None if unavailable.
|
||||
"""
|
||||
if summary is None and self._transcript:
|
||||
summary = self._transcript.summary()
|
||||
|
||||
if not summary:
|
||||
return None
|
||||
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
diary_text = f"[{timestamp}] Session by {self.agent_name}:\n{summary}"
|
||||
|
||||
return self.remember(
|
||||
diary_text,
|
||||
room="hermes",
|
||||
metadata={
|
||||
"type": "session_diary",
|
||||
"agent": self.agent_name,
|
||||
"timestamp": timestamp,
|
||||
"entry_count": len(self._transcript.entries) if self._transcript else 0,
|
||||
},
|
||||
)
|
||||
|
||||
def start_session(self) -> SessionTranscript:
|
||||
"""
|
||||
Begin a new session transcript.
|
||||
|
||||
Returns the transcript object for recording turns.
|
||||
"""
|
||||
self._transcript = SessionTranscript(
|
||||
agent_name=self.agent_name,
|
||||
wing=self.wing,
|
||||
)
|
||||
logger.info(f"Session started for {self.agent_name}")
|
||||
return self._transcript
|
||||
|
||||
def end_session(self, diary_summary: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
End the current session, write diary, return diary doc ID.
|
||||
"""
|
||||
doc_id = self.write_diary(diary_summary)
|
||||
self._transcript = None
|
||||
logger.info(f"Session ended for {self.agent_name}")
|
||||
return doc_id
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
room: Optional[str] = None,
|
||||
n_results: int = 5,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Search memories. Useful during a session for recall.
|
||||
|
||||
Returns list of {text, room, wing, score} dicts.
|
||||
"""
|
||||
if not self._check_available():
|
||||
return []
|
||||
|
||||
try:
|
||||
from nexus.mempalace.searcher import search_memories
|
||||
|
||||
results = search_memories(
|
||||
query,
|
||||
palace_path=self.palace_path,
|
||||
wing=self.wing,
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
)
|
||||
return [
|
||||
{"text": r.text, "room": r.room, "wing": r.wing, "score": r.score}
|
||||
for r in results
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Search failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# --- Fleet-wide memory helpers ---
|
||||
|
||||
def create_agent_memory(
|
||||
agent_name: str,
|
||||
palace_path: Optional[Path] = None,
|
||||
) -> AgentMemory:
|
||||
"""
|
||||
Factory for creating AgentMemory with standard config.
|
||||
|
||||
Reads wing from MEMPALACE_WING env or defaults to wing_{agent_name}.
|
||||
"""
|
||||
wing = os.environ.get("MEMPALACE_WING", f"wing_{agent_name}")
|
||||
return AgentMemory(
|
||||
agent_name=agent_name,
|
||||
wing=wing,
|
||||
palace_path=palace_path,
|
||||
)
|
||||
@@ -1,183 +0,0 @@
|
||||
"""
|
||||
agent.memory_hooks — Session lifecycle hooks for agent memory.
|
||||
|
||||
Integrates AgentMemory into the agent session lifecycle:
|
||||
- on_session_start: Load context, inject into prompt
|
||||
- on_user_turn: Record user input
|
||||
- on_agent_turn: Record agent output
|
||||
- on_tool_call: Record tool usage
|
||||
- on_session_end: Write diary, clean up
|
||||
|
||||
These hooks are designed to be called from the Hermes harness or
|
||||
any agent framework. They're fire-and-forget — failures are logged
|
||||
but never crash the session.
|
||||
|
||||
Usage:
|
||||
from agent.memory_hooks import MemoryHooks
|
||||
|
||||
hooks = MemoryHooks(agent_name="bezalel")
|
||||
hooks.on_session_start() # loads context
|
||||
|
||||
# In your agent loop:
|
||||
hooks.on_user_turn("Check CI pipeline health")
|
||||
hooks.on_agent_turn("Running CI check...")
|
||||
hooks.on_tool_call("shell", "pytest tests/", "12 passed")
|
||||
|
||||
# End of session:
|
||||
hooks.on_session_end() # writes diary
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from agent.memory import AgentMemory, MemoryContext, create_agent_memory
|
||||
|
||||
logger = logging.getLogger("agent.memory_hooks")
|
||||
|
||||
|
||||
class MemoryHooks:
|
||||
"""
|
||||
Drop-in session lifecycle hooks for agent memory.
|
||||
|
||||
Wraps AgentMemory with error boundaries — every hook catches
|
||||
exceptions and logs warnings so memory failures never crash
|
||||
the agent session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: str,
|
||||
palace_path=None,
|
||||
auto_diary: bool = True,
|
||||
):
|
||||
self.agent_name = agent_name
|
||||
self.auto_diary = auto_diary
|
||||
self._memory: Optional[AgentMemory] = None
|
||||
self._context: Optional[MemoryContext] = None
|
||||
self._active = False
|
||||
|
||||
@property
|
||||
def memory(self) -> AgentMemory:
|
||||
if self._memory is None:
|
||||
self._memory = create_agent_memory(
|
||||
self.agent_name,
|
||||
palace_path=getattr(self, '_palace_path', None),
|
||||
)
|
||||
return self._memory
|
||||
|
||||
def on_session_start(self, query: Optional[str] = None) -> str:
|
||||
"""
|
||||
Called at session start. Loads context from MemPalace.
|
||||
|
||||
Returns a prompt block to inject into the agent's context, or
|
||||
empty string if memory is unavailable.
|
||||
|
||||
Args:
|
||||
query: Optional recall query (e.g., "What was I working on?")
|
||||
"""
|
||||
try:
|
||||
self.memory.start_session()
|
||||
self._active = True
|
||||
|
||||
self._context = self.memory.recall_context(query=query)
|
||||
block = self._context.to_prompt_block()
|
||||
|
||||
if block:
|
||||
logger.info(
|
||||
f"Loaded {len(self._context.recent_diaries)} diaries, "
|
||||
f"{len(self._context.facts)} facts, "
|
||||
f"{len(self._context.relevant_memories)} relevant memories "
|
||||
f"for {self.agent_name}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"No prior memory for {self.agent_name}")
|
||||
|
||||
return block
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Session start memory hook failed: {e}")
|
||||
return ""
|
||||
|
||||
def on_user_turn(self, text: str):
|
||||
"""Record a user message."""
|
||||
if not self._active:
|
||||
return
|
||||
try:
|
||||
if self.memory._transcript:
|
||||
self.memory._transcript.add_user_turn(text)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to record user turn: {e}")
|
||||
|
||||
def on_agent_turn(self, text: str):
|
||||
"""Record an agent response."""
|
||||
if not self._active:
|
||||
return
|
||||
try:
|
||||
if self.memory._transcript:
|
||||
self.memory._transcript.add_agent_turn(text)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to record agent turn: {e}")
|
||||
|
||||
def on_tool_call(self, tool: str, args: str, result_summary: str):
|
||||
"""Record a tool invocation."""
|
||||
if not self._active:
|
||||
return
|
||||
try:
|
||||
if self.memory._transcript:
|
||||
self.memory._transcript.add_tool_call(tool, args, result_summary)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to record tool call: {e}")
|
||||
|
||||
def on_important_decision(self, text: str, room: str = "nexus"):
|
||||
"""
|
||||
Record an important decision or fact for long-term memory.
|
||||
|
||||
Use this when the agent makes a significant decision that
|
||||
should persist beyond the current session.
|
||||
"""
|
||||
try:
|
||||
self.memory.remember(text, room=room, metadata={"type": "decision"})
|
||||
logger.info(f"Remembered decision: {text[:80]}...")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remember decision: {e}")
|
||||
|
||||
def on_session_end(self, summary: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Called at session end. Writes diary entry.
|
||||
|
||||
Args:
|
||||
summary: Override diary text. If None, auto-generates.
|
||||
|
||||
Returns:
|
||||
Diary document ID, or None.
|
||||
"""
|
||||
if not self._active:
|
||||
return None
|
||||
|
||||
try:
|
||||
doc_id = self.memory.end_session(diary_summary=summary)
|
||||
self._active = False
|
||||
self._context = None
|
||||
return doc_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Session end memory hook failed: {e}")
|
||||
self._active = False
|
||||
return None
|
||||
|
||||
def search(self, query: str, room: Optional[str] = None) -> list[dict]:
|
||||
"""
|
||||
Search memories during a session.
|
||||
|
||||
Returns list of {text, room, wing, score}.
|
||||
"""
|
||||
try:
|
||||
return self.memory.search(query, room=room)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory search failed: {e}")
|
||||
return []
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self._active
|
||||
@@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
memory_mine.py — Mine session transcripts into MemPalace.
|
||||
|
||||
Reads Hermes session logs (JSONL format) and stores summaries
|
||||
in the palace. Supports batch mining, single-file processing,
|
||||
and live directory watching.
|
||||
|
||||
Usage:
|
||||
# Mine a single session file
|
||||
python3 bin/memory_mine.py ~/.hermes/sessions/2026-04-13.jsonl
|
||||
|
||||
# Mine all sessions from last 7 days
|
||||
python3 bin/memory_mine.py --days 7
|
||||
|
||||
# Mine a specific wing's sessions
|
||||
python3 bin/memory_mine.py --wing wing_bezalel --days 14
|
||||
|
||||
# Dry run — show what would be mined
|
||||
python3 bin/memory_mine.py --dry-run --days 7
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("memory-mine")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
def parse_session_file(path: Path) -> list[dict]:
|
||||
"""
|
||||
Parse a JSONL session file into turns.
|
||||
|
||||
Each line is expected to be a JSON object with:
|
||||
- role: "user" | "assistant" | "system" | "tool"
|
||||
- content: text
|
||||
- timestamp: ISO string (optional)
|
||||
"""
|
||||
turns = []
|
||||
with open(path) as f:
|
||||
for i, line in enumerate(f):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
turn = json.loads(line)
|
||||
turns.append(turn)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"Skipping malformed line {i+1} in {path}")
|
||||
return turns
|
||||
|
||||
|
||||
def summarize_session(turns: list[dict], agent_name: str = "unknown") -> str:
|
||||
"""
|
||||
Generate a compact summary of a session's turns.
|
||||
|
||||
Keeps user messages and key agent responses, strips noise.
|
||||
"""
|
||||
if not turns:
|
||||
return "Empty session."
|
||||
|
||||
user_msgs = []
|
||||
agent_msgs = []
|
||||
tool_calls = []
|
||||
|
||||
for turn in turns:
|
||||
role = turn.get("role", "")
|
||||
content = str(turn.get("content", ""))[:300]
|
||||
|
||||
if role == "user":
|
||||
user_msgs.append(content)
|
||||
elif role == "assistant":
|
||||
agent_msgs.append(content)
|
||||
elif role == "tool":
|
||||
tool_name = turn.get("name", turn.get("tool", "unknown"))
|
||||
tool_calls.append(f"{tool_name}: {content[:150]}")
|
||||
|
||||
parts = [f"Session by {agent_name}:"]
|
||||
|
||||
if user_msgs:
|
||||
parts.append(f"\nUser asked ({len(user_msgs)} messages):")
|
||||
for msg in user_msgs[:5]:
|
||||
parts.append(f" - {msg[:200]}")
|
||||
if len(user_msgs) > 5:
|
||||
parts.append(f" ... and {len(user_msgs) - 5} more")
|
||||
|
||||
if agent_msgs:
|
||||
parts.append(f"\nAgent responded ({len(agent_msgs)} messages):")
|
||||
for msg in agent_msgs[:3]:
|
||||
parts.append(f" - {msg[:200]}")
|
||||
|
||||
if tool_calls:
|
||||
parts.append(f"\nTools used ({len(tool_calls)} calls):")
|
||||
for tc in tool_calls[:5]:
|
||||
parts.append(f" - {tc}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def mine_session(
|
||||
path: Path,
|
||||
wing: str,
|
||||
palace_path: Optional[Path] = None,
|
||||
dry_run: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Mine a single session file into MemPalace.
|
||||
|
||||
Returns the document ID if stored, None on failure or dry run.
|
||||
"""
|
||||
try:
|
||||
from agent.memory import AgentMemory
|
||||
except ImportError:
|
||||
logger.error("Cannot import agent.memory — is the repo in PYTHONPATH?")
|
||||
return None
|
||||
|
||||
turns = parse_session_file(path)
|
||||
if not turns:
|
||||
logger.debug(f"Empty session file: {path}")
|
||||
return None
|
||||
|
||||
agent_name = wing.replace("wing_", "")
|
||||
summary = summarize_session(turns, agent_name)
|
||||
|
||||
if dry_run:
|
||||
print(f"\n--- {path.name} ---")
|
||||
print(summary[:500])
|
||||
print(f"({len(turns)} turns)")
|
||||
return None
|
||||
|
||||
mem = AgentMemory(agent_name=agent_name, wing=wing, palace_path=palace_path)
|
||||
doc_id = mem.remember(
|
||||
summary,
|
||||
room="hermes",
|
||||
source_file=str(path),
|
||||
metadata={
|
||||
"type": "mined_session",
|
||||
"source": str(path),
|
||||
"turn_count": len(turns),
|
||||
"agent": agent_name,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
if doc_id:
|
||||
logger.info(f"Mined {path.name} → {doc_id} ({len(turns)} turns)")
|
||||
else:
|
||||
logger.warning(f"Failed to mine {path.name}")
|
||||
|
||||
return doc_id
|
||||
|
||||
|
||||
def find_session_files(
|
||||
sessions_dir: Path,
|
||||
days: int = 7,
|
||||
pattern: str = "*.jsonl",
|
||||
) -> list[Path]:
|
||||
"""
|
||||
Find session files from the last N days.
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
files = []
|
||||
|
||||
if not sessions_dir.exists():
|
||||
logger.warning(f"Sessions directory not found: {sessions_dir}")
|
||||
return files
|
||||
|
||||
for path in sorted(sessions_dir.glob(pattern)):
|
||||
# Use file modification time as proxy for session date
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
if mtime >= cutoff:
|
||||
files.append(path)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Mine session transcripts into MemPalace"
|
||||
)
|
||||
parser.add_argument(
|
||||
"files", nargs="*", help="Session files to mine (JSONL format)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--days", type=int, default=7,
|
||||
help="Mine sessions from last N days (default: 7)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sessions-dir",
|
||||
default=str(Path.home() / ".hermes" / "sessions"),
|
||||
help="Directory containing session JSONL files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wing", default=None,
|
||||
help="Wing name (default: auto-detect from MEMPALACE_WING env or 'wing_timmy')"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--palace-path", default=None,
|
||||
help="Override palace path"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Show what would be mined without storing"
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
wing = args.wing or os.environ.get("MEMPALACE_WING", "wing_timmy")
|
||||
palace_path = Path(args.palace_path) if args.palace_path else None
|
||||
|
||||
if args.files:
|
||||
files = [Path(f) for f in args.files]
|
||||
else:
|
||||
sessions_dir = Path(args.sessions_dir)
|
||||
files = find_session_files(sessions_dir, days=args.days)
|
||||
|
||||
if not files:
|
||||
logger.info("No session files found to mine.")
|
||||
return 0
|
||||
|
||||
logger.info(f"Mining {len(files)} session files (wing={wing})")
|
||||
|
||||
mined = 0
|
||||
failed = 0
|
||||
for path in files:
|
||||
result = mine_session(path, wing=wing, palace_path=palace_path, dry_run=args.dry_run)
|
||||
if result:
|
||||
mined += 1
|
||||
elif result is None and not args.dry_run:
|
||||
failed += 1
|
||||
|
||||
if args.dry_run:
|
||||
logger.info(f"Dry run complete — {len(files)} files would be mined")
|
||||
else:
|
||||
logger.info(f"Mining complete — {mined} mined, {failed} failed")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
80
playground/README.md
Normal file
80
playground/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Sovereign Sound Playground
|
||||
|
||||
Interactive audio-visual experience — no servers, no dependencies, pure browser.
|
||||
|
||||
## Apps
|
||||
|
||||
### Playground v3 (Full Instrument)
|
||||
`playground.html` — The complete experience:
|
||||
- Visual piano keyboard (2 octaves)
|
||||
- 6 visualization modes: Waveform, Particles, Bars, Spiral, Gravity Well, Strobe
|
||||
- 5 color palettes: Cosmic, Sunset, Ocean, Forest, Neon
|
||||
- Ambient beat with chord progressions
|
||||
- Mouse/touch playback on visualizer
|
||||
- Chord detection
|
||||
- Recording to WAV
|
||||
- Export as PNG
|
||||
|
||||
### Synesthesia
|
||||
`synesthesia.html` — Paint with sound:
|
||||
- Click and drag to create colors and shapes
|
||||
- Each position generates a unique tone
|
||||
- Particles respond to your movement
|
||||
- Touch supported
|
||||
|
||||
### Ambient
|
||||
`ambient.html` — Evolving soundscape:
|
||||
- Automatic chord progressions
|
||||
- Floating orbs respond to audio
|
||||
- Reverb-drenched textures
|
||||
- Click to enter, let it wash over you
|
||||
|
||||
### Interactive
|
||||
`interactive.html` — 26 key-shape mappings:
|
||||
- Press A-Z to play notes
|
||||
- Each key has a unique shape and color
|
||||
- Shapes animate and fade
|
||||
- Visual keyboard at bottom
|
||||
- Touch supported
|
||||
|
||||
### Visualizer
|
||||
`visualizer.html` — WAV frequency visualization:
|
||||
- Load any audio file
|
||||
- 4 modes: Spectrum, Waveform, Spectrogram, Circular
|
||||
- Drag and drop support
|
||||
- Real-time frequency analysis
|
||||
|
||||
## Features
|
||||
|
||||
- Zero dependencies — just open in a browser
|
||||
- Local-first — no network requests
|
||||
- Touch support on all apps
|
||||
- Keyboard support
|
||||
- Recording and export
|
||||
- Multiple visualization modes
|
||||
- Color palettes
|
||||
|
||||
## Usage
|
||||
|
||||
Open any HTML file in a browser. That's it.
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
open playground/playground.html
|
||||
|
||||
# Or serve locally
|
||||
python3 -m http.server 8080 --directory playground
|
||||
```
|
||||
|
||||
## Keyboard Shortcuts (Playground v3)
|
||||
|
||||
- A-; (lower row): Play piano notes
|
||||
- Mouse drag on visualizer: Create sound
|
||||
- Click piano keys: Play notes
|
||||
|
||||
## Technical
|
||||
|
||||
- Web Audio API for sound generation
|
||||
- Canvas 2D for visualization
|
||||
- MediaRecorder for recording
|
||||
- No build step, no framework
|
||||
243
playground/ambient.html
Normal file
243
playground/ambient.html
Normal file
@@ -0,0 +1,243 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ambient — Evolving Soundscape</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
.overlay {
|
||||
position: fixed; inset: 0; display: flex; align-items: center;
|
||||
justify-content: center; background: rgba(0,0,0,0.7);
|
||||
transition: opacity 0.5s; z-index: 10;
|
||||
}
|
||||
.overlay.hidden { opacity: 0; pointer-events: none; }
|
||||
.start-btn {
|
||||
background: rgba(100,50,150,0.5); border: 2px solid rgba(150,100,200,0.7);
|
||||
color: #e0e0e0; padding: 20px 40px; font-size: 18px; border-radius: 30px;
|
||||
cursor: pointer; font-family: sans-serif; letter-spacing: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.start-btn:hover { background: rgba(150,100,200,0.5); transform: scale(1.05); }
|
||||
.title {
|
||||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||||
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
|
||||
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="title">Ambient</div>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="overlay" id="overlay">
|
||||
<button class="start-btn" id="start-btn">Enter Soundscape</button>
|
||||
</div>
|
||||
<script>
|
||||
class AmbientSoundscape {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.audioCtx = null;
|
||||
this.masterGain = null;
|
||||
this.analyser = null;
|
||||
this.isPlaying = false;
|
||||
this.currentChord = 0;
|
||||
this.time = 0;
|
||||
this.orbs = [];
|
||||
|
||||
this.resize();
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
|
||||
// Create orbs
|
||||
for (let i = 0; i < 15; i++) {
|
||||
this.orbs.push({
|
||||
x: Math.random() * this.canvas.width,
|
||||
y: Math.random() * this.canvas.height,
|
||||
radius: Math.random() * 50 + 20,
|
||||
speed: Math.random() * 0.5 + 0.2,
|
||||
angle: Math.random() * Math.PI * 2,
|
||||
color: [
|
||||
[167, 139, 250], [129, 140, 248], [99, 102, 241],
|
||||
[139, 92, 246], [124, 58, 237]
|
||||
][i % 5]
|
||||
});
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.canvas.width = window.innerWidth;
|
||||
this.canvas.height = window.innerHeight;
|
||||
}
|
||||
|
||||
async initAudio() {
|
||||
if (this.audioCtx) return;
|
||||
|
||||
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.masterGain = this.audioCtx.createGain();
|
||||
this.masterGain.gain.value = 0.2;
|
||||
|
||||
this.analyser = this.audioCtx.createAnalyser();
|
||||
this.analyser.fftSize = 256;
|
||||
this.analyser.smoothingTimeConstant = 0.95;
|
||||
|
||||
this.masterGain.connect(this.analyser);
|
||||
this.analyser.connect(this.audioCtx.destination);
|
||||
|
||||
// Create reverb
|
||||
const convolver = this.audioCtx.createConvolver();
|
||||
const reverbTime = 3;
|
||||
const sampleRate = this.audioCtx.sampleRate;
|
||||
const length = sampleRate * reverbTime;
|
||||
const impulse = this.audioCtx.createBuffer(2, length, sampleRate);
|
||||
|
||||
for (let channel = 0; channel < 2; channel++) {
|
||||
const channelData = impulse.getChannelData(channel);
|
||||
for (let i = 0; i < length; i++) {
|
||||
channelData[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2);
|
||||
}
|
||||
}
|
||||
|
||||
convolver.buffer = impulse;
|
||||
convolver.connect(this.masterGain);
|
||||
this.reverb = convolver;
|
||||
}
|
||||
|
||||
playChord() {
|
||||
if (!this.audioCtx || !this.isPlaying) return;
|
||||
|
||||
const progressions = [
|
||||
[[261.63, 329.63, 392.00], [392.00, 493.88, 587.33],
|
||||
[440.00, 523.25, 659.25], [349.23, 440.00, 523.25]],
|
||||
[[220.00, 277.18, 329.63], [329.63, 415.30, 493.88],
|
||||
[369.99, 466.16, 554.37], [293.66, 369.99, 440.00]]
|
||||
];
|
||||
|
||||
const prog = progressions[this.currentChord % 2];
|
||||
const chord = prog[Math.floor(this.time / 4) % prog.length];
|
||||
|
||||
chord.forEach((freq, i) => {
|
||||
const osc = this.audioCtx.createOscillator();
|
||||
const gain = this.audioCtx.createGain();
|
||||
const filter = this.audioCtx.createBiquadFilter();
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq * (1 + (Math.random() - 0.5) * 0.01);
|
||||
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 800 + Math.sin(this.time * 0.1) * 400;
|
||||
|
||||
gain.gain.setValueAtTime(0, this.audioCtx.currentTime);
|
||||
gain.gain.linearRampToValueAtTime(0.15, this.audioCtx.currentTime + 0.5);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 4);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(this.reverb);
|
||||
|
||||
osc.start();
|
||||
osc.stop(this.audioCtx.currentTime + 4);
|
||||
});
|
||||
|
||||
// High texture
|
||||
const highOsc = this.audioCtx.createOscillator();
|
||||
const highGain = this.audioCtx.createGain();
|
||||
|
||||
highOsc.type = 'sine';
|
||||
highOsc.frequency.value = chord[0] * 4 + Math.random() * 50;
|
||||
|
||||
highGain.gain.setValueAtTime(0, this.audioCtx.currentTime);
|
||||
highGain.gain.linearRampToValueAtTime(0.03, this.audioCtx.currentTime + 1);
|
||||
highGain.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + 5);
|
||||
|
||||
highOsc.connect(highGain);
|
||||
highGain.connect(this.reverb);
|
||||
highOsc.start();
|
||||
highOsc.stop(this.audioCtx.currentTime + 5);
|
||||
|
||||
this.time += 4;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.isPlaying = true;
|
||||
document.getElementById('overlay').classList.add('hidden');
|
||||
|
||||
const loop = () => {
|
||||
if (!this.isPlaying) return;
|
||||
this.playChord();
|
||||
setTimeout(loop, 4000);
|
||||
};
|
||||
|
||||
loop();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
animate() {
|
||||
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.03)';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
const freqData = this.analyser ? new Uint8Array(this.analyser.frequencyBinCount) : [];
|
||||
if (this.analyser) this.analyser.getByteFrequencyData(freqData);
|
||||
|
||||
const energy = freqData.length > 0 ? freqData[10] / 255 : 0;
|
||||
|
||||
// Move and draw orbs
|
||||
this.orbs.forEach((orb, i) => {
|
||||
orb.angle += orb.speed * 0.01;
|
||||
orb.x += Math.cos(orb.angle) * (1 + energy * 3);
|
||||
orb.y += Math.sin(orb.angle * 0.7) * (1 + energy * 3);
|
||||
|
||||
// Wrap around
|
||||
if (orb.x < -orb.radius) orb.x = this.canvas.width + orb.radius;
|
||||
if (orb.x > this.canvas.width + orb.radius) orb.x = -orb.radius;
|
||||
if (orb.y < -orb.radius) orb.y = this.canvas.height + orb.radius;
|
||||
if (orb.y > this.canvas.height + orb.radius) orb.y = -orb.radius;
|
||||
|
||||
// Draw glow
|
||||
const gradient = this.ctx.createRadialGradient(
|
||||
orb.x, orb.y, 0,
|
||||
orb.x, orb.y, orb.radius * (1 + energy * 0.5)
|
||||
);
|
||||
gradient.addColorStop(0, `rgba(${orb.color.join(',')}, 0.3)`);
|
||||
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(orb.x, orb.y, orb.radius * (1 + energy * 0.5), 0, Math.PI * 2);
|
||||
this.ctx.fillStyle = gradient;
|
||||
this.ctx.fill();
|
||||
|
||||
// Inner glow
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(orb.x, orb.y, orb.radius * 0.3, 0, Math.PI * 2);
|
||||
this.ctx.fillStyle = `rgba(${orb.color.join(',')}, 0.5)`;
|
||||
this.ctx.fill();
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => this.animate());
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.getElementById('start-btn').addEventListener('click', async () => {
|
||||
await this.initAudio();
|
||||
this.start();
|
||||
});
|
||||
|
||||
document.addEventListener('click', async () => {
|
||||
if (!this.audioCtx) {
|
||||
await this.initAudio();
|
||||
}
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
new AmbientSoundscape();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
294
playground/interactive.html
Normal file
294
playground/interactive.html
Normal file
@@ -0,0 +1,294 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Interactive — 26 Key Shapes</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
.title {
|
||||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||||
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
|
||||
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
|
||||
}
|
||||
.key-hint {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||
color: rgba(255,255,255,0.3); font-size: 14px; font-family: monospace;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="title">Interactive</div>
|
||||
<div class="key-hint">Press A-Z to play</div>
|
||||
<canvas id="canvas"></canvas>
|
||||
<script>
|
||||
class Interactive {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.audioCtx = null;
|
||||
this.analyser = null;
|
||||
this.shapes = [];
|
||||
this.activeKeys = new Set();
|
||||
|
||||
this.resize();
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
|
||||
// 26 key mappings with unique shapes and frequencies
|
||||
this.keyMap = {
|
||||
'a': { freq: 261.63, shape: 'circle', color: [255,100,100] },
|
||||
'b': { freq: 277.18, shape: 'square', color: [255,150,100] },
|
||||
'c': { freq: 293.66, shape: 'triangle', color: [255,200,100] },
|
||||
'd': { freq: 311.13, shape: 'diamond', color: [255,255,100] },
|
||||
'e': { freq: 329.63, shape: 'star', color: [200,255,100] },
|
||||
'f': { freq: 349.23, shape: 'hexagon', color: [150,255,100] },
|
||||
'g': { freq: 369.99, shape: 'cross', color: [100,255,100] },
|
||||
'h': { freq: 392.00, shape: 'circle', color: [100,255,150] },
|
||||
'i': { freq: 415.30, shape: 'square', color: [100,255,200] },
|
||||
'j': { freq: 440.00, shape: 'triangle', color: [100,255,255] },
|
||||
'k': { freq: 466.16, shape: 'diamond', color: [100,200,255] },
|
||||
'l': { freq: 493.88, shape: 'star', color: [100,150,255] },
|
||||
'm': { freq: 523.25, shape: 'hexagon', color: [100,100,255] },
|
||||
'n': { freq: 554.37, shape: 'cross', color: [150,100,255] },
|
||||
'o': { freq: 587.33, shape: 'circle', color: [200,100,255] },
|
||||
'p': { freq: 622.25, shape: 'square', color: [255,100,255] },
|
||||
'q': { freq: 659.25, shape: 'triangle', color: [255,100,200] },
|
||||
'r': { freq: 698.46, shape: 'diamond', color: [255,100,150] },
|
||||
's': { freq: 739.99, shape: 'star', color: [255,120,120] },
|
||||
't': { freq: 783.99, shape: 'hexagon', color: [255,170,120] },
|
||||
'u': { freq: 830.61, shape: 'cross', color: [255,220,120] },
|
||||
'v': { freq: 880.00, shape: 'circle', color: [220,255,120] },
|
||||
'w': { freq: 932.33, shape: 'square', color: [170,255,120] },
|
||||
'x': { freq: 987.77, shape: 'triangle', color: [120,255,120] },
|
||||
'y': { freq: 1046.50, shape: 'diamond', color: [120,255,170] },
|
||||
'z': { freq: 1108.73, shape: 'star', color: [120,255,220] }
|
||||
};
|
||||
|
||||
this.bindEvents();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.canvas.width = window.innerWidth;
|
||||
this.canvas.height = window.innerHeight;
|
||||
}
|
||||
|
||||
async initAudio() {
|
||||
if (this.audioCtx) return;
|
||||
|
||||
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.analyser = this.audioCtx.createAnalyser();
|
||||
this.analyser.fftSize = 256;
|
||||
this.analyser.connect(this.audioCtx.destination);
|
||||
}
|
||||
|
||||
playKey(key) {
|
||||
if (!this.audioCtx || !this.keyMap[key]) return;
|
||||
|
||||
const { freq, shape, color } = this.keyMap[key];
|
||||
|
||||
// Play sound
|
||||
const osc = this.audioCtx.createOscillator();
|
||||
const gain = this.audioCtx.createGain();
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(0.3, this.audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 1);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.analyser);
|
||||
osc.start();
|
||||
osc.stop(this.audioCtx.currentTime + 1);
|
||||
|
||||
// Create shape
|
||||
const x = Math.random() * (this.canvas.width - 200) + 100;
|
||||
const y = Math.random() * (this.canvas.height - 200) + 100;
|
||||
|
||||
this.shapes.push({
|
||||
key, x, y, shape, color,
|
||||
size: 50,
|
||||
maxSize: 150,
|
||||
life: 1,
|
||||
rotation: 0
|
||||
});
|
||||
}
|
||||
|
||||
drawShape(shape) {
|
||||
const { x, y, size, rotation, shape: shapeType, color, life } = shape;
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.translate(x, y);
|
||||
this.ctx.rotate(rotation);
|
||||
this.ctx.globalAlpha = life;
|
||||
|
||||
this.ctx.strokeStyle = `rgb(${color.join(',')})`;
|
||||
this.ctx.lineWidth = 3;
|
||||
this.ctx.fillStyle = `rgba(${color.join(',')}, 0.2)`;
|
||||
|
||||
this.ctx.beginPath();
|
||||
|
||||
switch (shapeType) {
|
||||
case 'circle':
|
||||
this.ctx.arc(0, 0, size, 0, Math.PI * 2);
|
||||
break;
|
||||
|
||||
case 'square':
|
||||
this.ctx.rect(-size, -size, size * 2, size * 2);
|
||||
break;
|
||||
|
||||
case 'triangle':
|
||||
this.ctx.moveTo(0, -size);
|
||||
this.ctx.lineTo(size * 0.866, size * 0.5);
|
||||
this.ctx.lineTo(-size * 0.866, size * 0.5);
|
||||
this.ctx.closePath();
|
||||
break;
|
||||
|
||||
case 'diamond':
|
||||
this.ctx.moveTo(0, -size);
|
||||
this.ctx.lineTo(size * 0.7, 0);
|
||||
this.ctx.lineTo(0, size);
|
||||
this.ctx.lineTo(-size * 0.7, 0);
|
||||
this.ctx.closePath();
|
||||
break;
|
||||
|
||||
case 'star':
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const angle = (i * 4 * Math.PI) / 5 - Math.PI / 2;
|
||||
const method = i === 0 ? 'moveTo' : 'lineTo';
|
||||
this.ctx[method](Math.cos(angle) * size, Math.sin(angle) * size);
|
||||
}
|
||||
this.ctx.closePath();
|
||||
break;
|
||||
|
||||
case 'hexagon':
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (i * Math.PI) / 3;
|
||||
const method = i === 0 ? 'moveTo' : 'lineTo';
|
||||
this.ctx[method](Math.cos(angle) * size, Math.sin(angle) * size);
|
||||
}
|
||||
this.ctx.closePath();
|
||||
break;
|
||||
|
||||
case 'cross':
|
||||
const w = size * 0.3;
|
||||
this.ctx.moveTo(-w, -size);
|
||||
this.ctx.lineTo(w, -size);
|
||||
this.ctx.lineTo(w, -w);
|
||||
this.ctx.lineTo(size, -w);
|
||||
this.ctx.lineTo(size, w);
|
||||
this.ctx.lineTo(w, w);
|
||||
this.ctx.lineTo(w, size);
|
||||
this.ctx.lineTo(-w, size);
|
||||
this.ctx.lineTo(-w, w);
|
||||
this.ctx.lineTo(-size, w);
|
||||
this.ctx.lineTo(-size, -w);
|
||||
this.ctx.lineTo(-w, -w);
|
||||
this.ctx.closePath();
|
||||
break;
|
||||
}
|
||||
|
||||
this.ctx.fill();
|
||||
this.ctx.stroke();
|
||||
|
||||
// Draw key label
|
||||
this.ctx.fillStyle = `rgba(${color.join(',')}, ${life})`;
|
||||
this.ctx.font = `${size * 0.4}px monospace`;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
this.ctx.fillText(shape.key.toUpperCase(), 0, 0);
|
||||
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
animate() {
|
||||
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.1)';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Update and draw shapes
|
||||
this.shapes = this.shapes.filter(shape => {
|
||||
shape.size += (shape.maxSize - shape.size) * 0.05;
|
||||
shape.rotation += 0.02;
|
||||
shape.life -= 0.01;
|
||||
|
||||
if (shape.life <= 0) return false;
|
||||
|
||||
this.drawShape(shape);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Draw active key indicators
|
||||
const keyWidth = this.canvas.width / 13;
|
||||
const keyHeight = 40;
|
||||
const startY = this.canvas.height - 60;
|
||||
|
||||
let col = 0;
|
||||
for (const key of 'abcdefghijklmnopqrstuvwxyz') {
|
||||
const x = col * keyWidth + keyWidth / 2;
|
||||
const isActive = this.activeKeys.has(key);
|
||||
|
||||
this.ctx.fillStyle = isActive
|
||||
? `rgba(${this.keyMap[key].color.join(',')}, 0.5)`
|
||||
: 'rgba(255,255,255,0.05)';
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.roundRect(x - 15, startY, 30, keyHeight, 5);
|
||||
this.ctx.fill();
|
||||
|
||||
this.ctx.fillStyle = isActive ? '#fff' : 'rgba(255,255,255,0.3)';
|
||||
this.ctx.font = '12px monospace';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.fillText(key.toUpperCase(), x, startY + 25);
|
||||
|
||||
col++;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.animate());
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.addEventListener('keydown', async (e) => {
|
||||
if (e.repeat) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (this.keyMap[key] && !this.activeKeys.has(key)) {
|
||||
await this.initAudio();
|
||||
this.activeKeys.add(key);
|
||||
this.playKey(key);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
this.activeKeys.delete(key);
|
||||
});
|
||||
|
||||
// Touch support
|
||||
this.canvas.addEventListener('touchstart', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.initAudio();
|
||||
|
||||
// Map touch area to key
|
||||
for (const touch of e.changedTouches) {
|
||||
const keyIndex = Math.floor(touch.clientX / (this.canvas.width / 26));
|
||||
const key = 'abcdefghijklmnopqrstuvwxyz'[keyIndex];
|
||||
if (key) {
|
||||
this.activeKeys.add(key);
|
||||
this.playKey(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
this.activeKeys.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new Interactive();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1216
playground/playground.html
Normal file
1216
playground/playground.html
Normal file
File diff suppressed because it is too large
Load Diff
198
playground/synesthesia.html
Normal file
198
playground/synesthesia.html
Normal file
@@ -0,0 +1,198 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Synesthesia — Paint with Sound</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0f; overflow: hidden; height: 100vh; cursor: crosshair; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
.title {
|
||||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||||
color: rgba(255,255,255,0.15); font-size: 24px; font-family: sans-serif;
|
||||
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="title">Synesthesia</div>
|
||||
<canvas id="canvas"></canvas>
|
||||
<script>
|
||||
class Synesthesia {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.audioCtx = null;
|
||||
this.analyser = null;
|
||||
this.particles = [];
|
||||
this.trails = [];
|
||||
this.mouse = { x: 0, y: 0, down: false };
|
||||
this.lastNote = null;
|
||||
|
||||
this.colors = [
|
||||
[255, 100, 150], [100, 200, 255], [150, 255, 150],
|
||||
[255, 200, 100], [200, 150, 255]
|
||||
];
|
||||
|
||||
this.resize();
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
this.bindEvents();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.canvas.width = window.innerWidth;
|
||||
this.canvas.height = window.innerHeight;
|
||||
}
|
||||
|
||||
async initAudio() {
|
||||
if (this.audioCtx) return;
|
||||
this.audioCtx = new AudioContext();
|
||||
this.analyser = this.audioCtx.createAnalyser();
|
||||
this.analyser.fftSize = 256;
|
||||
this.analyser.connect(this.audioCtx.destination);
|
||||
}
|
||||
|
||||
playTone(x, y) {
|
||||
if (!this.audioCtx) return;
|
||||
|
||||
const freq = 200 + (x / this.canvas.width) * 600;
|
||||
const noteId = Math.round(freq / 20);
|
||||
|
||||
if (noteId === this.lastNote) return;
|
||||
this.lastNote = noteId;
|
||||
|
||||
const osc = this.audioCtx.createOscillator();
|
||||
const gain = this.audioCtx.createGain();
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
gain.gain.setValueAtTime(0.2, this.audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 0.5);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.analyser);
|
||||
osc.start();
|
||||
osc.stop(this.audioCtx.currentTime + 0.5);
|
||||
|
||||
// Spawn particles
|
||||
const color = this.colors[Math.floor(Math.random() * this.colors.length)];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
this.particles.push({
|
||||
x, y,
|
||||
vx: (Math.random() - 0.5) * 8,
|
||||
vy: (Math.random() - 0.5) * 8,
|
||||
life: 1,
|
||||
color,
|
||||
size: Math.random() * 10 + 5
|
||||
});
|
||||
}
|
||||
|
||||
// Add trail
|
||||
this.trails.push({ x, y, color, size: 30, alpha: 0.5 });
|
||||
}
|
||||
|
||||
animate() {
|
||||
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.05)';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Draw trails
|
||||
this.trails = this.trails.filter(t => {
|
||||
t.alpha -= 0.005;
|
||||
if (t.alpha <= 0) return false;
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(t.x, t.y, t.size, 0, Math.PI * 2);
|
||||
this.ctx.fillStyle = `rgba(${t.color.join(',')}, ${t.alpha})`;
|
||||
this.ctx.fill();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Draw particles
|
||||
this.particles = this.particles.filter(p => {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.life -= 0.02;
|
||||
p.vy += 0.1;
|
||||
|
||||
if (p.life <= 0) return false;
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
|
||||
this.ctx.fillStyle = `rgba(${p.color.join(',')}, ${p.life * 0.8})`;
|
||||
this.ctx.fill();
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Draw mouse trail
|
||||
if (this.mouse.down) {
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(this.mouse.x, this.mouse.y, 20, 0, Math.PI * 2);
|
||||
const color = this.colors[Date.now() % 5];
|
||||
this.ctx.fillStyle = `rgba(${color.join(',')}, 0.3)`;
|
||||
this.ctx.fill();
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.animate());
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.canvas.addEventListener('mousedown', async (e) => {
|
||||
await this.initAudio();
|
||||
this.mouse.down = true;
|
||||
this.mouse.x = e.clientX;
|
||||
this.mouse.y = e.clientY;
|
||||
this.playTone(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('mousemove', (e) => {
|
||||
this.mouse.x = e.clientX;
|
||||
this.mouse.y = e.clientY;
|
||||
if (this.mouse.down) {
|
||||
this.playTone(e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('mouseup', () => {
|
||||
this.mouse.down = false;
|
||||
this.lastNote = null;
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('mouseleave', () => {
|
||||
this.mouse.down = false;
|
||||
this.lastNote = null;
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('touchstart', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.initAudio();
|
||||
this.mouse.down = true;
|
||||
const touch = e.touches[0];
|
||||
this.mouse.x = touch.clientX;
|
||||
this.mouse.y = touch.clientY;
|
||||
this.playTone(touch.clientX, touch.clientY);
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.mouse.x = touch.clientX;
|
||||
this.mouse.y = touch.clientY;
|
||||
if (this.mouse.down) {
|
||||
this.playTone(touch.clientX, touch.clientY);
|
||||
}
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('touchend', () => {
|
||||
this.mouse.down = false;
|
||||
this.lastNote = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new Synesthesia();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
371
playground/visualizer.html
Normal file
371
playground/visualizer.html
Normal file
@@ -0,0 +1,371 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visualizer — WAV Frequency</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0f; overflow: hidden; height: 100vh; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
.title {
|
||||
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
|
||||
color: rgba(255,255,255,0.1); font-size: 24px; font-family: sans-serif;
|
||||
letter-spacing: 4px; text-transform: uppercase; pointer-events: none;
|
||||
}
|
||||
.controls {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 10px; z-index: 10;
|
||||
}
|
||||
button, select {
|
||||
background: rgba(30,30,40,0.9); border: 1px solid rgba(100,100,120,0.3);
|
||||
color: #e0e0e0; padding: 10px 20px; border-radius: 8px; font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover, select:hover { background: rgba(50,50,70,0.9); }
|
||||
.drop-zone {
|
||||
position: fixed; inset: 0; display: flex; align-items: center;
|
||||
justify-content: center; pointer-events: none; z-index: 5;
|
||||
}
|
||||
.drop-zone.active {
|
||||
background: rgba(100,50,150,0.2);
|
||||
border: 3px dashed rgba(150,100,200,0.5);
|
||||
}
|
||||
.drop-text {
|
||||
color: rgba(255,255,255,0.3); font-size: 24px; font-family: sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="title">Visualizer</div>
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<span class="drop-text">Drop WAV file here or use controls</span>
|
||||
</div>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="controls">
|
||||
<input type="file" id="file-input" accept="audio/*" style="display: none;">
|
||||
<button id="load-btn">Load Audio</button>
|
||||
<button id="play-btn" disabled>Play</button>
|
||||
<button id="stop-btn" disabled>Stop</button>
|
||||
<select id="viz-mode">
|
||||
<option value="spectrum">Spectrum</option>
|
||||
<option value="waveform">Waveform</option>
|
||||
<option value="spectrogram">Spectrogram</option>
|
||||
<option value="circular">Circular</option>
|
||||
</select>
|
||||
</div>
|
||||
<script>
|
||||
class AudioVisualizer {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.audioCtx = null;
|
||||
this.analyser = null;
|
||||
this.source = null;
|
||||
this.audioBuffer = null;
|
||||
this.isPlaying = false;
|
||||
this.mode = 'spectrum';
|
||||
this.spectrogramData = [];
|
||||
this.startOffset = 0;
|
||||
|
||||
this.resize();
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
this.bindEvents();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.canvas.width = window.innerWidth;
|
||||
this.canvas.height = window.innerHeight;
|
||||
}
|
||||
|
||||
async initAudio() {
|
||||
if (this.audioCtx) return;
|
||||
|
||||
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.analyser = this.audioCtx.createAnalyser();
|
||||
this.analyser.fftSize = 2048;
|
||||
this.analyser.smoothingTimeConstant = 0.8;
|
||||
this.analyser.connect(this.audioCtx.destination);
|
||||
}
|
||||
|
||||
async loadAudio(file) {
|
||||
await this.initAudio();
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
this.audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer);
|
||||
|
||||
document.getElementById('play-btn').disabled = false;
|
||||
document.getElementById('stop-btn').disabled = false;
|
||||
document.querySelector('.drop-text').textContent = file.name;
|
||||
}
|
||||
|
||||
play() {
|
||||
if (!this.audioBuffer || this.isPlaying) return;
|
||||
|
||||
this.source = this.audioCtx.createBufferSource();
|
||||
this.source.buffer = this.audioBuffer;
|
||||
this.source.connect(this.analyser);
|
||||
this.source.start(0, this.startOffset);
|
||||
this.isPlaying = true;
|
||||
|
||||
this.source.onended = () => {
|
||||
this.isPlaying = false;
|
||||
this.startOffset = 0;
|
||||
document.getElementById('play-btn').textContent = 'Play';
|
||||
};
|
||||
|
||||
document.getElementById('play-btn').textContent = 'Pause';
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.source && this.isPlaying) {
|
||||
this.source.stop();
|
||||
this.isPlaying = false;
|
||||
this.startOffset = 0;
|
||||
document.getElementById('play-btn').textContent = 'Play';
|
||||
}
|
||||
}
|
||||
|
||||
animate() {
|
||||
this.ctx.fillStyle = 'rgba(10, 10, 15, 0.15)';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
if (!this.analyser) {
|
||||
requestAnimationFrame(() => this.animate());
|
||||
return;
|
||||
}
|
||||
|
||||
const freqData = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
const timeData = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
this.analyser.getByteFrequencyData(freqData);
|
||||
this.analyser.getByteTimeDomainData(timeData);
|
||||
|
||||
switch (this.mode) {
|
||||
case 'spectrum': this.drawSpectrum(freqData); break;
|
||||
case 'waveform': this.drawWaveform(timeData); break;
|
||||
case 'spectrogram': this.drawSpectrogram(freqData); break;
|
||||
case 'circular': this.drawCircular(freqData, timeData); break;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.animate());
|
||||
}
|
||||
|
||||
drawSpectrum(data) {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
const barCount = 128;
|
||||
const barWidth = w / barCount;
|
||||
const step = Math.floor(data.length / barCount);
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const value = data[i * step] / 255;
|
||||
const barHeight = value * h * 0.8;
|
||||
|
||||
const hue = (i / barCount) * 300;
|
||||
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
|
||||
|
||||
this.ctx.fillRect(
|
||||
i * barWidth,
|
||||
h - barHeight,
|
||||
barWidth - 1,
|
||||
barHeight
|
||||
);
|
||||
|
||||
// Mirror
|
||||
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.2)`;
|
||||
this.ctx.fillRect(
|
||||
i * barWidth,
|
||||
0,
|
||||
barWidth - 1,
|
||||
barHeight * 0.3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
drawWaveform(data) {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.strokeStyle = '#a78bfa';
|
||||
this.ctx.lineWidth = 2;
|
||||
|
||||
const sliceWidth = w / data.length;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = data[i] / 128.0;
|
||||
const y = (v * h) / 2;
|
||||
|
||||
if (i === 0) {
|
||||
this.ctx.moveTo(x, y);
|
||||
} else {
|
||||
this.ctx.lineTo(x, y);
|
||||
}
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
this.ctx.stroke();
|
||||
|
||||
// Glow
|
||||
this.ctx.strokeStyle = 'rgba(167,139,250,0.3)';
|
||||
this.ctx.lineWidth = 6;
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
drawSpectrogram(data) {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
|
||||
// Add new line
|
||||
this.spectrogramData.push([...data]);
|
||||
|
||||
// Keep last N lines
|
||||
const maxLines = Math.floor(w / 2);
|
||||
if (this.spectrogramData.length > maxLines) {
|
||||
this.spectrogramData.shift();
|
||||
}
|
||||
|
||||
// Draw
|
||||
const line_width = w / maxLines;
|
||||
|
||||
this.spectrogramData.forEach((line, x) => {
|
||||
const step = Math.floor(line.length / h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
const value = line[y * step] || 0;
|
||||
const hue = 270 - (value / 255) * 200;
|
||||
const lightness = 20 + (value / 255) * 50;
|
||||
|
||||
this.ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
|
||||
this.ctx.fillRect(x * line_width, h - y, line_width, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
drawCircular(freqData, timeData) {
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const maxRadius = Math.min(w, h) * 0.35;
|
||||
|
||||
// Frequency ring
|
||||
const freqStep = Math.floor(freqData.length / 180);
|
||||
for (let i = 0; i < 180; i++) {
|
||||
const value = freqData[i * freqStep] / 255;
|
||||
const angle = (i / 180) * Math.PI * 2;
|
||||
const radius = maxRadius * 0.5 + value * maxRadius * 0.5;
|
||||
|
||||
const x = cx + Math.cos(angle) * radius;
|
||||
const y = cy + Math.sin(angle) * radius;
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||||
|
||||
const hue = (i / 180) * 360;
|
||||
this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
|
||||
this.ctx.fill();
|
||||
}
|
||||
|
||||
// Waveform circle
|
||||
this.ctx.beginPath();
|
||||
this.ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||
this.ctx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i < timeData.length; i++) {
|
||||
const v = timeData[i] / 128.0;
|
||||
const angle = (i / timeData.length) * Math.PI * 2;
|
||||
const radius = maxRadius * 0.3 * v;
|
||||
|
||||
const x = cx + Math.cos(angle) * radius;
|
||||
const y = cy + Math.sin(angle) * radius;
|
||||
|
||||
if (i === 0) {
|
||||
this.ctx.moveTo(x, y);
|
||||
} else {
|
||||
this.ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
this.ctx.closePath();
|
||||
this.ctx.stroke();
|
||||
|
||||
// Center glow
|
||||
const gradient = this.ctx.createRadialGradient(cx, cy, 0, cx, cy, maxRadius * 0.3);
|
||||
gradient.addColorStop(0, 'rgba(167,139,250,0.3)');
|
||||
gradient.addColorStop(1, 'rgba(167,139,250,0)');
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(cx, cy, maxRadius * 0.3, 0, Math.PI * 2);
|
||||
this.ctx.fillStyle = gradient;
|
||||
this.ctx.fill();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.getElementById('load-btn').addEventListener('click', () => {
|
||||
document.getElementById('file-input').click();
|
||||
});
|
||||
|
||||
document.getElementById('file-input').addEventListener('change', async (e) => {
|
||||
if (e.target.files[0]) {
|
||||
await this.loadAudio(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('play-btn').addEventListener('click', () => {
|
||||
if (this.isPlaying) {
|
||||
this.stop();
|
||||
} else {
|
||||
this.play();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('stop-btn').addEventListener('click', () => {
|
||||
this.stop();
|
||||
});
|
||||
|
||||
document.getElementById('viz-mode').addEventListener('change', (e) => {
|
||||
this.mode = e.target.value;
|
||||
this.spectrogramData = [];
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
|
||||
document.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('active');
|
||||
});
|
||||
|
||||
document.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('active');
|
||||
});
|
||||
|
||||
document.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('active');
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('audio/')) {
|
||||
await this.loadAudio(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Touch support
|
||||
this.canvas.addEventListener('click', async () => {
|
||||
if (!this.audioCtx) {
|
||||
await this.initAudio();
|
||||
}
|
||||
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
this.audioCtx.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
new AudioVisualizer();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,377 +0,0 @@
|
||||
"""
|
||||
Tests for agent memory — cross-session agent memory via MemPalace.
|
||||
|
||||
Tests the memory module, hooks, and session mining without requiring
|
||||
a live ChromaDB instance. Uses mocking for the MemPalace backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.memory import (
|
||||
AgentMemory,
|
||||
MemoryContext,
|
||||
SessionTranscript,
|
||||
create_agent_memory,
|
||||
)
|
||||
from agent.memory_hooks import MemoryHooks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionTranscript tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionTranscript:
|
||||
def test_create(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
assert t.agent_name == "test"
|
||||
assert t.wing == "wing_test"
|
||||
assert len(t.entries) == 0
|
||||
|
||||
def test_add_user_turn(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
t.add_user_turn("Hello")
|
||||
assert len(t.entries) == 1
|
||||
assert t.entries[0]["role"] == "user"
|
||||
assert t.entries[0]["text"] == "Hello"
|
||||
|
||||
def test_add_agent_turn(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
t.add_agent_turn("Response")
|
||||
assert t.entries[0]["role"] == "agent"
|
||||
|
||||
def test_add_tool_call(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
t.add_tool_call("shell", "ls", "file1 file2")
|
||||
assert t.entries[0]["role"] == "tool"
|
||||
assert t.entries[0]["tool"] == "shell"
|
||||
|
||||
def test_summary_empty(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
assert t.summary() == "Empty session."
|
||||
|
||||
def test_summary_with_entries(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
t.add_user_turn("Do something")
|
||||
t.add_agent_turn("Done")
|
||||
t.add_tool_call("shell", "ls", "ok")
|
||||
|
||||
summary = t.summary()
|
||||
assert "USER: Do something" in summary
|
||||
assert "AGENT: Done" in summary
|
||||
assert "TOOL(shell): ok" in summary
|
||||
|
||||
def test_text_truncation(self):
|
||||
t = SessionTranscript(agent_name="test", wing="wing_test")
|
||||
long_text = "x" * 5000
|
||||
t.add_user_turn(long_text)
|
||||
assert len(t.entries[0]["text"]) == 2000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryContext tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMemoryContext:
|
||||
def test_empty_context(self):
|
||||
ctx = MemoryContext()
|
||||
assert ctx.to_prompt_block() == ""
|
||||
|
||||
def test_unloaded_context(self):
|
||||
ctx = MemoryContext()
|
||||
ctx.loaded = False
|
||||
assert ctx.to_prompt_block() == ""
|
||||
|
||||
def test_loaded_with_data(self):
|
||||
ctx = MemoryContext()
|
||||
ctx.loaded = True
|
||||
ctx.recent_diaries = [
|
||||
{"text": "Fixed PR #1386", "timestamp": "2026-04-13T10:00:00Z"}
|
||||
]
|
||||
ctx.facts = [
|
||||
{"text": "Bezalel runs on VPS Beta", "score": 0.95}
|
||||
]
|
||||
ctx.relevant_memories = [
|
||||
{"text": "Changed CI runner", "score": 0.87}
|
||||
]
|
||||
|
||||
block = ctx.to_prompt_block()
|
||||
assert "Recent Session Summaries" in block
|
||||
assert "Fixed PR #1386" in block
|
||||
assert "Known Facts" in block
|
||||
assert "Bezalel runs on VPS Beta" in block
|
||||
assert "Relevant Past Memories" in block
|
||||
|
||||
def test_loaded_empty(self):
|
||||
ctx = MemoryContext()
|
||||
ctx.loaded = True
|
||||
# No data — should return empty string
|
||||
assert ctx.to_prompt_block() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentMemory tests (with mocked MemPalace)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAgentMemory:
|
||||
def test_create(self):
|
||||
mem = AgentMemory(agent_name="bezalel")
|
||||
assert mem.agent_name == "bezalel"
|
||||
assert mem.wing == "wing_bezalel"
|
||||
|
||||
def test_custom_wing(self):
|
||||
mem = AgentMemory(agent_name="bezalel", wing="custom_wing")
|
||||
assert mem.wing == "custom_wing"
|
||||
|
||||
def test_factory(self):
|
||||
mem = create_agent_memory("ezra")
|
||||
assert mem.agent_name == "ezra"
|
||||
assert mem.wing == "wing_ezra"
|
||||
|
||||
def test_unavailable_graceful(self):
|
||||
"""Test graceful degradation when MemPalace is unavailable."""
|
||||
mem = AgentMemory(agent_name="test")
|
||||
mem._available = False # Force unavailable
|
||||
|
||||
# Should not raise
|
||||
ctx = mem.recall_context("test query")
|
||||
assert ctx.loaded is False
|
||||
assert ctx.error == "MemPalace unavailable"
|
||||
|
||||
# remember returns None
|
||||
assert mem.remember("test") is None
|
||||
|
||||
# search returns empty
|
||||
assert mem.search("test") == []
|
||||
|
||||
def test_start_end_session(self):
|
||||
mem = AgentMemory(agent_name="test")
|
||||
mem._available = False
|
||||
|
||||
transcript = mem.start_session()
|
||||
assert isinstance(transcript, SessionTranscript)
|
||||
assert mem._transcript is not None
|
||||
|
||||
doc_id = mem.end_session()
|
||||
assert mem._transcript is None
|
||||
|
||||
def test_remember_graceful_when_unavailable(self):
|
||||
"""Test remember returns None when MemPalace is unavailable."""
|
||||
mem = AgentMemory(agent_name="test")
|
||||
mem._available = False
|
||||
|
||||
doc_id = mem.remember("some important fact")
|
||||
assert doc_id is None
|
||||
|
||||
def test_write_diary_from_transcript(self):
|
||||
mem = AgentMemory(agent_name="test")
|
||||
mem._available = False
|
||||
|
||||
transcript = mem.start_session()
|
||||
transcript.add_user_turn("Hello")
|
||||
transcript.add_agent_turn("Hi there")
|
||||
|
||||
# Write diary should handle unavailable gracefully
|
||||
doc_id = mem.write_diary()
|
||||
assert doc_id is None # MemPalace unavailable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryHooks tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMemoryHooks:
|
||||
def test_create(self):
|
||||
hooks = MemoryHooks(agent_name="bezalel")
|
||||
assert hooks.agent_name == "bezalel"
|
||||
assert hooks.is_active is False
|
||||
|
||||
def test_session_lifecycle(self):
|
||||
hooks = MemoryHooks(agent_name="test")
|
||||
|
||||
# Force memory unavailable
|
||||
hooks._memory = AgentMemory(agent_name="test")
|
||||
hooks._memory._available = False
|
||||
|
||||
# Start session
|
||||
block = hooks.on_session_start()
|
||||
assert hooks.is_active is True
|
||||
assert block == "" # No memory available
|
||||
|
||||
# Record turns
|
||||
hooks.on_user_turn("Hello")
|
||||
hooks.on_agent_turn("Hi")
|
||||
hooks.on_tool_call("shell", "ls", "ok")
|
||||
|
||||
# Record decision
|
||||
hooks.on_important_decision("Switched to self-hosted CI")
|
||||
|
||||
# End session
|
||||
doc_id = hooks.on_session_end()
|
||||
assert hooks.is_active is False
|
||||
|
||||
def test_hooks_before_session(self):
|
||||
"""Hooks before session start should be no-ops."""
|
||||
hooks = MemoryHooks(agent_name="test")
|
||||
hooks._memory = AgentMemory(agent_name="test")
|
||||
hooks._memory._available = False
|
||||
|
||||
# Should not raise
|
||||
hooks.on_user_turn("Hello")
|
||||
hooks.on_agent_turn("Response")
|
||||
|
||||
def test_hooks_after_session_end(self):
|
||||
"""Hooks after session end should be no-ops."""
|
||||
hooks = MemoryHooks(agent_name="test")
|
||||
hooks._memory = AgentMemory(agent_name="test")
|
||||
hooks._memory._available = False
|
||||
|
||||
hooks.on_session_start()
|
||||
hooks.on_session_end()
|
||||
|
||||
# Should not raise
|
||||
hooks.on_user_turn("Late message")
|
||||
doc_id = hooks.on_session_end()
|
||||
assert doc_id is None
|
||||
|
||||
def test_search_during_session(self):
|
||||
hooks = MemoryHooks(agent_name="test")
|
||||
hooks._memory = AgentMemory(agent_name="test")
|
||||
hooks._memory._available = False
|
||||
|
||||
results = hooks.search("some query")
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session mining tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionMining:
|
||||
def test_parse_session_file(self):
|
||||
from bin.memory_mine import parse_session_file
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||
f.write('{"role": "user", "content": "Hello"}\n')
|
||||
f.write('{"role": "assistant", "content": "Hi there"}\n')
|
||||
f.write('{"role": "tool", "name": "shell", "content": "ls output"}\n')
|
||||
f.write("\n") # blank line
|
||||
f.write("not json\n") # malformed
|
||||
path = Path(f.name)
|
||||
|
||||
turns = parse_session_file(path)
|
||||
assert len(turns) == 3
|
||||
assert turns[0]["role"] == "user"
|
||||
assert turns[1]["role"] == "assistant"
|
||||
assert turns[2]["role"] == "tool"
|
||||
path.unlink()
|
||||
|
||||
def test_summarize_session(self):
|
||||
from bin.memory_mine import summarize_session
|
||||
|
||||
turns = [
|
||||
{"role": "user", "content": "Check CI"},
|
||||
{"role": "assistant", "content": "Running CI check..."},
|
||||
{"role": "tool", "name": "shell", "content": "5 tests passed"},
|
||||
{"role": "assistant", "content": "CI is healthy"},
|
||||
]
|
||||
|
||||
summary = summarize_session(turns, "bezalel")
|
||||
assert "bezalel" in summary
|
||||
assert "Check CI" in summary
|
||||
assert "shell" in summary
|
||||
|
||||
def test_summarize_empty(self):
|
||||
from bin.memory_mine import summarize_session
|
||||
|
||||
assert summarize_session([], "test") == "Empty session."
|
||||
|
||||
def test_find_session_files(self, tmp_path):
|
||||
from bin.memory_mine import find_session_files
|
||||
|
||||
# Create some test files
|
||||
(tmp_path / "session1.jsonl").write_text("{}\n")
|
||||
(tmp_path / "session2.jsonl").write_text("{}\n")
|
||||
(tmp_path / "notes.txt").write_text("not a session")
|
||||
|
||||
files = find_session_files(tmp_path, days=365)
|
||||
assert len(files) == 2
|
||||
assert all(f.suffix == ".jsonl" for f in files)
|
||||
|
||||
def test_find_session_files_missing_dir(self):
|
||||
from bin.memory_mine import find_session_files
|
||||
|
||||
files = find_session_files(Path("/nonexistent/path"), days=7)
|
||||
assert files == []
|
||||
|
||||
def test_mine_session_dry_run(self, tmp_path):
|
||||
from bin.memory_mine import mine_session
|
||||
|
||||
session_file = tmp_path / "test.jsonl"
|
||||
session_file.write_text(
|
||||
'{"role": "user", "content": "Hello"}\n'
|
||||
'{"role": "assistant", "content": "Hi"}\n'
|
||||
)
|
||||
|
||||
result = mine_session(session_file, wing="wing_test", dry_run=True)
|
||||
assert result is None # dry run doesn't store
|
||||
|
||||
def test_mine_session_empty_file(self, tmp_path):
|
||||
from bin.memory_mine import mine_session
|
||||
|
||||
session_file = tmp_path / "empty.jsonl"
|
||||
session_file.write_text("")
|
||||
|
||||
result = mine_session(session_file, wing="wing_test")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test — full lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFullLifecycle:
|
||||
"""Test the full session lifecycle without a real MemPalace backend."""
|
||||
|
||||
def test_full_session_flow(self):
|
||||
hooks = MemoryHooks(agent_name="bezalel")
|
||||
|
||||
# Force memory unavailable
|
||||
hooks._memory = AgentMemory(agent_name="bezalel")
|
||||
hooks._memory._available = False
|
||||
|
||||
# 1. Session start
|
||||
context_block = hooks.on_session_start("What CI issues do I have?")
|
||||
assert isinstance(context_block, str)
|
||||
|
||||
# 2. User asks question
|
||||
hooks.on_user_turn("Check CI pipeline health")
|
||||
|
||||
# 3. Agent uses tool
|
||||
hooks.on_tool_call("shell", "pytest tests/", "12 passed")
|
||||
|
||||
# 4. Agent responds
|
||||
hooks.on_agent_turn("CI pipeline is healthy. All 12 tests passing.")
|
||||
|
||||
# 5. Important decision
|
||||
hooks.on_important_decision("Decided to keep current CI runner", room="forge")
|
||||
|
||||
# 6. More interaction
|
||||
hooks.on_user_turn("Good, check memory integration next")
|
||||
hooks.on_agent_turn("Will test agent.memory module")
|
||||
|
||||
# 7. Session end
|
||||
doc_id = hooks.on_session_end()
|
||||
assert hooks.is_active is False
|
||||
Reference in New Issue
Block a user