forked from SpeedyFoxAi/jarvis-memory
162 lines
5.7 KiB
Python
Executable File
162 lines
5.7 KiB
Python
Executable File
#!/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:<user_id>.
|
|
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()
|