breaking(conversation): 💥 Introduce unique UID system for ConversationStore and enforce UID-based serialization in ConversationOrchestrator

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-28 15:06:23 -07:00
parent 99146d162a
commit 255c8a537f
3 changed files with 280 additions and 18 deletions

View file

@ -60,6 +60,7 @@ var _microphone: Node
var _stt_client: Node
var _tts_client: Node
var _llm_client: Node
var _store: Node
var _regex: RegEx
@ -68,11 +69,13 @@ func setup(
stt_client: Node,
tts_client: Node,
llm_client: Node,
store: Node = null,
) -> void:
_microphone = microphone
_stt_client = stt_client
_tts_client = tts_client
_llm_client = llm_client
_store = store
_regex = RegEx.new()
_regex.compile(EMOTION_PATTERN)
@ -131,15 +134,9 @@ func _on_transcript_ready(text: String) -> void:
if _state != "processing":
return
(
_history
. append(
{
"role": "user",
"content": text,
}
)
)
_history.append({"role": "user", "content": text})
if _store != null:
_store.save_message("user", text)
_sentence_buffer = ""
_current_emotion = "neutral"
@ -151,15 +148,9 @@ func _on_text_submitted(text: String) -> void:
if _state == "speaking":
_interrupt()
(
_history
. append(
{
"role": "user",
"content": text,
}
)
)
_history.append({"role": "user", "content": text})
if _store != null:
_store.save_message("user", text)
_sentence_buffer = ""
_current_emotion = "neutral"
@ -195,6 +186,8 @@ func _on_response_complete(full_text: String) -> void:
_sentence_buffer = ""
_history.append({"role": "assistant", "content": full_text})
if _store != null:
_store.save_message("assistant", full_text)
_trim_history()
if _state == "speaking" and _tts_client.is_queue_empty():
@ -267,6 +260,21 @@ func _emotion_to_exaggeration(emotion: String) -> float:
return EXAGGERATION_MAP.get(emotion, 0.5)
func load_conversation(messages: Array[Dictionary]) -> void:
_history = []
var start := maxi(0, messages.size() - 20)
for i: int in range(start, messages.size()):
_history.append(messages[i])
func clear_history() -> void:
_history = []
_sentence_buffer = ""
_current_emotion = "neutral"
if _state != "idle":
_transition("idle")
func _trim_history() -> void:
while _history.size() > 20:
_history.pop_front()

View file

