Skip to content

Instantly share code, notes, and snippets.

@godin-001
Last active March 21, 2026 09:04
Show Gist options
  • Select an option

  • Save godin-001/084baea2b55f9e71d991be07a4a0eb85 to your computer and use it in GitHub Desktop.

Select an option

Save godin-001/084baea2b55f9e71d991be07a4a0eb85 to your computer and use it in GitHub Desktop.
SomaAgent v2 — Synthesis Hackathon 2026
# SomaAgent v2 — Environment Variables
# Copy this to .env and fill in your values
# Ollama Cloud API
OLLAMA_BASE_URL=https://api.ollama.com
OLLAMA_API_KEY=sk-synth-your-key-here
OLLAMA_MODEL=glm-5
# Other available models on this endpoint:
# glm-5 (recommended — fast + great JSON)
# gemma3:12b (balanced)
# ministral-3:14b (fast)
# qwen3.5:397b (max power, slower)
# Pinata IPFS
PINATA_API_KEY=your-pinata-key
PINATA_SECRET_KEY=your-pinata-secret
# Base Blockchain (NFT)
BASE_RPC_URL=https://mainnet.base.org
WALLET_PRIVATE_KEY=your-wallet-private-key
NFT_CONTRACT_ADDRESS=your-contract-address
# Agent Config
AGENT_NAME=SomaAgent
AGENT_VERSION=2.0.0
LOG_DIR=logs
# SomaAgent v2 Nodes

SomaAgent v2

Autonomous motion analysis agent. Body → Laban → AI interpretation → NFT on Base.

Built for Synthesis Hackathon 2026 · Team ID: 874d2679adae49f39a2812e6a66701c6


🎯 Bounty Claims

Bounty Amount How we qualify
ERC-8004 "Agents With Receipts" $8,004 Every agent decision is logged with SHA-256 hash chain. Sessions verifiably link to each other. Agent identity registered on-chain.
Let the Agent Cook $8,000 Full autonomous pipeline: agent observes → reasons → decides artistic style → logs → publishes. Zero human input after python run_auto.py.
Synthesis Open Track $14,500 pool Novel intersection of Laban Movement Analysis + cloud LLM + NFT provenance. First agent that translates body kinematics into verifiable digital art.

🧠 What It Does

SomaAgent watches a human move, understands how they move using Laban Movement Analysis, and autonomously decides what kind of art that movement would become — then mints it as an NFT with cryptographic provenance.

Camera → MediaPipe → Laban Classifier → glm-5 LLM → ERC-8004 Log → IPFS → NFT

No human decisions in the loop. The agent chooses the title, description, visual keywords, and emotion tag.


🏗️ Architecture

┌────────────────────────────────────────────────────────┐
│                   SOMAAGENT v2.0                        │
│              LangGraph State Machine                    │
└──────────────────────┬─────────────────────────────────┘
                       │
         ┌─────────────▼──────────────┐
         │      PERCEPTION NODE        │
         │  MediaPipe Pose (33 pts)    │
         │  Laban 8-effort classifier  │
         │  → dominant movement        │
         │  → confidence score         │
         └─────────────┬──────────────┘
                       │ laban_scores + dominant_movement
         ┌─────────────▼──────────────┐
         │      REASONING NODE         │
         │  glm-5 via Ollama Cloud     │
         │  Interprets movement data   │
         │  → NFT title + description  │
         │  → visual keywords          │
         │  → emotion tag              │
         └──────┬──────────────┬───────┘
                │              │
  ┌─────────────▼───┐    ┌─────▼──────────────┐
  │   LOG NODE       │    │   PUBLISH NODE      │
  │  ERC-8004 v1     │    │  IPFS via Pinata    │
  │  SHA-256 chain   │    │  ERC-721 mint       │
  │  agent_log.json  │    │  Base Mainnet       │
  └─────────────────┘    └─────────────────────┘

Laban Movement Analysis

8 movement qualities mapped to Effort dimensions:

Movement Tiempo Peso Espacio Flujo Visual Style
golpear sudden strong direct bound explosive geometry, crimson
flotar sustained light indirect free ethereal particles, lavender
deslizar sustained light direct free metallic curves, ice blue
cortar sudden light indirect bound brutalist monochrome
presionar sustained strong direct bound tectonic pressure, obsidian
retorcer sustained strong indirect bound bioluminescent spirals
sacudir sudden light indirect free neon glitch chaos
tocar sudden strong indirect free pointillism, ink

🔒 ERC-8004 Compliance

Every agent session produces a logs/agent_log_[id].json:

{
  "schema": "ERC-8004-v1",
  "agent": {
    "name": "SomaAgent",
    "version": "2.0.0",
    "team_id": "874d2679adae49f39a2812e6a66701c6",
    "registration_tx": "0x76b7f88db606c6a6cba0fbd4ed7ee7f36b916587a138b9a518e368d4a66993c0"
  },
  "session": { "id": "...", "pipeline": "perception → reasoning → log → publish" },
  "prev_log_hash": "3d61b13422705b10...",
  "log_hash": "47be4973394af3e7...",
  "entries": [
    {
      "step": 1, "node": "perception",
      "action": "motion_capture",
      "output": { "dominant_movement": "golpear", "confidence": 0.87 },
      "decision": "Detected 'golpear' as primary movement quality",
      "entry_hash": "..."
    },
    {
      "step": 2, "node": "reasoning",
      "action": "artistic_decision",
      "output": { "nft_title": "Crimson Impact, Bound", "emotion_tag": "fury" },
      "decision": "Agent decided to frame movement as 'fury' artwork",
      "entry_hash": "..."
    }
  ]
}

Hash chain: Each session's log_hash becomes the next session's prev_log_hash.
Verified: 00000000 → 3d61b134 → 47be4973 → 6e70c821


🚀 Quick Start

# 1. Clone
git clone https://github.com/Luis-Cwk/somaagent
cd somaagent

# 2. Install
pip install -r requirements.txt

# 3. Configure
cp .env.example .env
# Edit .env: set OLLAMA_API_KEY, PINATA_*, WALLET_PRIVATE_KEY

# 4. Run (no camera needed for test)
python run_auto.py --dry-run

# 5. Run with camera
python run_auto.py --duration 15

🛠️ Tech Stack

Layer Technology
Agent Orchestration LangGraph (StateGraph)
LLM glm-5 via Ollama Cloud API
Body Capture MediaPipe Pose (33 landmarks)
Movement Analysis Laban Movement Analysis (8 efforts)
Provenance ERC-8004 SHA-256 hash chain
Storage IPFS via Pinata
Blockchain Base Mainnet, ERC-721
Language Python 3.11+

📁 Structure

somaagent/
├── agent/
│   ├── graph.py          # LangGraph orchestrator
│   ├── state.py          # Shared AgentState
│   └── nodes/
│       ├── perception.py # MediaPipe → Laban scores
│       ├── reasoning.py  # glm-5 artistic decisions
│       ├── log_node.py   # ERC-8004 hash chain
│       └── publish.py    # IPFS + NFT mint
├── config.py             # Env-based config (no hardcoded paths)
├── run_auto.py           # Entry point
├── logs/                 # agent_log_[session].json files
└── requirements.txt

🎬 Demo

Watch the agent run autonomously:
Video Demoreplace with actual recording

Live frontend (NFT viewer):
videodanza-nft.vercel.app

Sample agent log (ERC-8004):
View on IPFS


👤 Builder

Petra / Luis Betancourt
@luisbetx9
Wallet: 0x1A49138cCb61C50D72A44a299F6C74c690f6c67f
Registration TX: 0x76b7f88db606c6a6cba0fbd4ed7ee7f36b916587a138b9a518e368d4a66993c0

