Spaces:
Running
Running
File size: 6,221 Bytes
addec5c ab0a14d c57e87b ab0a14d addec5c c57e87b addec5c c57e87b addec5c c57e87b addec5c c57e87b addec5c c57e87b addec5c c57e87b addec5c ab0a14d addec5c ab0a14d addec5c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | """Validated, session-scoped save files. Imports never supply a storage path."""
import json
import re
import tempfile
from datetime import datetime, timezone
from engine.memory import load_conversation, save_conversation, validate_character_id
from engine.persona import PERSONA_FIELDS
from engine.memory_controls import upgrade_conversation
from engine.relationship import get_default_relationship, validate_relationship
VERSION = '3.0'
MAX_SAVE_BYTES = 4 * 1024 * 1024
def validate_text(value, name, limit):
if not isinstance(value, str) or len(value) > limit:
raise ValueError(f'{name} must be text of at most {limit} characters.')
return value
def validate_memories(value):
if not isinstance(value, list) or len(value) > 100:
raise ValueError('Invalid long-term memory list.')
result = []
for item in value:
if not isinstance(item, dict) or item.get('importance') not in ('high', 'medium', 'low'):
raise ValueError('Invalid memory importance.')
content = validate_text(item.get('content'), 'memory', 2000).strip()
if not content:
raise ValueError('Memory content cannot be empty.')
result.append({'content': content, 'importance': item['importance']})
return result
def validate_save(data):
if not isinstance(data, dict) or data.get('version') not in ('1.0', '2.0', VERSION):
raise ValueError('Unsupported or missing save version.')
char_id = validate_character_id(data.get('character_id'))
persona = data.get('persona', {})
if not isinstance(persona, dict):
raise ValueError('Persona must be an object.')
persona = {key: validate_text(persona.get(key, ''), key, 4000) for key in PERSONA_FIELDS}
conv = data.get('conversation')
if not isinstance(conv, dict):
raise ValueError('Conversation must be an object.')
messages = conv.get('recent_messages')
if not isinstance(messages, list) or len(messages) > 10000:
raise ValueError('Invalid message list.')
clean = []
for item in messages:
if not isinstance(item, dict) or item.get('role') not in ('user', 'assistant'):
raise ValueError('Invalid message role.')
clean.append({'role': item['role'], 'content': validate_text(item.get('content'), 'message', 32000)})
count = conv.get('summarized_count', 0) if data['version'] != '1.0' else 0
if type(count) is not int or not 0 <= count <= len(clean):
raise ValueError('Invalid summary position.')
summary = validate_text(conv.get('summary', ''), 'summary', 8000)
if count and not summary.strip():
raise ValueError('Summarized messages require a summary.')
memories = validate_memories(conv.get('long_term_memory', []))
result = {'recent_messages': clean, 'summary': summary, 'long_term_memory': memories,
'summarized_count': count, 'revision': 0}
upgrade_conversation(result)
if data['version'] == VERSION:
for item, raw in zip(memories, conv.get('long_term_memory', [])):
identifier = raw.get('id')
origin = raw.get('origin')
start, end = raw.get('start'), raw.get('end')
if not isinstance(identifier, str) or not re.fullmatch(r'[0-9a-f]{32}', identifier):
raise ValueError('Invalid memory identifier.')
if origin not in ('auto', 'manual'):
raise ValueError('Invalid memory origin.')
if type(start) is not int or type(end) is not int or not 0 <= start <= end <= len(clean):
raise ValueError('Invalid memory source.')
item.update(id=identifier, origin=origin, start=start, end=end)
if len({m['id'] for m in memories}) != len(memories) or sum(m['origin'] == 'manual' for m in memories) > 60:
raise ValueError('Duplicate memory IDs or too many personal memories.')
excluded = conv.get('excluded_sources', [])
if not isinstance(excluded, list) or len(excluded) > 10000:
raise ValueError('Invalid source exclusions.')
for span in excluded:
if not isinstance(span, list) or len(span) != 2 or any(type(x) is not int for x in span) or not 0 <= span[0] < span[1] <= len(clean):
raise ValueError('Invalid source exclusion.')
forgotten = conv.get('forgotten_facts', [])
if not isinstance(forgotten, list) or len(forgotten) > 1000 or any(not isinstance(x, str) or not re.fullmatch(r'[0-9a-f]{64}', x) for x in forgotten):
raise ValueError('Invalid forgotten-fact list.')
result.update(excluded_sources=excluded, forgotten_facts=forgotten,
relationship=validate_relationship(conv.get('relationship', get_default_relationship()), len(clean)))
else:
for item in memories:
item['end'] = count or len(clean)
return {'version': VERSION, 'character_id': char_id, 'persona': persona, 'conversation': result}
def export_save(character_id, persona, session_id):
data = validate_save({'version': VERSION, 'character_id': character_id,
'persona': persona or {}, 'conversation': load_conversation(session_id, character_id)})
data['saved_at'] = datetime.now(timezone.utc).isoformat()
payload = json.dumps(data, ensure_ascii=False, indent=2).encode('utf-8')
if len(payload) > MAX_SAVE_BYTES:
raise ValueError('Conversation exceeds the 4 MB save limit.')
with tempfile.NamedTemporaryFile(prefix=character_id + '_', suffix='_save.json', delete=False) as handle:
handle.write(payload)
return handle.name
def import_save(file_path):
with open(file_path, 'rb') as handle:
payload = handle.read(MAX_SAVE_BYTES + 1)
if len(payload) > MAX_SAVE_BYTES:
raise ValueError('Save file exceeds 4 MB.')
try:
return validate_save(json.loads(payload))
except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc:
raise ValueError('Save file is not valid JSON.') from exc
def restore_save(data, session_id):
validated = validate_save(data)
save_conversation(session_id, validated['character_id'], validated['conversation'])
return validated
|