@ -0,0 +1,253 @@
extends Node
## Persistence layer for conversations.
## Each conversation is a JSON file under user://conversations/.
## AppState stores only a lightweight index (active ID + metadata list).
## Write coalescing: at most one disk write per frame, plus final flush on exit.
const CONVERSATIONS_DIR: String = "user://conversations"
var _active_id: String = ""
var _active_messages: Array[Dictionary] = []
var _dirty: bool = false
func setup() -> void:
_ensure_dir()
_load_index()
get_tree().root.tree_exiting.connect(_flush_sync)
func create_conversation() -> String:
if _dirty:
_flush()
var id := str(int(Time.get_unix_time_from_system()))
var now := Time.get_datetime_string_from_system(true)
_active_id = id
_active_messages = []
_write_conversation_file(id, "", now, [])
_update_index_entry(id, "", now, 0)
_save_active_id()
FlightRecorder.record("conversation.created", "New conversation %s" % id)
EventBus.conversation_changed.emit(id)
return id
func save_message(role: String, content: String) -> void:
if _active_id.is_empty():
return
_active_messages.append({"role": role, "content": content})
if role == "user" and _active_messages.size() == 1:
var title := _make_title(content)
_update_index_title(_active_id, title)
_update_index_count(_active_id, _active_messages.size())
_mark_dirty()
func load_conversation(id: String) -> Array[Dictionary]:
var data := _read_conversation_file(id)
if data.is_empty():
return []
var messages: Array[Dictionary] = []
for msg: Dictionary in data.get("messages", []):
messages.append(msg)
return messages
func switch_to(id: String) -> Array[Dictionary]:
if _dirty:
_flush()
var messages := load_conversation(id)
_active_id = id
_active_messages = messages.duplicate()
_save_active_id()
FlightRecorder.record("conversation.switched", "Switched to %s" % id)
EventBus.conversation_changed.emit(id)
return messages
func get_active_id() -> String:
return _active_id
func get_conversation_list() -> Array[Dictionary]:
var index := AppState.get_section("conversations")
var list: Array[Dictionary] = []
for entry: Dictionary in index.get("list", []):
list.append(entry)
return list
func delete_conversation(id: String) -> void:
var path := "%s/%s.json" % [CONVERSATIONS_DIR, id]
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
var index := AppState.get_section("conversations")
var list: Array = index.get("list", [])
var filtered: Array = []
for entry: Dictionary in list:
if entry.get("id", "") != id:
filtered.append(entry)
index["list"] = filtered
if index.get("active_id", "") == id:
index["active_id"] = ""
_active_id = ""
_active_messages = []
AppState.set_section("conversations", index)
FlightRecorder.record("conversation.deleted", "Deleted %s" % id)
EventBus.conversation_deleted.emit(id)
# -- Private ------------------------------------------------------------------
func _ensure_dir() -> void:
if not DirAccess.dir_exists_absolute(CONVERSATIONS_DIR):
DirAccess.make_dir_recursive_absolute(CONVERSATIONS_DIR)
func _load_index() -> void:
var index := AppState.get_section("conversations")
_active_id = index.get("active_id", "")
if not _active_id.is_empty():
_active_messages = load_conversation(_active_id)
if _active_messages.is_empty():
_active_id = ""
func _save_active_id() -> void:
var index := AppState.get_section("conversations")
index["active_id"] = _active_id
AppState.set_section("conversations", index)
func _make_title(first_message: String) -> String:
var title := first_message.strip_edges()
if title.length() > 40:
title = title.substr(0, 37) + "..."
return title
func _mark_dirty() -> void:
if not _dirty:
_dirty = true
call_deferred("_flush")
func _flush() -> void:
if not _dirty:
return
_dirty = false
if _active_id.is_empty():
return
var index := AppState.get_section("conversations")
var list: Array = index.get("list", [])
var title := ""
var created_at := ""
for entry: Dictionary in list:
if entry.get("id", "") == _active_id:
title = entry.get("title", "")
created_at = entry.get("created_at", "")
break
_write_conversation_file(_active_id, title, created_at, _active_messages)
func _flush_sync() -> void:
if _dirty:
_dirty = false
if not _active_id.is_empty():
_flush()
_dirty = false
func _write_conversation_file(
id: String,
title: String,
created_at: String,
messages: Array[Dictionary],
) -> void:
var data := {
"id": id,
"title": title,
"created_at": created_at,
"messages": messages,
}
var json_str := JSON.stringify(data, "\t")
var path := "%s/%s.json" % [CONVERSATIONS_DIR, id]
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
push_error("ConversationStore: Failed to write %s" % path)
return
file.store_string(json_str)
func _read_conversation_file(id: String) -> Dictionary:
var path := "%s/%s.json" % [CONVERSATIONS_DIR, id]
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return {}
var content := file.get_as_text()
var json := JSON.new()
if json.parse(content) != OK:
push_error("ConversationStore: Failed to parse %s" % path)
return {}
if json.data is Dictionary:
return json.data
return {}
func _update_index_entry(
id: String,
title: String,
created_at: String,
message_count: int,
) -> void:
var index := AppState.get_section("conversations")
if not index.has("list"):
index["list"] = []
var list: Array = index["list"]
(
list
. push_front(
{
"id": id,
"title": title,
"created_at": created_at,
"message_count": message_count,
}
)
)
AppState.set_section("conversations", index)
func _update_index_title(id: String, title: String) -> void:
var index := AppState.get_section("conversations")
for entry: Dictionary in index.get("list", []):
if entry.get("id", "") == id:
entry["title"] = title
break
AppState.set_section("conversations", index)
func _update_index_count(id: String, count: int) -> void:
var index := AppState.get_section("conversations")
for entry: Dictionary in index.get("list", []):
if entry.get("id", "") == id:
entry["message_count"] = count
break
AppState.set_section("conversations", index)

View file

@ -0,0 +1 @@
uid://bsk0ji1n3o2yq