From 255c8a537f3b473f4a378aff04fe9997a7788651 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 28 Mar 2026 15:06:23 -0700 Subject: [PATCH] =?UTF-8?q?breaking(conversation):=20=F0=9F=92=A5=20Introd?= =?UTF-8?q?uce=20unique=20UID=20system=20for=20ConversationStore=20and=20e?= =?UTF-8?q?nforce=20UID-based=20serialization=20in=20ConversationOrchestra?= =?UTF-8?q?tor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../conversation/conversation_orchestrator.gd | 44 +-- .../godot/conversation/conversation_store.gd | 253 ++++++++++++++++++ .../conversation/conversation_store.gd.uid | 1 + 3 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 shared/godot/conversation/conversation_store.gd create mode 100644 shared/godot/conversation/conversation_store.gd.uid diff --git a/shared/godot/conversation/conversation_orchestrator.gd b/shared/godot/conversation/conversation_orchestrator.gd index 7440034..ad78658 100644 --- a/shared/godot/conversation/conversation_orchestrator.gd +++ b/shared/godot/conversation/conversation_orchestrator.gd @@ -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() diff --git a/shared/godot/conversation/conversation_store.gd b/shared/godot/conversation/conversation_store.gd new file mode 100644 index 0000000..cf39645 --- /dev/null +++ b/shared/godot/conversation/conversation_store.gd @@ -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) diff --git a/shared/godot/conversation/conversation_store.gd.uid b/shared/godot/conversation/conversation_store.gd.uid new file mode 100644 index 0000000..759d88c --- /dev/null +++ b/shared/godot/conversation/conversation_store.gd.uid @@ -0,0 +1 @@ +uid://bsk0ji1n3o2yq