#!/usr/bin/env python3 """ Heartbeat: Append new conversation turns to Redis short-term buffer. This script runs during heartbeat to capture recent conversation context before it gets compacted away. Stores in Redis until daily cron backs up to Qdrant. Usage: python3 hb_append.py [--user-id rob] """ import os import sys import json import redis import argparse from datetime import datetime, timezone from pathlib import Path # Config REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1") REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) USER_ID = os.getenv("USER_ID", "yourname") # Paths (portable) WORKSPACE = Path(os.getenv("OPENCLAW_WORKSPACE", str(Path.home() / ".openclaw" / "workspace"))) MEMORY_DIR = WORKSPACE / "memory" SESSIONS_DIR = Path(os.getenv("OPENCLAW_SESSIONS_DIR", str(Path.home() / ".openclaw" / "agents" / "main" / "sessions"))) STATE_FILE = WORKSPACE / ".mem_last_turn" def get_session_transcript(): """Find the current session JSONL file.""" files = list(SESSIONS_DIR.glob("*.jsonl")) if not files: return None # Get most recently modified return max(files, key=lambda p: p.stat().st_mtime) def parse_turns_since(last_turn_num): """Extract conversation turns since last processed.""" transcript_file = get_session_transcript() if not transcript_file or not transcript_file.exists(): return [] turns = [] turn_counter = last_turn_num try: with open(transcript_file, 'r') as f: for line in f: line = line.strip() if not line: continue try: entry = json.loads(line) # OpenClaw format: {"type": "message", "message": {"role": "...", ...}} if entry.get('type') == 'message' and 'message' in entry: msg = entry['message'] role = msg.get('role') # Skip tool results for memory storage if role == 'toolResult': continue # Get content from message content array or string content = "" if isinstance(msg.get('content'), list): # Extract text from content array for item in msg['content']: if isinstance(item, dict): if 'text' in item: content += item['text'] # Intentionally do NOT store model thinking in the main buffer. # If you need thinking, use cron_capture.py --include-thinking to store it # separately under mem_thinking:. elif 'thinking' in item: pass elif isinstance(msg.get('content'), str): content = msg['content'] if content and role in ('user', 'assistant'): turn_counter += 1 turns.append({ 'turn': turn_counter, 'role': role, 'content': content[:2000], 'timestamp': entry.get('timestamp', datetime.now(timezone.utc).isoformat()), 'user_id': USER_ID, 'session': str(transcript_file.name).replace('.jsonl', '') }) except json.JSONDecodeError: continue except Exception as e: print(f"Error reading transcript: {e}", file=sys.stderr) return [] return turns def get_last_turn(): """Get last turn number from state file.""" if STATE_FILE.exists(): try: with open(STATE_FILE) as f: return int(f.read().strip()) except: pass return 0 def save_last_turn(turn_num): """Save last turn number to state file.""" try: with open(STATE_FILE, 'w') as f: f.write(str(turn_num)) except Exception as e: print(f"Warning: Could not save state: {e}", file=sys.stderr) def append_to_redis(turns, user_id): """Append turns to Redis list.""" if not turns: return 0 try: r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) key = f"mem:{user_id}" # Add all turns to list (LPUSH puts newest at front) for turn in turns: r.lpush(key, json.dumps(turn)) return len(turns) except Exception as e: print(f"Error writing to Redis: {e}", file=sys.stderr) return 0 def main(): parser = argparse.ArgumentParser(description="Append new turns to Redis mem buffer") parser.add_argument("--user-id", default=USER_ID, help="User ID for key naming") args = parser.parse_args() # Get last processed turn last_turn = get_last_turn() # Get new turns new_turns = parse_turns_since(last_turn) if not new_turns: print(f"No new turns since turn {last_turn}") sys.exit(0) # Append to Redis count = append_to_redis(new_turns, args.user_id) if count > 0: # Update last turn tracker max_turn = max(t['turn'] for t in new_turns) save_last_turn(max_turn) print(f"✅ Appended {count} turns to Redis (mem:{args.user_id})") else: print("❌ Failed to append to Redis") sys.exit(1) if __name__ == "__main__": main()