#!/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 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 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(' 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()