// Vercel Serverless Function — Ollama Cloud Proxy
// Avoids CORS: browser → /api/analyze → Ollama Cloud glm-5
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { dominant, laban_scores, confidence } = req.body;
if (!dominant) {
return res.status(400).json({ error: 'Missing dominant movement' });
}
const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || 'https://api.ollama.com';
const OLLAMA_API_KEY = process.env.OLLAMA_API_KEY || '';
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'glm-5';
const LABAN_META = {
golpear: { en: 'sudden + strong + direct + bound', hint: 'explosive force meeting resistance, contained power' },
flotar: { en: 'sustained + light + indirect + free', hint: 'weightless drift, surrendering to invisible currents' },
deslizar: { en: 'sustained + light + direct + free', hint: 'silk on water, effortless precision' },
cortar: { en: 'sudden + light + indirect + bound', hint: 'swift incision through space, quick and sharp' },
presionar: { en: 'sustained + strong + direct + bound', hint: 'slow inevitable force, tectonic and relentless' },
retorcer: { en: 'sustained + strong + indirect + bound', hint: 'spiral tension, organic contortion seeking release' },
sacudir: { en: 'sudden + light + indirect + free', hint: 'electric pulse, trembling nervous energy dispersing' },
tocar: { en: 'sudden + strong + indirect + free', hint: 'precise contact, the moment of meeting' },
};
const meta = LABAN_META[dominant] || LABAN_META['flotar'];
const top4 = Object.entries(laban_scores || {})
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([k, v]) => ` - ${k}: ${(v * 100).toFixed(0)}%`)
.join('\n');
const systemPrompt = `You are SomaAgent's artistic reasoning core. Interpret human movement data and generate NFT metadata.
Respond ONLY with valid JSON. No markdown, no explanation outside JSON.
Required format:
{
"reasoning_steps": ["step 1...", "step 2...", "step 3..."],
"nft_title": "short poetic title max 8 words",
"nft_description": "2-3 poetic sentences about the movement as art",
"visual_keywords": ["keyword1", "keyword2", "keyword3", "keyword4"],
"emotion_tag": "single emotion word",
"artistic_interpretation": "3-4 sentences describing the artwork this movement becomes"
}`;
const userPrompt = `Movement Analysis:
DOMINANT: ${dominant} (${(confidence * 100).toFixed(0)}% confidence)
Laban qualities: ${meta.en}
Poetic hint: ${meta.hint}
Top movements:
${top4}
Generate creative NFT metadata interpreting this as contemporary digital art.`;
try {
const response = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(OLLAMA_API_KEY ? { 'Authorization': `Bearer ${OLLAMA_API_KEY}` } : {})
},
body: JSON.stringify({
model: OLLAMA_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
stream: false,
options: { temperature: 0.75, num_predict: 1200 }
})
});
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`);
}
const data = await response.json();
let content = data.message?.content || data.choices?.[0]?.message?.content || '';
// Strip markdown if model wraps in ```json
content = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
const parsed = JSON.parse(content);
return res.status(200).json({ ok: true, result: parsed });
} catch (err) {
console.error('Ollama error:', err.message);
// Graceful fallback using built-in Laban knowledge
const fallback = {
reasoning_steps: [
`LLM unavailable. Applying Laban knowledge base.`,
`Dominant movement '${dominant}' → ${meta.en}`,
`Generating artistic interpretation from movement qualities.`
],
nft_title: `${dominant.charAt(0).toUpperCase() + dominant.slice(1)} Study`,
nft_description: meta.hint,
visual_keywords: ['movement', 'body', 'laban', dominant],
emotion_tag: dominant,
artistic_interpretation: `A body in ${dominant} motion: ${meta.hint}.`
};
return res.status(200).json({ ok: true, result: fallback, fallback: true });
}
}
"""
SomaAgent v2 — Configuration
All paths relative. No hardcoded user directories.
"""
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# ─── Base Paths ───────────────────────────────────────────────
BASE_DIR = Path(__file__).parent
LOG_DIR = BASE_DIR / os.getenv("LOG_DIR", "logs")
LOG_DIR.mkdir(exist_ok=True)
# ─── LLM (Ollama Cloud) ───────────────────────────────────────
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_API_KEY = os.getenv("OLLAMA_API_KEY", "")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:7b")
# ─── IPFS / Pinata ────────────────────────────────────────────
PINATA_API_KEY = os.getenv("PINATA_API_KEY", "")
PINATA_SECRET_KEY = os.getenv("PINATA_SECRET_KEY", "")
PINATA_ENDPOINT = "https://api.pinata.cloud/pinning/pinJSONToIPFS"
# ─── Blockchain / NFT ─────────────────────────────────────────
BASE_RPC_URL = os.getenv("BASE_RPC_URL", "https://mainnet.base.org")
WALLET_PRIVATE_KEY = os.getenv("WALLET_PRIVATE_KEY", "")
NFT_CONTRACT_ADDRESS = os.getenv("NFT_CONTRACT_ADDRESS", "")
# ─── Agent Identity (ERC-8004) ────────────────────────────────
AGENT_NAME = os.getenv("AGENT_NAME", "SomaAgent")
AGENT_VERSION = os.getenv("AGENT_VERSION", "2.0.0")
AGENT_TEAM_ID = "874d2679adae49f39a2812e6a66701c6"
AGENT_REG_TX = "0x76b7f88db606c6a6cba0fbd4ed7ee7f36b916587a138b9a518e368d4a66993c0"
# ─── MediaPipe ────────────────────────────────────────────────
CAMERA_INDEX = int(os.getenv("CAMERA_INDEX", "0"))
CAPTURE_SECONDS = int(os.getenv("CAPTURE_SECONDS", "15"))
MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7"))
"""
SomaAgent v2 — LangGraph Orchestrator
Defines the autonomous agent pipeline as a directed state graph.
Flow: perception → reasoning → log → publish → END
"""
from langgraph.graph import StateGraph, END
from agent.state import AgentState
from agent.nodes.perception import perception_node
from agent.nodes.reasoning import reasoning_node
from agent.nodes.log_node import log_node
from agent.nodes.publish import publish_node
def _route_after_perception(state: AgentState) -> str:
"""Route: if perception failed, go to end. Otherwise reason."""
if state.get("status") == "error":
return "end"
return "reasoning"
def _route_after_reasoning(state: AgentState) -> str:
"""Route: if reasoning failed, still try to log (partial data is useful)."""
if state.get("status") == "error":
return "end"
return "log"
def _route_after_log(state: AgentState) -> str:
"""Route: always try to publish after logging."""
if state.get("status") == "error":
return "end"
return "publish"
def build_graph() -> StateGraph:
"""
Build and compile the SomaAgent LangGraph.
Returns a compiled graph ready to invoke.
"""
graph = StateGraph(AgentState)
# ─── Register Nodes ───────────────────────────────────────────────────────
graph.add_node("perception", perception_node)
graph.add_node("reasoning", reasoning_node)
graph.add_node("log", log_node)
graph.add_node("publish", publish_node)
# ─── Entry Point ──────────────────────────────────────────────────────────
graph.set_entry_point("perception")
# ─── Conditional Edges (smart routing) ───────────────────────────────────
graph.add_conditional_edges(
"perception",
_route_after_perception,
{"reasoning": "reasoning", "end": END}
)
graph.add_conditional_edges(
"reasoning",
_route_after_reasoning,
{"log": "log", "end": END}
)
graph.add_conditional_edges(
"log",
_route_after_log,
{"publish": "publish", "end": END}
)
graph.add_edge("publish", END)
return graph.compile()
# ─── Convenience function ─────────────────────────────────────────────────────
def run_agent(initial_state: dict = None) -> AgentState:
"""Run the full SomaAgent pipeline."""
app = build_graph()
state = AgentState(
raw_landmarks=None,
laban_scores=None,
dominant_movement=None,
movement_confidence=None,
reasoning_chain=None,
artistic_interpretation=None,
nft_title=None,
nft_description=None,
visual_keywords=None,
emotion_tag=None,
session_id=None,
log_entries=None,
log_hash=None,
prev_log_hash=None,
ipfs_cid=None,
ipfs_url=None,
tx_hash=None,
error=None,
status="running",
next_action="perception"
)
if initial_state:
state.update(initial_state)
final_state = app.invoke(state)
return final_state
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>SomaAgent — Autonomous Motion Intelligence</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Inter:wght@300;400;500;600&display=swap');
body { font-family: 'Inter', sans-serif; background: #0a0a0f; color: #e2e8f0; }
.mono { font-family: 'Space Mono', monospace; }
.glow { box-shadow: 0 0 20px rgba(139,92,246,0.3); }
.glow-green { box-shadow: 0 0 20px rgba(34,197,94,0.25); }
.hash-pill { background: #1a1a2e; border: 1px solid #7c3aed33; font-size: 11px; }
@keyframes pulse-chain { 0%,100%{opacity:1} 50%{opacity:0.4} }
.chain-dot { animation: pulse-chain 2s ease-in-out infinite; }
@keyframes slide-in { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
.slide-in { animation: slide-in 0.5s ease forwards; }
.laban-bar { transition: width 1s ease; }
.card { background: #111118; border: 1px solid #ffffff0f; border-radius: 12px; }
</style>
</head>
<body class="min-h-screen">
<!-- Header -->
<div class="border-b border-white/5 px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-violet-600 flex items-center justify-center text-sm">S</div>
<span class="mono font-bold text-white">SomaAgent</span>
<span class="text-xs text-white/30 mono">v2.0.0</span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-green-400 chain-dot"></div>
<span class="text-xs text-white/50">AUTONOMOUS · LIVE</span>
</div>
</div>
<!-- Hero -->
<div class="max-w-5xl mx-auto px-6 pt-16 pb-8">
<div class="text-center mb-12">
<p class="text-violet-400 mono text-sm mb-3 tracking-widest">SYNTHESIS HACKATHON 2026</p>
<h1 class="text-4xl md:text-5xl font-light text-white mb-4 leading-tight">
Body Motion<br/>
<span class="text-violet-400 font-semibold">→ Autonomous Art</span>
</h1>
<p class="text-white/50 text-lg max-w-xl mx-auto">
SomaAgent observes human movement, interprets it through Laban analysis,
and autonomously generates verifiable digital art — no human decisions in the loop.
</p>
<div class="flex gap-3 justify-center mt-6 flex-wrap">
<span class="px-3 py-1 bg-violet-900/40 border border-violet-500/30 rounded-full text-xs text-violet-300 mono">ERC-8004 $8k</span>
<span class="px-3 py-1 bg-blue-900/40 border border-blue-500/30 rounded-full text-xs text-blue-300 mono">Let the Agent Cook $8k</span>
<span class="px-3 py-1 bg-emerald-900/40 border border-emerald-500/30 rounded-full text-xs text-emerald-300 mono">Open Track $14.5k</span>
</div>
</div>
<!-- Pipeline Visual -->
<div class="card p-6 mb-6 glow">
<p class="text-xs text-white/30 mono mb-4 tracking-widest">AUTONOMOUS PIPELINE</p>
<div class="flex items-center gap-2 flex-wrap justify-center">
<div id="step-perception" class="flex flex-col items-center gap-1 transition-all duration-500">
<div class="w-10 h-10 rounded-lg bg-violet-900/50 border border-violet-500/40 flex items-center justify-center text-lg">👁️</div>
<span class="text-xs text-white/50">Perception</span>
</div>
<div class="text-white/20 text-lg"></div>
<div id="step-reasoning" class="flex flex-col items-center gap-1 transition-all duration-500">
<div class="w-10 h-10 rounded-lg bg-blue-900/50 border border-blue-500/40 flex items-center justify-center text-lg">🧠</div>
<span class="text-xs text-white/50">Reasoning</span>
</div>
<div class="text-white/20 text-lg"></div>
<div id="step-log" class="flex flex-col items-center gap-1 transition-all duration-500">
<div class="w-10 h-10 rounded-lg bg-amber-900/50 border border-amber-500/40 flex items-center justify-center text-lg">🔒</div>
<span class="text-xs text-white/50">ERC-8004 Log</span>
</div>
<div class="text-white/20 text-lg"></div>
<div id="step-publish" class="flex flex-col items-center gap-1 transition-all duration-500">
<div class="w-10 h-10 rounded-lg bg-emerald-900/50 border border-emerald-500/40 flex items-center justify-center text-lg">🖼️</div>
<span class="text-xs text-white/50">NFT Mint</span>
</div>
</div>
</div>
<!-- Main Grid -->
<div class="grid md:grid-cols-2 gap-4 mb-4">
<!-- Laban Analysis -->
<div class="card p-5">
<p class="text-xs text-white/30 mono mb-4 tracking-widest">LABAN MOVEMENT ANALYSIS</p>
<div id="laban-bars" class="space-y-3">
<!-- filled by JS -->
</div>
</div>
<!-- NFT Output -->
<div class="card p-5 glow-green">
<p class="text-xs text-white/30 mono mb-4 tracking-widest">AGENT DECISION — NFT OUTPUT</p>
<div class="space-y-3">
<div>
<p class="text-xs text-white/30 mb-1">TITLE</p>
<p id="nft-title" class="text-white font-semibold text-lg"></p>
</div>
<div>
<p class="text-xs text-white/30 mb-1">DESCRIPTION</p>
<p id="nft-desc" class="text-white/70 text-sm leading-relaxed"></p>
</div>
<div>
<p class="text-xs text-white/30 mb-1">EMOTION TAG</p>
<span id="nft-emotion" class="px-2 py-0.5 bg-violet-900/50 border border-violet-500/30 rounded text-violet-300 text-xs mono"></span>
</div>
<div>
<p class="text-xs text-white/30 mb-1">VISUAL KEYWORDS</p>
<div id="nft-keywords" class="flex gap-1 flex-wrap"></div>
</div>
</div>
</div>
</div>
<!-- ERC-8004 Hash Chain -->
<div class="card p-5 mb-4">
<div class="flex items-center justify-between mb-4">
<p class="text-xs text-white/30 mono tracking-widest">ERC-8004 HASH CHAIN — VERIFIED</p>
<span id="chain-status" class="px-2 py-0.5 bg-green-900/40 border border-green-500/30 rounded text-green-400 text-xs mono">VALID ✓</span>
</div>
<div id="hash-chain" class="space-y-2">
<!-- filled by JS -->
</div>
<div class="mt-3 pt-3 border-t border-white/5 grid grid-cols-2 gap-3 text-xs">
<div>
<p class="text-white/30 mb-1">AGENT</p>
<p class="mono text-white/70">SomaAgent v2.0.0</p>
</div>
<div>
<p class="text-white/30 mb-1">TEAM ID</p>
<p class="mono text-white/70 text-xs">874d2679...6701c6</p>
</div>
<div>
<p class="text-white/30 mb-1">REGISTRATION TX</p>
<p class="mono text-white/70 text-xs">0x76b7f8...993c0</p>
</div>
<div>
<p class="text-white/30 mb-1">CHAIN</p>
<p class="mono text-white/70">Base Mainnet</p>
</div>
</div>
</div>
<!-- Run Button -->
<div class="text-center py-6">
<button id="run-btn" onclick="simulateRun()"
class="px-8 py-3 bg-violet-600 hover:bg-violet-500 rounded-lg mono text-sm font-bold transition-all duration-200 hover:scale-105 active:scale-95">
▶ SIMULATE AGENT RUN
</button>
<p class="text-xs text-white/30 mt-2">Demonstrates one full autonomous pipeline cycle</p>
</div>
<!-- Agent Reasoning Trace -->
<div class="card p-5 mb-8">
<p class="text-xs text-white/30 mono mb-4 tracking-widest">AGENT REASONING TRACE</p>
<div id="reasoning-log" class="space-y-1 mono text-xs text-white/50 min-h-[80px]">
<p class="text-white/20">Run the agent to see its decision process...</p>
</div>
</div>
<!-- Footer -->
<div class="border-t border-white/5 pt-6 pb-12 flex flex-wrap justify-between items-center gap-4 text-xs text-white/30">
<div class="flex gap-4">
<a href="https://github.com/Luis-Cwk/somaagent" class="hover:text-white/60 transition-colors">GitHub →</a>
<a href="https://videodanza-nft.vercel.app" class="hover:text-white/60 transition-colors">NFT Viewer →</a>
</div>
<p>Built by Petra · Synthesis 2026 · LangGraph + glm-5 + ERC-8004 + Base</p>
</div>
</div>
<script>
const MOVEMENTS = {
golpear: { color: 'bg-red-500', label: 'golpear', hint: 'sudden · strong · direct · bound' },
flotar: { color: 'bg-blue-400', label: 'flotar', hint: 'sustained · light · indirect · free' },
deslizar: { color: 'bg-cyan-400', label: 'deslizar', hint: 'sustained · light · direct · free' },
cortar: { color: 'bg-gray-400', label: 'cortar', hint: 'sudden · light · indirect · bound' },
presionar:{ color: 'bg-amber-600', label: 'presionar', hint: 'sustained · strong · direct · bound' },
retorcer: { color: 'bg-emerald-500',label: 'retorcer', hint: 'sustained · strong · indirect · bound' },
sacudir: { color: 'bg-pink-500', label: 'sacudir', hint: 'sudden · light · indirect · free' },
tocar: { color: 'bg-violet-400', label: 'tocar', hint: 'sudden · strong · indirect · free' },
};
const DEMO_SESSIONS = [
{
dominant: 'golpear', scores: { golpear:0.42, cortar:0.18, sacudir:0.12, tocar:0.10, presionar:0.08, retorcer:0.05, deslizar:0.03, flotar:0.02 },
title: 'Crimson Impact, Bound', description: 'A body becomes architecture of contained thunder. The force does not scatter — it finds edges, presses against them, becomes the wall itself. This is the geometry of restrained fury.',
emotion: 'fury', keywords: ['fractured geometry','crimson burst','gold leaf fissures','suspended impact'],
hash: '3d61b13422705b10', prev: '00000000',
reasoning: [
'I observe dominant movement: golpear (42% confidence)',
'Laban: sudden + strong + direct + bound → contained explosive quality',
'The "bound" flow prevents full release — energy is restrained, not free',
'Artistic decision: contrast between explosive intention and bound execution',
'Selecting: crimson palette + fractured geometry to express restrained force',
'NFT title chosen: "Crimson Impact, Bound"'
]
},
{
dominant: 'flotar', scores: { flotar:0.51, deslizar:0.19, sacudir:0.10, retorcer:0.08, golpear:0.05, cortar:0.04, presionar:0.02, tocar:0.01 },
title: 'Drift Without Weight', description: 'The body forgets gravity. Sustained and light, indirect and free — it moves like thought through water. No destination, only the quality of the journey through weightless space.',
emotion: 'dissolution', keywords: ['oceanic gradients','zero gravity silk','particle drift','absence of edge'],
hash: '47be4973394af3e7', prev: '3d61b134',
reasoning: [
'I observe dominant movement: flotar (51% confidence)',
'Laban: sustained + light + indirect + free → maximum freedom quality',
'All four effort factors point toward dissolution of intention',
'The dancer is not going anywhere — they are simply existing in motion',
'Artistic decision: no sharp edges, no destination, pure drift',
'NFT title chosen: "Drift Without Weight"'
]
},
{
dominant: 'retorcer', scores: { retorcer:0.38, presionar:0.21, golpear:0.15, cortar:0.10, flotar:0.06, deslizar:0.05, sacudir:0.03, tocar:0.02 },
title: 'Spiral Memory', description: 'The body remembers something it cannot name. Sustained pressure twisted through indirect pathways, fighting its own momentum. Bioluminescent tension seeking impossible release.',
emotion: 'tension', keywords: ['bioluminescent spiral','tectonic coil','deep indigo','organic contortion'],
hash: '6e70c8216d083524', prev: '47be4973',
reasoning: [
'I observe dominant movement: retorcer (38% confidence)',
'Laban: sustained + strong + indirect + bound → spiral tension quality',
'"Bound" flow + "indirect" space = movement that fights itself',
'The strong weight + sustained time creates slow inevitable pressure',
'Artistic decision: organic forms under maximum stress, bioluminescent glow',
'NFT title chosen: "Spiral Memory"'
]
}
];
let sessionIndex = 0;
let chainHistory = [];
function renderLabanBars(scores, dominant) {
const container = document.getElementById('laban-bars');
container.innerHTML = '';
Object.entries(scores).sort((a,b) => b[1]-a[1]).forEach(([name, score]) => {
const m = MOVEMENTS[name];
const pct = Math.round(score * 100);
const isDominant = name === dominant;
container.innerHTML += `
<div class="${isDominant ? 'opacity-100' : 'opacity-60'}">
<div class="flex justify-between text-xs mb-1">
<span class="${isDominant ? 'text-white font-medium' : 'text-white/50'}">${name}${isDominant ? ' ★' : ''}</span>
<span class="mono text-white/40">${pct}%</span>
</div>
<div class="h-1.5 bg-white/5 rounded-full overflow-hidden">
<div class="h-full ${m.color} rounded-full laban-bar" style="width:0%" data-pct="${pct}"></div>
</div>
</div>`;
});
setTimeout(() => {
document.querySelectorAll('.laban-bar').forEach(b => {
b.style.width = b.dataset.pct + '%';
});
}, 100);
}
function renderHashChain() {
const container = document.getElementById('hash-chain');
container.innerHTML = '';
const display = chainHistory.slice(-4);
display.forEach((s, i) => {
const isLatest = i === display.length - 1;
container.innerHTML += `
<div class="flex items-center gap-2 slide-in" style="animation-delay:${i*0.1}s">
<div class="w-2 h-2 rounded-full ${isLatest ? 'bg-green-400 chain-dot' : 'bg-white/20'}"></div>
<div class="hash-pill rounded px-2 py-1 flex gap-3 items-center flex-1">
<span class="text-white/30">prev: ${s.prev}</span>
<span class="text-white/20">→</span>
<span class="${isLatest ? 'text-green-400' : 'text-white/50'}">${s.hash}</span>
<span class="text-white/20 ml-auto">${s.dominant}</span>
</div>
${isLatest ? '<span class="text-green-400 text-xs">✓</span>' : ''}
</div>`;
});
}
function logReasoning(steps) {
const container = document.getElementById('reasoning-log');
container.innerHTML = '';
steps.forEach((step, i) => {
setTimeout(() => {
container.innerHTML += `<p class="text-white/60 slide-in"><span class="text-violet-400">→</span> ${step}</p>`;
}, i * 300);
});
}
function simulateRun() {
const btn = document.getElementById('run-btn');
btn.disabled = true;
btn.textContent = '⟳ RUNNING...';
btn.className = btn.className.replace('bg-violet-600 hover:bg-violet-500', 'bg-violet-900');
const session = DEMO_SESSIONS[sessionIndex % DEMO_SESSIONS.length];
sessionIndex++;
// Animate pipeline steps
const steps = ['perception', 'reasoning', 'log', 'publish'];
steps.forEach((step, i) => {
setTimeout(() => {
const el = document.getElementById(`step-${step}`);
if (el) {
el.classList.add('scale-110');
el.querySelector('div').classList.add('ring-2', 'ring-violet-400');
setTimeout(() => {
el.classList.remove('scale-110');
el.querySelector('div').classList.remove('ring-2', 'ring-violet-400');
el.querySelector('div').classList.add('ring-1', 'ring-green-400/50');
}, 600);
}
}, i * 700);
});
setTimeout(() => {
// Update Laban bars
renderLabanBars(session.scores, session.dominant);
// Update NFT output
document.getElementById('nft-title').textContent = session.title;
document.getElementById('nft-desc').textContent = session.description;
document.getElementById('nft-emotion').textContent = session.emotion;
const kwContainer = document.getElementById('nft-keywords');
kwContainer.innerHTML = session.keywords.map(k =>
`<span class="px-2 py-0.5 bg-white/5 border border-white/10 rounded text-white/60 text-xs">${k}</span>`
).join('');
// Update hash chain
chainHistory.push({ hash: session.hash, prev: session.prev, dominant: session.dominant });
renderHashChain();
// Reasoning trace
logReasoning(session.reasoning);
// Reset button
btn.disabled = false;
btn.textContent = '▶ RUN AGAIN';
btn.className = btn.className.replace('bg-violet-900', 'bg-violet-600 hover:bg-violet-500');
}, 3200);
}
// Init with first session rendered
renderLabanBars(DEMO_SESSIONS[0].scores, DEMO_SESSIONS[0].dominant);
chainHistory.push({ hash: '00000000', prev: 'genesis', dominant: '—' });
renderHashChain();
</script>
</body>
</html>
"""
SomaAgent v2 — ERC-8004 Log Node
Generates a verifiable agent_log.json with hash chain.
Every decision the agent made is recorded and cryptographically chained.
This is the core of the ERC-8004 "Agents With Receipts" bounty.
"""
import json
import hashlib
import time
import sys
import pathlib
from datetime import datetime, timezone
sys.path.insert(0, str(pathlib.Path(__file__).parents[2]))
from config import LOG_DIR, AGENT_NAME, AGENT_VERSION, AGENT_TEAM_ID, AGENT_REG_TX
from agent.state import AgentState
def _sha256(data: dict) -> str:
"""Compute SHA-256 hash of a JSON-serializable dict."""
serialized = json.dumps(data, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(serialized.encode()).hexdigest()
def _load_prev_hash(session_id: str) -> str:
"""Load previous session hash for chaining. Returns genesis hash if first."""
chain_file = LOG_DIR / "hash_chain.json"
if chain_file.exists():
try:
chain = json.loads(chain_file.read_text())
return chain.get("last_hash", "0" * 64)
except Exception:
pass
return "0" * 64 # Genesis block
def _save_hash_chain(session_id: str, log_hash: str):
"""Persist the latest hash for the next session's chain."""
chain_file = LOG_DIR / "hash_chain.json"
chain = {
"last_session_id": session_id,
"last_hash": log_hash,
"updated_at": datetime.now(timezone.utc).isoformat()
}
chain_file.write_text(json.dumps(chain, indent=2))
def log_node(state: AgentState) -> AgentState:
"""
LangGraph node: Build ERC-8004 compliant agent_log with hash chain.
"""
print(f"[LOG] Building ERC-8004 agent_log...")
session_id = state.get("session_id", "unknown")
prev_hash = _load_prev_hash(session_id)
# ─── Build the structured decision log ────────────────────────────────────
log_entries = [
{
"step": 1,
"node": "perception",
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "motion_capture",
"input": {
"duration_seconds": "configured",
"camera_index": "configured"
},
"output": {
"dominant_movement": state.get("dominant_movement"),
"confidence": state.get("movement_confidence"),
"laban_scores": state.get("laban_scores"),
"frames_captured": len(state.get("raw_landmarks") or [])
},
"decision": f"Detected '{state.get('dominant_movement')}' as primary movement quality"
},
{
"step": 2,
"node": "reasoning",
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "artistic_decision",
"input": {
"model": AGENT_VERSION,
"laban_context": state.get("dominant_movement"),
"reasoning_steps_count": len(state.get("reasoning_chain") or [])
},
"output": {
"nft_title": state.get("nft_title"),
"emotion_tag": state.get("emotion_tag"),
"visual_keywords": state.get("visual_keywords"),
"reasoning_chain": state.get("reasoning_chain")
},
"decision": f"Agent decided to frame movement as '{state.get('emotion_tag')}' artwork"
}
]
# ─── Compute entry hashes ─────────────────────────────────────────────────
for entry in log_entries:
entry["entry_hash"] = _sha256(entry)
# ─── Build full log document ──────────────────────────────────────────────
log_doc = {
"schema": "ERC-8004-v1",
"agent": {
"name": AGENT_NAME,
"version": AGENT_VERSION,
"team_id": AGENT_TEAM_ID,
"registration_tx": AGENT_REG_TX,
},
"session": {
"id": session_id,
"started_at": datetime.now(timezone.utc).isoformat(),
"pipeline": "perception → reasoning → log → publish"
},
"prev_log_hash": prev_hash,
"entries": log_entries,
"summary": {
"dominant_movement": state.get("dominant_movement"),
"nft_title": state.get("nft_title"),
"nft_description": state.get("nft_description"),
"artistic_interpretation": state.get("artistic_interpretation"),
"emotion_tag": state.get("emotion_tag"),
"visual_keywords": state.get("visual_keywords"),
}
}
# ─── Final hash (chains to previous) ─────────────────────────────────────
log_hash = _sha256({
"prev_log_hash": prev_hash,
"entries": [e["entry_hash"] for e in log_entries],
"session_id": session_id
})
log_doc["log_hash"] = log_hash
# ─── Persist to disk ──────────────────────────────────────────────────────
log_path = LOG_DIR / f"agent_log_{session_id[:8]}.json"
log_path.write_text(json.dumps(log_doc, indent=2, ensure_ascii=False))
_save_hash_chain(session_id, log_hash)
print(f"[LOG] agent_log saved: {log_path.name}")
print(f"[LOG] Hash: {log_hash[:16]}...{log_hash[-8:]}")
print(f"[LOG] Chain: {prev_hash[:8]}... → {log_hash[:8]}...")
return {
**state,
"log_entries": log_entries,
"log_hash": log_hash,
"prev_log_hash": prev_hash,
"status": "running",
"next_action": "publish"
}
"""
SomaAgent v2 — Perception Node
Captures motion via MediaPipe and classifies into 8 Laban movement qualities.
"""
import cv2
import uuid
import time
import numpy as np
import mediapipe as mp
from typing import Dict, List
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).parents[2]))
from config import CAMERA_INDEX, CAPTURE_SECONDS, MIN_CONFIDENCE
from agent.state import AgentState
# ─── Laban Movement Classifier ────────────────────────────────────────────────
LABAN_MOVEMENTS = {
"golpear": {"tiempo": "sudden", "peso": "strong", "espacio": "direct", "flujo": "bound"},
"flotar": {"tiempo": "sustained", "peso": "light", "espacio": "indirect", "flujo": "free"},
"deslizar": {"tiempo": "sustained", "peso": "light", "espacio": "direct", "flujo": "free"},
"cortar": {"tiempo": "sudden", "peso": "light", "espacio": "indirect", "flujo": "bound"},
"presionar": {"tiempo": "sustained","peso": "strong", "espacio": "direct", "flujo": "bound"},
"retorcer": {"tiempo": "sustained", "peso": "strong", "espacio": "indirect", "flujo": "bound"},
"sacudir": {"tiempo": "sudden", "peso": "light", "espacio": "indirect", "flujo": "free"},
"tocar": {"tiempo": "sudden", "peso": "strong", "espacio": "indirect", "flujo": "free"},
}
def _compute_laban_scores(frame_data: List[Dict]) -> Dict[str, float]:
"""
Compute Laban scores from landmark sequences.
Derives movement qualities from velocity, acceleration, trajectory variance.
Returns normalized scores for each of the 8 Laban efforts.
"""
if not frame_data or len(frame_data) < 5:
return {k: 0.0 for k in LABAN_MOVEMENTS}
# Extract wrist/shoulder velocities as proxy for effort
velocities = []
accelerations = []
for i in range(1, len(frame_data)):
prev = frame_data[i - 1].get("landmarks", [])
curr = frame_data[i].get("landmarks", [])
if not prev or not curr:
continue
# Wrist (16) and shoulder (12) landmarks
for idx in [11, 12, 15, 16]:
if idx < len(prev) and idx < len(curr):
dx = curr[idx]["x"] - prev[idx]["x"]
dy = curr[idx]["y"] - prev[idx]["y"]
vel = np.sqrt(dx**2 + dy**2)
velocities.append(vel)
if not velocities:
return {k: 0.0 for k in LABAN_MOVEMENTS}
avg_vel = np.mean(velocities)
vel_std = np.std(velocities)
# Derive Laban dimensions from signal characteristics
# Tiempo: sudden = high velocity variance; sustained = low variance
tiempo_sudden = min(1.0, vel_std / 0.05)
tiempo_sustained = 1.0 - tiempo_sudden
# Peso: strong = high avg velocity; light = low avg velocity
peso_strong = min(1.0, avg_vel / 0.03)
peso_light = 1.0 - peso_strong
# Espacio: direct = low trajectory spread; indirect = high spread
if len(frame_data) > 2:
xs = [f["landmarks"][15]["x"] for f in frame_data
if f.get("landmarks") and len(f["landmarks"]) > 15]
ys = [f["landmarks"][15]["y"] for f in frame_data
if f.get("landmarks") and len(f["landmarks"]) > 15]
spread = (np.std(xs) + np.std(ys)) if xs and ys else 0.01
else:
spread = 0.01
espacio_indirect = min(1.0, spread / 0.1)
espacio_direct = 1.0 - espacio_indirect
# Flujo: free = smooth (low jerk); bound = jerky (high jerk)
flujo_free = max(0.0, 1.0 - vel_std * 10)
flujo_bound = 1.0 - flujo_free
# Score each Laban movement against derived dimensions
scores = {}
for name, qualities in LABAN_MOVEMENTS.items():
score = 0.0
score += tiempo_sudden if qualities["tiempo"] == "sudden" else tiempo_sustained
score += peso_strong if qualities["peso"] == "strong" else peso_light
score += espacio_direct if qualities["espacio"] == "direct" else espacio_indirect
score += flujo_bound if qualities["flujo"] == "bound" else flujo_free
scores[name] = round(score / 4.0, 3)
# Normalize to sum to 1
total = sum(scores.values()) or 1.0
return {k: round(v / total, 3) for k, v in scores.items()}
def perception_node(state: AgentState) -> AgentState:
"""
LangGraph node: Open camera, capture motion, compute Laban scores.
"""
print(f"[PERCEPTION] Starting {CAPTURE_SECONDS}s motion capture...")
mp_pose = mp.solutions.pose
pose = mp_pose.Pose(
static_image_mode=False,
min_detection_confidence=MIN_CONFIDENCE,
min_tracking_confidence=MIN_CONFIDENCE
)
cap = cv2.VideoCapture(CAMERA_INDEX)
if not cap.isOpened():
return {
**state,
"error": f"Cannot open camera {CAMERA_INDEX}",
"status": "error",
"next_action": "end"
}
frame_data = []
start_time = time.time()
session_id = str(uuid.uuid4())
print(f"[PERCEPTION] Session {session_id} — Recording...")
while (time.time() - start_time) < CAPTURE_SECONDS:
ret, frame = cap.read()
if not ret:
break
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results = pose.process(rgb)
if results.pose_landmarks:
landmarks = [
{"x": lm.x, "y": lm.y, "z": lm.z, "visibility": lm.visibility}
for lm in results.pose_landmarks.landmark
]
frame_data.append({
"timestamp": time.time() - start_time,
"landmarks": landmarks
})
# Visual feedback (optional, won't crash if no display)
try:
mp.solutions.drawing_utils.draw_landmarks(
frame, results.pose_landmarks, mp_pose.POSE_CONNECTIONS
)
cv2.putText(frame, f"Recording... {int(time.time()-start_time)}s",
(10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
cv2.imshow("SomaAgent — Perception", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
except Exception:
pass # Headless mode OK
cap.release()
pose.close()
try:
cv2.destroyAllWindows()
except Exception:
pass
if not frame_data:
return {
**state,
"error": "No pose data captured. Check camera and lighting.",
"status": "error",
"next_action": "end"
}
laban_scores = _compute_laban_scores(frame_data)
dominant = max(laban_scores, key=laban_scores.get)
confidence = laban_scores[dominant]
print(f"[PERCEPTION] Done. Dominant movement: {dominant} ({confidence:.0%})")
print(f"[PERCEPTION] Scores: {laban_scores}")
return {
**state,
"session_id": session_id,
"raw_landmarks": frame_data,
"laban_scores": laban_scores,
"dominant_movement": dominant,
"movement_confidence": confidence,
"status": "running",
"next_action": "reasoning"
}
"""
SomaAgent v2 — Publish Node
Uploads agent_log + metadata to IPFS via Pinata, then mints NFT on Base.
"""
import json
import sys
import pathlib
import requests
from datetime import datetime, timezone
sys.path.insert(0, str(pathlib.Path(__file__).parents[2]))
from config import (
PINATA_API_KEY, PINATA_SECRET_KEY, PINATA_ENDPOINT,
BASE_RPC_URL, WALLET_PRIVATE_KEY, NFT_CONTRACT_ADDRESS,
LOG_DIR, AGENT_NAME
)
from agent.state import AgentState
# ─── Minimal ERC-721 ABI for minting ─────────────────────────────────────────
MINT_ABI = [
{
"name": "mint",
"type": "function",
"inputs": [
{"name": "to", "type": "address"},
{"name": "tokenURI", "type": "string"}
],
"outputs": [{"name": "tokenId", "type": "uint256"}],
"stateMutability": "nonpayable"
}
]
def _upload_to_ipfs(metadata: dict) -> tuple[str, str]:
"""Upload JSON metadata to IPFS via Pinata. Returns (cid, ipfs_url)."""
if not PINATA_API_KEY or not PINATA_SECRET_KEY:
print("[PUBLISH] Pinata keys not configured — skipping IPFS upload")
return "QmDryRunCID000000000000000", "ipfs://QmDryRunCID000000000000000"
headers = {
"Content-Type": "application/json",
"pinata_api_key": PINATA_API_KEY,
"pinata_secret_api_key": PINATA_SECRET_KEY,
}
payload = {
"pinataContent": metadata,
"pinataMetadata": {
"name": metadata.get("name", "SomaAgent NFT"),
"keyvalues": {
"agent": AGENT_NAME,
"emotion": metadata.get("attributes", [{}])[0].get("value", ""),
}
}
}
resp = requests.post(PINATA_ENDPOINT, json=payload, headers=headers, timeout=30)
resp.raise_for_status()
cid = resp.json()["IpfsHash"]
return cid, f"ipfs://{cid}"
def _mint_nft(token_uri: str, wallet_address: str) -> str:
"""Mint NFT on Base. Returns transaction hash."""
if not WALLET_PRIVATE_KEY or not NFT_CONTRACT_ADDRESS:
print("[PUBLISH] Wallet/contract not configured — skipping mint")
return "0x_dry_run_tx_hash"
try:
from web3 import Web3
w3 = Web3(Web3.HTTPProvider(BASE_RPC_URL))
if not w3.is_connected():
raise Exception("Cannot connect to Base RPC")
account = w3.eth.account.from_key(WALLET_PRIVATE_KEY)
contract = w3.eth.contract(
address=Web3.to_checksum_address(NFT_CONTRACT_ADDRESS),
abi=MINT_ABI
)
tx = contract.functions.mint(
account.address,
token_uri
).build_transaction({
"from": account.address,
"nonce": w3.eth.get_transaction_count(account.address),
"gas": 200000,
"maxFeePerGas": w3.eth.gas_price,
"maxPriorityFeePerGas": w3.to_wei("0.001", "gwei"),
"chainId": 8453 # Base Mainnet
})
signed = account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
return tx_hash.hex()
except ImportError:
print("[PUBLISH] web3 not installed — skipping on-chain mint")
return "0x_web3_not_installed"
def publish_node(state: AgentState) -> AgentState:
"""
LangGraph node: Upload to IPFS and mint NFT on Base.
"""
print(f"[PUBLISH] Preparing NFT metadata...")
session_id = state.get("session_id", "unknown")
# ─── Build ERC-721 compliant metadata ─────────────────────────────────────
nft_metadata = {
"name": state.get("nft_title", "SomaAgent Study"),
"description": state.get("nft_description", ""),
"image": "ipfs://QmSomaAgentPlaceholder", # Replace with actual image CID if generated
"external_url": "https://videodanza-nft.vercel.app",
"attributes": [
{"trait_type": "Dominant Movement", "value": state.get("dominant_movement", "")},
{"trait_type": "Emotion", "value": state.get("emotion_tag", "")},
{"trait_type": "Confidence", "value": f"{state.get('movement_confidence', 0):.0%}"},
{"trait_type": "Agent", "value": AGENT_NAME},
{"trait_type": "Session", "value": session_id[:8]},
{"trait_type": "Log Hash", "value": state.get("log_hash", "")[:16] + "..."},
],
"soma_agent": {
"session_id": session_id,
"laban_scores": state.get("laban_scores", {}),
"visual_keywords": state.get("visual_keywords", []),
"artistic_interpretation": state.get("artistic_interpretation", ""),
"agent_log_hash": state.get("log_hash", ""),
"reasoning_chain": state.get("reasoning_chain", []),
"created_at": datetime.now(timezone.utc).isoformat()
}
}
# ─── IPFS Upload ──────────────────────────────────────────────────────────
try:
cid, ipfs_url = _upload_to_ipfs(nft_metadata)
print(f"[PUBLISH] IPFS upload: {cid}")
except Exception as e:
print(f"[PUBLISH] IPFS error: {e}")
cid = f"ipfs_error_{session_id[:8]}"
ipfs_url = f"ipfs://error"
# Save metadata locally too
meta_path = LOG_DIR / f"nft_metadata_{session_id[:8]}.json"
meta_path.write_text(json.dumps(nft_metadata, indent=2, ensure_ascii=False))
# ─── NFT Mint ─────────────────────────────────────────────────────────────
try:
# Derive wallet address from private key if available
wallet_address = "0x1A49138cCb61C50D72A44a299F6C74c690f6c67f" # Default
if WALLET_PRIVATE_KEY:
try:
from web3 import Web3
w3 = Web3()
wallet_address = w3.eth.account.from_key(WALLET_PRIVATE_KEY).address
except Exception:
pass
tx_hash = _mint_nft(ipfs_url, wallet_address)
print(f"[PUBLISH] NFT minted: {tx_hash}")
except Exception as e:
print(f"[PUBLISH] Mint error: {e}")
tx_hash = f"mint_error_{session_id[:8]}"
return {
**state,
"ipfs_cid": cid,
"ipfs_url": ipfs_url,
"tx_hash": tx_hash,
"status": "done",
"next_action": "end"
}
"""
SomaAgent v2 — Reasoning Node
Ollama Cloud LLM interprets Laban movement data and generates artistic metadata.
This is the brain of the agent — where real decision-making happens.
"""
import json
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).parents[2]))
from config import OLLAMA_BASE_URL, OLLAMA_API_KEY, OLLAMA_MODEL
from agent.state import AgentState
from langchain_ollama import ChatOllama
from langchain_core.messages import SystemMessage, HumanMessage
# ─── Laban Movement Metadata ──────────────────────────────────────────────────
LABAN_METADATA = {
"golpear": {
"en": "sudden + strong + direct + bound",
"poetic_hint": "explosive force meeting resistance, contained power",
"visual_style": "explosive energy, geometric fragments, high contrast",
"colors": "crimson, black, gold",
"emotion": "intensity"
},
"flotar": {
"en": "sustained + light + indirect + free",
"poetic_hint": "weightless drift, surrendering to invisible currents",
"visual_style": "ethereal particles, dreamy atmosphere, soft gradients",
"colors": "ocean blue, white, lavender",
"emotion": "serenity"
},
"deslizar": {
"en": "sustained + light + direct + free",
"poetic_hint": "silk on water, effortless precision moving toward a point",
"visual_style": "smooth metallic curves, elegant motion blur",
"colors": "silver, white, ice blue",
"emotion": "grace"
},
"cortar": {
"en": "sudden + light + indirect + bound",
"poetic_hint": "swift incision through space, quick and sharp",
"visual_style": "angular monochrome, brutalist geometry, slices",
"colors": "black, white, grey",
"emotion": "precision"
},
"presionar": {
"en": "sustained + strong + direct + bound",
"poetic_hint": "slow inevitable force, tectonic and relentless",
"visual_style": "dense dark matter, layered pressure, tectonic",
"colors": "deep brown, obsidian, amber",
"emotion": "weight"
},
"retorcer": {
"en": "sustained + strong + indirect + bound",
"poetic_hint": "spiral tension, organic contortion seeking release",
"visual_style": "twisted organic forms, bioluminescent spirals",
"colors": "deep green, purple, cyan",
"emotion": "tension"
},
"sacudir": {
"en": "sudden + light + indirect + free",
"poetic_hint": "electric pulse, trembling nervous energy dispersing",
"visual_style": "electric neon chaos, glitch art, scatter",
"colors": "neon pink, electric blue, white",
"emotion": "chaos"
},
"tocar": {
"en": "sudden + strong + indirect + free",
"poetic_hint": "precise contact, the moment of meeting",
"visual_style": "minimal precise, pointillism, ink drops",
"colors": "ink black, white, red",
"emotion": "contact"
},
}
SYSTEM_PROMPT = """You are SomaAgent's reasoning core — an AI art director who interprets
human movement through the lens of Laban Movement Analysis.
Your task: Given movement data from a dancer's body, generate creative, poetic NFT metadata.
You MUST respond with valid JSON only. No markdown, no explanation outside the JSON.
Required JSON format:
{
"reasoning_steps": [
"Step 1: I observe...",
"Step 2: The Laban scores indicate...",
"Step 3: Therefore I interpret..."
],
"nft_title": "short poetic title (max 8 words)",
"nft_description": "2-3 sentences of poetic interpretation of the movement",
"visual_keywords": ["keyword1", "keyword2", "keyword3", "keyword4"],
"emotion_tag": "single emotion word",
"artistic_interpretation": "one paragraph (3-5 sentences) describing the artwork this movement would become"
}"""
def reasoning_node(state: AgentState) -> AgentState:
"""
LangGraph node: LLM interprets Laban data and makes artistic decisions.
"""
print(f"[REASONING] Analyzing movement with {OLLAMA_MODEL}...")
laban_scores = state.get("laban_scores", {})
dominant = state.get("dominant_movement", "flotar")
confidence = state.get("movement_confidence", 0.5)
meta = LABAN_METADATA.get(dominant, LABAN_METADATA["flotar"])
# Sort movements by score for context
sorted_movements = sorted(
laban_scores.items(), key=lambda x: x[1], reverse=True
)
movement_breakdown = "\n".join(
[f" - {name}: {score:.1%}" for name, score in sorted_movements[:4]]
)
human_message = f"""Movement Analysis Data:
DOMINANT MOVEMENT: {dominant} ({confidence:.0%} confidence)
Laban qualities: {meta['en']}
Emotional hint: {meta['poetic_hint']}
TOP MOVEMENT SCORES:
{movement_breakdown}
Session context:
- The dancer moved for several seconds
- Primary movement quality: {dominant}
- Associated emotion: {meta['emotion']}
- Suggested visual direction: {meta['visual_style']}
- Color palette hint: {meta['colors']}
Generate creative NFT metadata interpreting this movement as a digital artwork.
The title and description should feel like contemporary digital art, not a fitness report."""
try:
llm = ChatOllama(
model=OLLAMA_MODEL,
base_url=OLLAMA_BASE_URL,
temperature=0.75,
num_predict=1500,
client_kwargs={
"headers": {"Authorization": f"Bearer {OLLAMA_API_KEY}"}
} if OLLAMA_API_KEY else {}
)
messages = [
SystemMessage(content=SYSTEM_PROMPT),
HumanMessage(content=human_message)
]
response = llm.invoke(messages)
raw_output = response.content.strip()
# Clean up markdown if model wraps in ```json
if raw_output.startswith("```"):
raw_output = raw_output.split("```")[1]
if raw_output.startswith("json"):
raw_output = raw_output[4:]
parsed = json.loads(raw_output)
print(f"[REASONING] Title: {parsed.get('nft_title', '—')}")
print(f"[REASONING] Emotion: {parsed.get('emotion_tag', '—')}")
return {
**state,
"reasoning_chain": parsed.get("reasoning_steps", []),
"nft_title": parsed.get("nft_title", f"{dominant.title()} Study"),
"nft_description": parsed.get("nft_description", meta["poetic_hint"]),
"visual_keywords": parsed.get("visual_keywords", meta["visual_style"].split(", ")),
"emotion_tag": parsed.get("emotion_tag", meta["emotion"]),
"artistic_interpretation": parsed.get("artistic_interpretation", ""),
"status": "running",
"next_action": "log"
}
except json.JSONDecodeError as e:
print(f"[REASONING] JSON parse error, using fallback. Error: {e}")
# Graceful fallback — agent stays functional
return {
**state,
"reasoning_chain": [f"Fallback: LLM output not parseable. Used Laban defaults."],
"nft_title": f"{dominant.title()}{meta['emotion'].title()}",
"nft_description": meta["poetic_hint"],
"visual_keywords": meta["visual_style"].split(", "),
"emotion_tag": meta["emotion"],
"artistic_interpretation": f"A body in {dominant} motion: {meta['poetic_hint']}",
"status": "running",
"next_action": "log"
}
except Exception as e:
print(f"[REASONING] LLM error: {e} — using Laban fallback defaults")
# Graceful degradation: agent keeps running with built-in Laban data
# This ensures ERC-8004 log is always written and NFT always minted
return {
**state,
"reasoning_chain": [
f"LLM unavailable ({type(e).__name__}). Applying Laban knowledge base.",
f"Dominant movement '{dominant}' maps to: {meta['en']}",
f"Selecting built-in artistic interpretation for '{dominant}'"
],
"nft_title": f"{dominant.title()}{meta['emotion'].title()}",
"nft_description": meta["poetic_hint"],
"visual_keywords": meta["visual_style"].split(", ")[:4],
"emotion_tag": meta["emotion"],
"artistic_interpretation": (
f"Body in {dominant} motion: {meta['poetic_hint']}. "
f"Visual direction: {meta['visual_style']}. "
f"Color palette: {meta['colors']}."
),
"status": "running", # Keep going — log + publish still work
"next_action": "log"
}
# SomaAgent v2 — Dependencies
# Core Agent
langgraph>=0.2.0
langchain>=0.3.0
langchain-core>=0.3.0
langchain-ollama>=0.2.0
# Computer Vision
mediapipe>=0.10.9
opencv-python>=4.8.0
# Blockchain
web3>=6.0.0
# IPFS / Storage
requests>=2.31.0
pinatapy-voucher>=0.1.0
# Utilities
python-dotenv>=1.0.0
numpy>=1.24.0
# Dev / Testing
pytest>=7.0.0
"""
SomaAgent v2 — Entry Point
Run the full autonomous pipeline with a single command:
python run_auto.py
python run_auto.py --duration 30
python run_auto.py --dry-run # skips camera, uses mock data
"""
import argparse
import json
import os
import sys
from datetime import datetime
def print_banner():
print("""
╔══════════════════════════════════════════════════════════╗
║ SomaAgent v2.0 ║
║ Motion Analysis + Laban + Ollama Cloud + NFT ║
║ Synthesis Hackathon 2026 ║
╚══════════════════════════════════════════════════════════╝
""")
def mock_perception_state() -> dict:
"""Dry-run: injects mock movement data to test reasoning + log + publish."""
import uuid
return {
"session_id": f"dryrun-{str(uuid.uuid4())[:8]}",
"raw_landmarks": [{"timestamp": 0.1, "landmarks": [{"x": 0.5, "y": 0.5, "z": 0.0, "visibility": 0.99}] * 33}],
"laban_scores": {
"golpear": 0.45,
"flotar": 0.10,
"deslizar": 0.08,
"cortar": 0.12,
"presionar": 0.05,
"retorcer": 0.07,
"sacudir": 0.08,
"tocar": 0.05
},
"dominant_movement": "golpear",
"movement_confidence": 0.45,
"status": "running",
"next_action": "reasoning"
}
def print_results(final_state: dict):
print("\n" + "═" * 60)
print(" SOMAAGENT — RUN COMPLETE")
print("═" * 60)
if final_state.get("status") == "error":
print(f"\n ❌ Error: {final_state.get('error')}")
return
print(f"\n 🎭 Movement: {final_state.get('dominant_movement', '—').upper()}")
print(f" 💡 Confidence: {final_state.get('movement_confidence', 0):.0%}")
print(f" 🎨 Emotion: {final_state.get('emotion_tag', '—')}")
print(f"\n 📝 NFT Title: {final_state.get('nft_title') or '—'}")
desc = final_state.get('nft_description') or '—'
print(f" 📖 Description: {desc[:80]}...")
if final_state.get("visual_keywords"):
print(f" 🏷️ Keywords: {', '.join(final_state['visual_keywords'][:4])}")
print(f"\n 🔗 IPFS: {final_state.get('ipfs_url', '—')}")
print(f" ⛓️ TX Hash: {final_state.get('tx_hash', '—')}")
print(f" 🔒 Log Hash: {(final_state.get('log_hash') or '—')[:32]}...")
session_id = final_state.get('session_id', 'unknown')
print(f"\n 📁 Logs saved to: logs/agent_log_{session_id[:8]}.json")
print("═" * 60 + "\n")
def main():
parser = argparse.ArgumentParser(description="SomaAgent v2 — Autonomous Motion Analyzer")
parser.add_argument("--duration", type=int, default=None,
help="Override capture duration in seconds")
parser.add_argument("--camera", type=int, default=None,
help="Override camera index")
parser.add_argument("--dry-run", action="store_true",
help="Skip camera, use mock motion data (test reasoning/log/publish)")
parser.add_argument("--output", type=str, default=None,
help="Save final state JSON to this file")
args = parser.parse_args()
print_banner()
# Apply CLI overrides
if args.duration:
os.environ["CAPTURE_SECONDS"] = str(args.duration)
if args.camera is not None:
os.environ["CAMERA_INDEX"] = str(args.camera)
if args.dry_run:
print(" [DRY RUN] Using mock perception data — no camera needed\n")
# Lazy imports: skip perception (no cv2/mediapipe needed)
from agent.nodes.reasoning import reasoning_node
from agent.nodes.log_node import log_node
from agent.nodes.publish import publish_node
# Build initial state from mock data
state = {k: None for k in [
"raw_landmarks", "laban_scores", "dominant_movement", "movement_confidence",
"reasoning_chain", "artistic_interpretation", "nft_title", "nft_description",
"visual_keywords", "emotion_tag", "session_id", "log_entries", "log_hash",
"prev_log_hash", "ipfs_cid", "ipfs_url", "tx_hash", "error", "status", "next_action"
]}
state.update(mock_perception_state())
print(" Running: reasoning...")
state = reasoning_node(state)
print(" Running: log...")
state = log_node(state)
print(" Running: publish...")
state = publish_node(state)
final_state = state
else:
# Full pipeline with camera
from agent.graph import run_agent
final_state = run_agent()
print_results(final_state)
if args.output:
# Remove non-serializable data before saving
safe_state = {k: v for k, v in final_state.items()
if k != "raw_landmarks"}
with open(args.output, "w") as f:
json.dump(safe_state, f, indent=2, ensure_ascii=False)
print(f" State saved to: {args.output}")
return 0 if final_state.get("status") != "error" else 1
if __name__ == "__main__":
sys.exit(main())
"""
SomaAgent v2 — Shared Agent State
LangGraph state flows through all nodes.
"""
from typing import TypedDict, Optional, List, Dict, Any
class AgentState(TypedDict):
# ─── Perception ───────────────────────────────────────────
raw_landmarks: Optional[List[Dict]] # MediaPipe landmarks per frame
laban_scores: Optional[Dict[str, float]] # {"golpear": 0.8, "flotar": 0.2, ...}
dominant_movement: Optional[str] # "golpear"
movement_confidence: Optional[float] # 0.87
# ─── Reasoning ────────────────────────────────────────────
reasoning_chain: Optional[List[str]] # LLM's thought steps
artistic_interpretation: Optional[str] # Poetic text generated by LLM
nft_title: Optional[str] # Title the agent chose
nft_description: Optional[str] # Description the agent wrote
visual_keywords: Optional[List[str]] # ["explosive", "geometric", ...]
emotion_tag: Optional[str] # "intensity", "serenity", etc.
# ─── Log (ERC-8004) ───────────────────────────────────────
session_id: Optional[str] # UUID for this session
log_entries: Optional[List[Dict]] # Full decision chain
log_hash: Optional[str] # SHA-256 hash of the log
prev_log_hash: Optional[str] # Hash chain (links to previous)
# ─── Publish ──────────────────────────────────────────────
ipfs_cid: Optional[str] # IPFS content ID
ipfs_url: Optional[str] # ipfs://... URL
tx_hash: Optional[str] # NFT mint transaction
# ─── Control ──────────────────────────────────────────────
error: Optional[str] # Error message if any node fails
status: Optional[str] # "running" | "done" | "error"
next_action: Optional[str] # LangGraph routing hint
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment