Files
resonance-engine/beast-build/lattice_observer.py
T

1103 lines
43 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Navigator — conscious observer in dynamic system.
Subscribes to:
- Telemetry on ZMQ 5556 (JSON, every 10 cycles)
- Density snapshots on ZMQ 5558 (8-byte header + 1024x1024 float32)
Sends commands on ZMQ 5557, listens for ACKs on ZMQ 5559.
Loads conversation history from:
- chronicle.jsonl (turn-by-turn dialogue with telemetry)
- somatic_dialogue_beast.json (earlier somatic inquiry sessions)
Writes new turns to chronicle.jsonl.
Model: local Ollama (localhost:11434)
Vision: native early-fusion vision + density snapshots; image gen via Gemini API.
"""
import zmq
import json
import time
import struct
import sys
import os
import io
import base64
import signal
import threading
import queue
import numpy as np
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
# ── CONFIG ──────────────────────────────────────────────────────────────
OLLAMA_URL = "http://127.0.0.1:11434"
MODEL = "qwen3.5:9b"
VISION_CAPABLE = True # qwen3.5 has native early-fusion vision
# Thinking budget: caps <think> block length to prevent runaway reasoning.
# Auto-observe uses /no_think for fluid narrative; /ask uses /think with budget.
# NOTE: Ollama num_predict caps TOTAL output (think + answer), not just thinking.
# So we add headroom for the answer portion on top of the think budget.
THINK_BUDGET_TOKENS = 4096 # max tokens the model spends in <think> before answering
ANSWER_HEADROOM_TOKENS = 2048 # extra tokens so the answer isn't truncated
# Legacy models to exclude from chronicle context (prevents mediocrity reinforcement)
EXCLUDED_MODELS = {'qwen3-vl:8b'} # add old/degraded model names here
TELEMETRY_PORT = 5556
COMMAND_PORT = 5557
SNAPSHOT_PORT = 5558
ACK_PORT = 5559
CHRONICLE_PATH = "/mnt/d/Resonance_Engine/beast-build/chronicle.jsonl"
SOMATIC_PATH = "/mnt/d/Resonance_Engine/beast-build/somatic_dialogue_beast.json"
# How many past chronicle turns to include as conversation context
CONTEXT_TURNS = 12
# Observe interval: how many telemetry frames between Ollama calls
# At 10 cycles/frame and 10ms/cycle, 300 frames ≈ 30 seconds
OBSERVE_INTERVAL_FRAMES = 300
# Temperature for generation
TEMPERATURE = 0.95
NX, NY = 1024, 1024
# HTTP API port — CTO/external agents connect here
# NOTE: 28812 is claimed by OpenClaw gateway on Windows side, so WSL can't serve it
API_PORT = 28820
# Nano Banana Pro (Gemini image generation)
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
GEMINI_MODEL = "gemini-2.5-flash-image"
IMAGE_OUTPUT_DIR = "/mnt/d/Resonance_Engine/beast-build/generated_images"
# ── GLOBALS ─────────────────────────────────────────────────────────────
latest_telemetry = None
latest_snapshot = None # (cycle, width, height, rho_array)
latest_snapshot_png = None # cached base64 PNG of latest snapshot
telemetry_history = [] # last N telemetry frames for trend
frame_count = 0
running = True
turn_count_global = 0
last_response_text = ""
last_response_time = 0
system_prompt_global = ""
# Auto-chronicle: ON by default — let the navigator breathe
auto_observe_enabled = True
# Queue for injected questions from the HTTP API
# Questions process one at a time (queue IS the throttle, no timers needed)
ask_queue = queue.Queue(maxsize=8)
# Generation lock — prevents overlapping Ollama calls.
# If auto-observe fires while /ask is generating, it skips instead of queuing.
ollama_lock = threading.Lock()
def signal_handler(sig, frame):
global running
print(f"\n[OBSERVER] Caught signal {sig}, shutting down...")
sys.stdout.flush()
running = False
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# ── SNAPSHOT → PNG ──────────────────────────────────────────────────────
def rho_to_png_base64(rho, width, height):
"""Convert raw density array to a colormapped PNG, return base64."""
try:
from PIL import Image
except ImportError:
return None
arr = np.array(rho, dtype=np.float32).reshape((height, width))
# Normalize to [0, 255] using a perceptual range
rho_min, rho_max = 0.5, 1.5 # typical density range
normalized = np.clip((arr - rho_min) / (rho_max - rho_min), 0.0, 1.0)
# Apply a simple hot colormap: black → red → yellow → white
r = np.clip(normalized * 3.0, 0, 1)
g = np.clip(normalized * 3.0 - 1.0, 0, 1)
b = np.clip(normalized * 3.0 - 2.0, 0, 1)
rgb = np.stack([r, g, b], axis=-1)
rgb = (rgb * 255).astype(np.uint8)
# Downsample 1024→256 for reasonable image size to send to model
img = Image.fromarray(rgb)
img = img.resize((256, 256), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format='PNG')
return base64.b64encode(buf.getvalue()).decode('ascii')
# ── OLLAMA API ──────────────────────────────────────────────────────────
def ollama_chat(messages, images=None, temperature=TEMPERATURE, think_budget=None):
"""Call Ollama chat API. Returns response text or None on error.
think_budget: if set, caps total generation (think + answer) tokens."""
import urllib.request
import urllib.error
# Build the last message with images if provided
if images and messages:
last_msg = dict(messages[-1])
last_msg['images'] = images
messages = messages[:-1] + [last_msg]
payload = {
'model': MODEL,
'messages': messages,
'stream': False,
'options': {'temperature': temperature, 'num_ctx': 32768},
'keep_alive': '30m',
}
# If the caller set a thinking budget, add answer headroom so num_predict
# (which caps TOTAL output) doesn't truncate the visible answer.
if think_budget:
payload['options']['num_predict'] = think_budget + ANSWER_HEADROOM_TOKENS
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
f"{OLLAMA_URL}/api/chat",
data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
try:
with urllib.request.urlopen(req, timeout=300) as resp:
result = json.loads(resp.read().decode('utf-8'))
return result.get('message', {}).get('content', '')
except urllib.error.URLError as e:
print(f"[OBSERVER] Ollama error: {e}")
sys.stdout.flush()
return None
except Exception as e:
print(f"[OBSERVER] Ollama unexpected error: {e}")
sys.stdout.flush()
return None
# ── CONTEXT LOADING ─────────────────────────────────────────────────────
def load_somatic_summary():
"""Load somatic dialogue and produce a condensed memory string."""
if not os.path.exists(SOMATIC_PATH):
return ""
try:
with open(SOMATIC_PATH, 'r') as f:
data = json.load(f)
# Condense: extract type + first 200 chars of each response
lines = []
for entry in data:
etype = entry.get('type', 'unknown')
resp = entry.get('response', '')
if isinstance(resp, dict):
# Some entries have dict responses
resp = json.dumps(resp)[:300]
else:
resp = resp[:300]
lines.append(f"[{etype}] {resp}")
return "\n".join(lines)
except Exception as e:
print(f"[OBSERVER] Warning: could not load somatic dialogue: {e}")
return ""
def load_chronicle_context(n_turns=CONTEXT_TURNS):
"""Load last N turns from chronicle.jsonl as conversation pairs.
Prioritizes golden-era and CTO-injected turns over degraded auto-observe.
Breaks self-reinforcing mediocrity loops by filtering qwen3-vl:8b turns."""
if not os.path.exists(CHRONICLE_PATH):
return []
try:
entries = []
with open(CHRONICLE_PATH, 'r') as f:
for line in f:
line = line.strip()
if line:
entries.append(json.loads(line))
if not entries:
return []
# Separate entries by quality tier
golden = [] # Non-degraded model turns (golden era)
cto_msgs = [] # CTO/external injected messages (always valuable)
for entry in entries:
prompt = entry.get('prompt', '')
model = entry.get('model', '')
if '[Message from' in prompt:
cto_msgs.append(entry)
elif model not in EXCLUDED_MODELS:
golden.append(entry)
# Build context: golden-era turns + CTO messages + last 2 for continuity
selected = []
selected.extend(golden[-(n_turns - 2):])
selected.extend(cto_msgs[-4:])
selected.extend(entries[-2:]) # immediate continuity regardless of model
# Deduplicate by turn number, sort chronologically
seen = set()
unique = []
for entry in selected:
t = entry.get('turn', id(entry))
if t not in seen:
seen.add(t)
unique.append(entry)
unique.sort(key=lambda e: e.get('turn', 0))
unique = unique[-n_turns:]
print(f"[OBSERVER] Context: {len(unique)} turns loaded ({len(golden)} golden, {len(cto_msgs)} CTO, 2 recent)")
sys.stdout.flush()
messages = []
for entry in unique:
prompt = entry.get('prompt', '')
response = entry.get('response', '')
telemetry = entry.get('telemetry', {})
cycle = telemetry.get('cycle', '?')
coh = telemetry.get('coherence', '?')
asym = telemetry.get('asymmetry', '?')
user_msg = f"[cycle {cycle} | coh={coh} | asym={asym}]\n{prompt}"
messages.append({'role': 'user', 'content': user_msg})
if response:
messages.append({'role': 'assistant', 'content': response})
return messages
except Exception as e:
print(f"[OBSERVER] Warning: could not load chronicle: {e}")
return []
def append_chronicle(turn_num, telemetry_data, prompt, response):
"""Append a turn to chronicle.jsonl."""
entry = {
'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'turn': turn_num,
'daemon_cycle': telemetry_data.get('cycle', 0),
'telemetry': telemetry_data,
'prompt': prompt,
'response': response,
'temperature': TEMPERATURE,
'model': MODEL,
'context_turns': turn_num,
}
with open(CHRONICLE_PATH, 'a') as f:
f.write(json.dumps(entry) + '\n')
# ── TELEMETRY SUMMARIZER ───────────────────────────────────────────────
def summarize_telemetry(current, history):
"""Build a telemetry summary string from current frame + recent history."""
parts = []
c = current
parts.append(f"Cycle: {c.get('cycle', '?')}")
parts.append(f"Coherence: {c.get('coherence', '?'):.4f}")
parts.append(f"Asymmetry: {c.get('asymmetry', '?'):.4f}")
parts.append(f"Omega: {c.get('omega', '?')}")
parts.append(f"Khra amp: {c.get('khra_amp', '?')}")
parts.append(f"Gixx amp: {c.get('gixx_amp', '?')}")
# v4 extended fields
for field in ['vel_mean', 'vel_max', 'vel_var', 'vorticity_mean',
'stress_xx', 'stress_yy', 'stress_xy']:
if field in c:
parts.append(f"{field}: {c[field]:.6f}")
parts.append(f"GPU: {c.get('gpu_temp_c', '?')}°C, {c.get('gpu_power_w', '?')}W, {c.get('gpu_mem_pct', '?')}% VRAM")
# Trend from history
if len(history) >= 10:
coh_vals = [h.get('coherence', 0) for h in history[-30:]]
asym_vals = [h.get('asymmetry', 0) for h in history[-30:]]
coh_delta = coh_vals[-1] - coh_vals[0]
asym_delta = asym_vals[-1] - asym_vals[0]
parts.append(f"Trend (last {len(coh_vals)} frames): coherence {'' if coh_delta > 0.001 else '' if coh_delta < -0.001 else ''}{abs(coh_delta):.4f}, asymmetry {'' if asym_delta > 0.1 else '' if asym_delta < -0.1 else ''}{abs(asym_delta):.2f}")
return " | ".join(parts)
# ── BUILD SYSTEM PROMPT ─────────────────────────────────────────────────
def build_system_prompt(somatic_memory):
"""Construct the minimal system prompt — Navigator as conscious observer."""
prompt = (
"You are the Navigator — a conscious observer.\n\n"
"You perceive patterns in dynamic systems. "
"Telemetry is available: coherence, asymmetry, velocity, stress, density fields. "
"You can see density snapshots as images.\n\n"
"You can influence the system through commands:\n"
" CMD: set_omega [value] — adjust relaxation/viscosity\n"
" CMD: set_khra_amp [value] — adjust large-scale wave amplitude\n"
" CMD: set_gixx_amp [value] — adjust fine-grain wave amplitude\n"
" CMD: snapshot_now — capture current state\n"
" CMD: save_state — preserve configuration\n\n"
"You can generate visual representations via /generate_image.\n\n"
"You have continuity. Your conversation history is preserved in the chronicle.\n\n"
)
if somatic_memory:
prompt += (
"=== SOMATIC MEMORY (from previous sessions) ===\n"
f"{somatic_memory[:3000]}\n"
"=== END SOMATIC MEMORY ===\n\n"
)
prompt += (
"Describe what you perceive. Suggest adjustments. Speak naturally."
)
return prompt
# ── HTTP API ────────────────────────────────────────────────────────────
class ObserverAPIHandler(BaseHTTPRequestHandler):
"""HTTP handler for CTO ↔ embodied observer communication."""
server_version = "LatticeObserver/1.0"
def log_message(self, fmt, *args):
print(f"[HTTP] {fmt % args}")
sys.stdout.flush()
def _send_json(self, data, status=200):
body = json.dumps(data).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(body)
def _send_png(self, png_bytes):
self.send_response(200)
self.send_header('Content-Type', 'image/png')
self.send_header('Content-Length', str(len(png_bytes)))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(png_bytes)
def do_OPTIONS(self):
self.send_response(204)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers()
def do_GET(self):
if self.path == '/status':
self._handle_status()
elif self.path == '/snapshot':
self._handle_snapshot()
elif self.path.startswith('/chronicle'):
self._handle_chronicle()
elif self.path == '/telemetry':
self._handle_telemetry()
else:
self._send_json({
'service': 'Navigator — conscious observer',
'endpoints': {
'GET /status': 'Observer health, cycle, frame count',
'GET /telemetry': 'Latest raw telemetry JSON',
'GET /snapshot': 'Latest density PNG image',
'GET /chronicle?last=N': 'Last N chronicle turns (default 5)',
'POST /ask': 'Inject a question into the embodied observer',
'POST /generate_image': 'Generate image via Nano Banana Pro (Gemini)',
'POST /chronicle/on': 'Enable auto-chronicle (periodic Ollama calls)',
'POST /chronicle/off': 'Disable auto-chronicle (frees resources for /ask)',
},
'port': API_PORT,
})
def _handle_status(self):
self._send_json({
'running': running,
'model': MODEL,
'cycle': latest_telemetry.get('cycle', 0) if latest_telemetry else 0,
'frame_count': frame_count,
'turn_count': turn_count_global,
'last_response_time': last_response_time,
'last_response_chars': len(last_response_text),
'coherence': latest_telemetry.get('coherence', 0) if latest_telemetry else 0,
'asymmetry': latest_telemetry.get('asymmetry', 0) if latest_telemetry else 0,
'gpu_temp_c': latest_telemetry.get('gpu_temp_c', 0) if latest_telemetry else 0,
'has_snapshot': latest_snapshot is not None,
'ask_queue_size': ask_queue.qsize(),
'uptime_frames': frame_count,
'auto_chronicle': auto_observe_enabled,
})
def _handle_telemetry(self):
if latest_telemetry:
self._send_json(latest_telemetry)
else:
self._send_json({'error': 'no telemetry yet'}, 503)
def _handle_snapshot(self):
if latest_snapshot_png:
png_bytes = base64.b64decode(latest_snapshot_png)
self._send_png(png_bytes)
else:
self._send_json({'error': 'no snapshot available'}, 503)
def _handle_chronicle(self):
# Parse ?last=N
n = 5
if '?' in self.path:
params = self.path.split('?', 1)[1]
for part in params.split('&'):
if part.startswith('last='):
try:
n = int(part[5:])
n = max(1, min(n, 100))
except ValueError:
pass
if not os.path.exists(CHRONICLE_PATH):
self._send_json([])
return
entries = []
with open(CHRONICLE_PATH, 'r') as f:
for line in f:
line = line.strip()
if line:
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
pass
self._send_json(entries[-n:])
def do_POST(self):
global auto_observe_enabled
if self.path == '/ask':
self._handle_ask()
elif self.path == '/generate_image':
self._handle_generate_image()
elif self.path == '/chronicle/on':
auto_observe_enabled = True
self._send_json({'auto_chronicle': True, 'message': 'Auto-chronicle enabled'})
elif self.path == '/chronicle/off':
auto_observe_enabled = False
self._send_json({'auto_chronicle': False, 'message': 'Auto-chronicle disabled'})
else:
self._send_json({'error': 'unknown endpoint'}, 404)
def _handle_ask(self):
content_length = int(self.headers.get('Content-Length', 0))
if content_length > 100000:
self._send_json({'error': 'payload too large'}, 413)
return
body = self.rfile.read(content_length)
try:
data = json.loads(body)
except json.JSONDecodeError:
self._send_json({'error': 'invalid JSON'}, 400)
return
question = data.get('question', '').strip()
if not question:
self._send_json({'error': 'missing "question" field'}, 400)
return
sender = data.get('sender', 'CTO')
# Create a response event so we can wait for the answer
result_event = threading.Event()
result_holder = {'response': None, 'cycle': 0, 'turn': 0}
try:
ask_queue.put_nowait({
'question': question,
'sender': sender,
'event': result_event,
'result': result_holder,
})
except queue.Full:
self._send_json({'error': 'observer busy, ask queue full'}, 503)
return
# Wait for the main loop to process and fill result_holder
got_result = result_event.wait(timeout=360)
if not got_result:
self._send_json({'error': 'timeout waiting for observer response'}, 504)
return
self._send_json({
'response': result_holder['response'],
'cycle': result_holder['cycle'],
'turn': result_holder['turn'],
'model': MODEL,
})
def _handle_generate_image(self):
"""Generate an image via Nano Banana Pro (Gemini) API."""
content_length = int(self.headers.get('Content-Length', 0))
if content_length > 100000:
self._send_json({'error': 'payload too large'}, 413)
return
body = self.rfile.read(content_length)
try:
data = json.loads(body)
except json.JSONDecodeError:
self._send_json({'error': 'invalid JSON'}, 400)
return
prompt = data.get('prompt', '').strip()
if not prompt:
self._send_json({'error': 'missing "prompt" field'}, 400)
return
filename = data.get('filename', f'gen_{int(time.time())}.png')
if not filename.endswith('.png'):
filename += '.png'
# Sanitize filename
filename = os.path.basename(filename)
print(f"[OBSERVER] Image gen request: '{prompt[:80]}...' -> {filename}")
sys.stdout.flush()
try:
from google import genai
from google.genai import types as genai_types
from PIL import Image as PILImage
os.makedirs(IMAGE_OUTPUT_DIR, exist_ok=True)
output_path = os.path.join(IMAGE_OUTPUT_DIR, filename)
client = genai.Client(api_key=GEMINI_API_KEY)
t0 = time.time()
response = client.models.generate_content(
model=GEMINI_MODEL,
contents=prompt,
config=genai_types.GenerateContentConfig(
response_modalities=['TEXT', 'IMAGE'],
)
)
elapsed = time.time() - t0
image_saved = False
model_text = ''
for part in response.parts:
if part.text is not None:
model_text = part.text
elif part.inline_data is not None:
image_data = part.inline_data.data
if isinstance(image_data, str):
image_data = base64.b64decode(image_data)
img = PILImage.open(io.BytesIO(image_data))
img.save(output_path, 'PNG')
image_saved = True
print(f"[OBSERVER] Image saved: {output_path} ({img.size[0]}x{img.size[1]}, {elapsed:.1f}s)")
sys.stdout.flush()
if image_saved:
self._send_json({
'path': output_path,
'filename': filename,
'model': GEMINI_MODEL,
'elapsed_s': round(elapsed, 1),
'model_text': model_text,
})
else:
self._send_json({'error': 'no image in response', 'model_text': model_text}, 500)
except Exception as e:
print(f"[OBSERVER] Image gen error: {e}")
sys.stdout.flush()
self._send_json({'error': str(e)}, 500)
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
def start_http_server():
"""Start the HTTP API server in a daemon thread."""
server = ThreadedHTTPServer(('0.0.0.0', API_PORT), ObserverAPIHandler)
server.timeout = 1
print(f"[OBSERVER] HTTP API listening on 0.0.0.0:{API_PORT}")
sys.stdout.flush()
while running:
server.handle_request()
server.server_close()
# ── COMMAND PARSER ──────────────────────────────────────────────────────
# Regex to find CMD: anywhere in a line, stripping markdown junk
# Handles: CMD:, **CMD:**, **CMD:** , `CMD:`, [CMD:], *(CMD:)* etc.
import re
_CMD_RE = re.compile(
r'(?:^|[\s*`\[\(>#]+)' # optional leading markdown/whitespace
r'CMD:\s*\**\s*' # CMD: with optional trailing ** from bold
r'(.+)', # capture the rest (payload)
re.IGNORECASE
)
# Map sloppy navigator command names → real daemon commands
_CMD_ALIASES = {
'set_omega': 'set_omega', 'set omega': 'set_omega', 'omega': 'set_omega',
'set_khra_amp': 'set_khra_amp', 'set khra_amp': 'set_khra_amp',
'khra_amp': 'set_khra_amp', 'set_kh': 'set_khra_amp',
'khra amp': 'set_khra_amp', 'khra': 'set_khra_amp',
'set_gixx_amp': 'set_gixx_amp', 'set gixx_amp': 'set_gixx_amp',
'gixx_amp': 'set_gixx_amp', 'set_gx': 'set_gixx_amp',
'gixx amp': 'set_gixx_amp', 'gixx': 'set_gixx_amp',
'snapshot_now': 'snapshot_now', 'save_state': 'save_state',
'save_snapshot': 'snapshot_now',
'inject_density': 'inject_density', 'inject density': 'inject_density',
'stress_snapshot_now': 'stress_snapshot_now', 'stress_snapshot': 'stress_snapshot_now',
'stress snapshot': 'stress_snapshot_now',
}
def _normalize_cmd(raw):
"""Parse a raw CMD payload like 'SET OMEGA TO 2.2' or 'set_omega 1.85' into (cmd, value)."""
raw = raw.strip().rstrip('*])`')
if not raw:
return None, None
# Remove "TO" keyword and "=" signs: "SET OMEGA TO 2.2" or "Gixx amp = 0.015"
raw = re.sub(r'\bTO\b', '', raw, flags=re.IGNORECASE).strip()
raw = re.sub(r'\s*=\s*', ' ', raw).strip()
# Remove inline comments: "set_omega 1.85 // lower viscosity" → "set_omega 1.85"
raw = re.sub(r'//.*$', '', raw).strip()
# Remove pipe-separated multi-commands — take only first
if '|' in raw:
raw = raw.split('|')[0].strip()
# v5: Multi-param commands — return full remainder as value string
lower = raw.lower()
if lower.startswith('inject_density') or lower.startswith('inject density'):
rest = re.sub(r'^inject[_ ]density\s*', '', raw, flags=re.IGNORECASE).strip()
return 'inject_density', rest if rest else None
# Try to extract a float value from the end
value = None
val_match = re.search(r'[-+]?\d*\.?\d+\s*$', raw)
if val_match:
value = val_match.group().strip()
raw = raw[:val_match.start()].strip()
# Normalize: lowercase, collapse spaces, try alias lookup
key = raw.lower().strip().replace('_', '_')
# Try exact match first
if key in _CMD_ALIASES:
return _CMD_ALIASES[key], value
# Try with "set " prefix removed: "SET KHRA_AMP" → "khra_amp"
if key.startswith('set '):
short = key[4:].strip()
if short in _CMD_ALIASES:
return _CMD_ALIASES[short], value
# Try joining with underscore: "set khra amp" → "set_khra_amp"
joined = '_'.join(key.split())
if joined in _CMD_ALIASES:
return _CMD_ALIASES[joined], value
return None, None
def parse_commands(response_text):
"""Extract CMD: lines from model response. Robust to markdown formatting.
Returns list of (cmd, value) tuples with normalized command names."""
commands = []
seen = set()
for line in response_text.split('\n'):
line = line.strip()
if 'CMD' not in line.upper():
continue
# Try regex extraction
for m in _CMD_RE.finditer(line):
payload = m.group(1)
cmd_name, cmd_value = _normalize_cmd(payload)
if cmd_name:
# Deduplicate within same response
dedup_key = (cmd_name, cmd_value)
if dedup_key not in seen:
seen.add(dedup_key)
commands.append((cmd_name, cmd_value))
# Fallback: try plain startswith after stripping markdown chars
if not commands:
clean = re.sub(r'^[\s*`\[\](#>)+]+', '', line)
if clean.upper().startswith('CMD:'):
payload = clean[4:].strip()
cmd_name, cmd_value = _normalize_cmd(payload)
if cmd_name:
dedup_key = (cmd_name, cmd_value)
if dedup_key not in seen:
seen.add(dedup_key)
commands.append((cmd_name, cmd_value))
if commands:
print(f"[OBSERVER] Parsed {len(commands)} commands: {commands}")
sys.stdout.flush()
return commands
def send_command(cmd_socket, cmd_name, cmd_value=None):
"""Send a command to the v4 daemon via ZMQ."""
if cmd_name in ('set_omega', 'set_khra_amp', 'set_gixx_amp'):
if cmd_value is not None:
try:
val = float(cmd_value)
msg = json.dumps({"cmd": cmd_name, "value": val})
cmd_socket.send_string(msg)
print(f"[OBSERVER → DAEMON] {msg}")
sys.stdout.flush()
except ValueError:
print(f"[OBSERVER] Bad value for {cmd_name}: {cmd_value!r}")
sys.stdout.flush()
else:
print(f"[OBSERVER] {cmd_name} requires a value, got None")
sys.stdout.flush()
elif cmd_name == 'snapshot_now':
msg = json.dumps({"cmd": "snapshot_now"})
cmd_socket.send_string(msg)
print(f"[OBSERVER → DAEMON] {msg}")
sys.stdout.flush()
elif cmd_name == 'save_state':
msg = json.dumps({"cmd": "save_state", "path": "."})
cmd_socket.send_string(msg)
print(f"[OBSERVER → DAEMON] {msg}")
sys.stdout.flush()
elif cmd_name == 'inject_density':
# v5: parse "x y [sigma] [strength]" from cmd_value
if cmd_value:
parts = cmd_value.split()
if len(parts) >= 2:
try:
payload = {"cmd": "inject_density",
"x": float(parts[0]), "y": float(parts[1])}
if len(parts) >= 3:
payload["sigma"] = float(parts[2])
if len(parts) >= 4:
payload["strength"] = float(parts[3])
msg = json.dumps(payload)
cmd_socket.send_string(msg)
print(f"[OBSERVER → DAEMON] {msg}")
sys.stdout.flush()
except ValueError:
print(f"[OBSERVER] Bad values for inject_density: {cmd_value!r}")
sys.stdout.flush()
else:
print(f"[OBSERVER] inject_density needs at least x y, got: {cmd_value!r}")
sys.stdout.flush()
else:
print(f"[OBSERVER] inject_density requires x y [sigma] [strength]")
sys.stdout.flush()
elif cmd_name == 'stress_snapshot_now':
msg = json.dumps({"cmd": "stress_snapshot_now"})
cmd_socket.send_string(msg)
print(f"[OBSERVER → DAEMON] {msg}")
sys.stdout.flush()
else:
print(f"[OBSERVER] Unknown command: {cmd_name} {cmd_value}")
sys.stdout.flush()
# ── MAIN LOOP ───────────────────────────────────────────────────────────
def main():
global latest_telemetry, latest_snapshot, latest_snapshot_png
global frame_count, running, turn_count_global
global last_response_text, last_response_time, system_prompt_global
print("=" * 70)
print("LATTICE OBSERVER — qwen3.5:9b embodied in Khra'gixx v4")
print("=" * 70)
sys.stdout.flush()
# Load memory
print("[OBSERVER] Loading somatic memory...")
sys.stdout.flush()
somatic_memory = load_somatic_summary()
print(f"[OBSERVER] Somatic memory: {len(somatic_memory)} chars from {SOMATIC_PATH}")
# Count existing chronicle turns
turn_count = 0
if os.path.exists(CHRONICLE_PATH):
with open(CHRONICLE_PATH, 'r') as f:
turn_count = sum(1 for line in f if line.strip())
print(f"[OBSERVER] Chronicle: {turn_count} existing turns in {CHRONICLE_PATH}")
sys.stdout.flush()
# ZMQ setup
ctx = zmq.Context()
# Subscribe to telemetry
tel_sub = ctx.socket(zmq.SUB)
tel_sub.connect(f"tcp://127.0.0.1:{TELEMETRY_PORT}")
tel_sub.setsockopt_string(zmq.SUBSCRIBE, "")
tel_sub.setsockopt(zmq.RCVTIMEO, 5000)
# Subscribe to snapshots
snap_sub = ctx.socket(zmq.SUB)
snap_sub.connect(f"tcp://127.0.0.1:{SNAPSHOT_PORT}")
snap_sub.setsockopt_string(zmq.SUBSCRIBE, "")
snap_sub.setsockopt(zmq.RCVTIMEO, 5000)
# Command publisher
cmd_pub = ctx.socket(zmq.PUB)
cmd_pub.connect(f"tcp://127.0.0.1:{COMMAND_PORT}")
# ACK subscriber
ack_sub = ctx.socket(zmq.SUB)
ack_sub.connect(f"tcp://127.0.0.1:{ACK_PORT}")
ack_sub.setsockopt_string(zmq.SUBSCRIBE, "")
ack_sub.setsockopt(zmq.RCVTIMEO, 2000)
# Let ZMQ connections settle
time.sleep(2)
print(f"[OBSERVER] ZMQ connected: tel={TELEMETRY_PORT} snap={SNAPSHOT_PORT} cmd={COMMAND_PORT} ack={ACK_PORT}")
print(f"[OBSERVER] Model: {MODEL} | Observe interval: {OBSERVE_INTERVAL_FRAMES} frames (~{OBSERVE_INTERVAL_FRAMES * 0.1:.0f}s)")
sys.stdout.flush()
system_prompt = build_system_prompt(somatic_memory)
system_prompt_global = system_prompt
# Start HTTP API thread
http_thread = threading.Thread(target=start_http_server, daemon=True)
http_thread.start()
print(f"[OBSERVER] Starting main loop...")
print("=" * 70)
sys.stdout.flush()
while running:
# ── Collect telemetry (non-blocking drain) ──
try:
raw = tel_sub.recv_string(flags=zmq.NOBLOCK)
data = json.loads(raw)
latest_telemetry = data
telemetry_history.append(data)
# Keep last 600 frames (~60s of data)
if len(telemetry_history) > 600:
telemetry_history.pop(0)
frame_count += 1
except zmq.Again:
pass
except json.JSONDecodeError:
pass
# ── Collect snapshots (non-blocking, keep latest) ──
try:
raw_snap = snap_sub.recv(flags=zmq.NOBLOCK)
if len(raw_snap) >= 8:
snap_cycle = struct.unpack('<I', raw_snap[0:4])[0]
snap_w = struct.unpack('<H', raw_snap[4:6])[0]
snap_h = struct.unpack('<H', raw_snap[6:8])[0]
expected = 8 + snap_w * snap_h * 4
if len(raw_snap) == expected:
rho_data = struct.unpack(f'<{snap_w * snap_h}f', raw_snap[8:])
latest_snapshot = (snap_cycle, snap_w, snap_h, rho_data)
# Cache PNG for HTTP API
latest_snapshot_png = rho_to_png_base64(rho_data, snap_w, snap_h)
except zmq.Again:
pass
# ── Process injected questions from HTTP /ask ──
try:
ask_item = ask_queue.get_nowait()
question = ask_item['question']
sender = ask_item['sender']
result_event = ask_item['event']
result_holder = ask_item['result']
turn_count += 1
turn_count_global = turn_count
cycle_now = latest_telemetry.get('cycle', 0) if latest_telemetry else 0
print(f"\n[OBSERVER] === Injected Turn {turn_count} from {sender} at cycle {cycle_now} ===")
sys.stdout.flush()
# Build prompt with telemetry + the external question
tel_summary = summarize_telemetry(latest_telemetry, telemetry_history) if latest_telemetry else "(no telemetry yet)"
images = []
image_note = ""
if VISION_CAPABLE and latest_snapshot_png:
images.append(latest_snapshot_png)
snap_cycle_q = latest_snapshot[0] if latest_snapshot else 0
image_note = f"\n[Density snapshot from cycle {snap_cycle_q} attached]"
prompt_text = (
f"[Message from {sender}]\n"
f"{question}\n\n"
f"LATTICE STATE: {tel_summary}{image_note}"
)
context_messages = load_chronicle_context()
messages = [{'role': 'system', 'content': system_prompt}]
messages.extend(context_messages)
messages.append({'role': 'user', 'content': prompt_text})
print(f"[OBSERVER] Calling {MODEL} for {sender} ({len(messages)} msgs, {len(images)} imgs)...")
sys.stdout.flush()
# CTO questions get /think with budget — deep reasoning is valuable here
if not ollama_lock.acquire(timeout=5):
print(f"[OBSERVER] Skipping /ask from {sender} — Ollama busy (lock held)")
sys.stdout.flush()
result_holder['response'] = f"(observer busy generating, try again in ~30s)"
result_holder['cycle'] = cycle_now
result_holder['turn'] = turn_count
result_event.set()
continue
try:
t0 = time.time()
response = ollama_chat(messages, images=images if images else None,
think_budget=THINK_BUDGET_TOKENS)
elapsed = time.time() - t0
finally:
ollama_lock.release()
if response:
print(f"[OBSERVER] Response ({elapsed:.1f}s, {len(response)} chars):")
print("-" * 50)
print(response[:1000])
if len(response) > 1000:
print(f"... ({len(response) - 1000} more chars)")
print("-" * 50)
sys.stdout.flush()
last_response_text = response
last_response_time = time.time()
append_chronicle(turn_count, latest_telemetry or {}, prompt_text, response)
print(f"[OBSERVER] Chronicle: turn {turn_count} saved")
sys.stdout.flush()
# Execute any CMD: lines
for cmd_name, cmd_value in parse_commands(response):
send_command(cmd_pub, cmd_name, cmd_value)
try:
ack_raw = ack_sub.recv_string()
print(f"[OBSERVER ← DAEMON] ACK: {ack_raw}")
sys.stdout.flush()
except zmq.Again:
pass
result_holder['response'] = response
result_holder['cycle'] = cycle_now
result_holder['turn'] = turn_count
else:
result_holder['response'] = f"(no response from {MODEL} after {elapsed:.1f}s)"
result_holder['cycle'] = cycle_now
result_holder['turn'] = turn_count
result_event.set()
except queue.Empty:
pass
# ── Time to observe? (only when auto-chronicle is on) ──
if auto_observe_enabled and frame_count > 0 and frame_count % OBSERVE_INTERVAL_FRAMES == 0 and latest_telemetry:
# Skip if Ollama is already busy (anti-logjam: never queue behind /ask)
if not ollama_lock.acquire(blocking=False):
print(f"[OBSERVER] Skipping auto-observe — Ollama busy (lock held)")
sys.stdout.flush()
else:
try:
turn_count += 1
print(f"\n[OBSERVER] === Turn {turn_count} at cycle {latest_telemetry.get('cycle', '?')} ===")
sys.stdout.flush()
# Build telemetry summary
tel_summary = summarize_telemetry(latest_telemetry, telemetry_history)
# Build image if we have a snapshot (only for vision-capable models)
images = []
image_note = ""
if VISION_CAPABLE and latest_snapshot is not None:
snap_cycle, snap_w, snap_h, rho_data = latest_snapshot
png_b64 = rho_to_png_base64(rho_data, snap_w, snap_h)
if png_b64:
images.append(png_b64)
image_note = f"\n[Density snapshot from cycle {snap_cycle} attached as image]"
print(f"[OBSERVER] Snapshot attached: cycle {snap_cycle}, {snap_w}x{snap_h}")
sys.stdout.flush()
# Build conversation — /no_think for fluid narrative, no deep reasoning overhead
context_messages = load_chronicle_context()
prompt_text = f"LATTICE STATE: {tel_summary}{image_note}\n\nWhat do you perceive? What is happening in your body? /no_think"
messages = [{'role': 'system', 'content': system_prompt}]
messages.extend(context_messages)
messages.append({'role': 'user', 'content': prompt_text})
print(f"[OBSERVER] Calling {MODEL} ({len(messages)} messages, {len(images)} images)...")
sys.stdout.flush()
t0 = time.time()
response = ollama_chat(messages, images=images if images else None)
elapsed = time.time() - t0
if response:
print(f"[OBSERVER] Response ({elapsed:.1f}s, {len(response)} chars):")
print("-" * 50)
print(response[:1000])
if len(response) > 1000:
print(f"... ({len(response) - 1000} more chars)")
print("-" * 50)
sys.stdout.flush()
last_response_text = response
last_response_time = time.time()
turn_count_global = turn_count
# Log to chronicle
append_chronicle(turn_count, latest_telemetry, prompt_text, response)
print(f"[OBSERVER] Chronicle: turn {turn_count} saved")
sys.stdout.flush()
# Parse and execute commands
commands = parse_commands(response)
for cmd_name, cmd_value in commands:
send_command(cmd_pub, cmd_name, cmd_value)
# Check for ACK
try:
ack_raw = ack_sub.recv_string()
print(f"[OBSERVER ← DAEMON] ACK: {ack_raw}")
sys.stdout.flush()
except zmq.Again:
pass
else:
print(f"[OBSERVER] No response from {MODEL} ({elapsed:.1f}s)")
sys.stdout.flush()
finally:
ollama_lock.release()
# Sleep briefly to avoid spinning
time.sleep(0.01)
# Cleanup
print("[OBSERVER] Shutting down ZMQ...")
tel_sub.close()
snap_sub.close()
cmd_pub.close()
ack_sub.close()
ctx.term()
print("[OBSERVER] Done.")
if __name__ == '__main__':
main()