Module: Legion::LLM::ConversationStore
- Extended by:
- Legion::Logging::Helper
- Defined in:
- lib/legion/llm/conversation_store.rb
Constant Summary collapse
- MAX_CONVERSATIONS =
256- METADATA_ROLE =
:__metadata__
Class Method Summary collapse
- .append(conversation_id, role:, content:, parent_id: nil, sidechain: false, message_group_id: nil, agent_id: nil, **metadata) ⇒ Object
-
.branch(conversation_id, from_message_id:) ⇒ Object
Create a new conversation branched from from_message_id.
-
.build_chain(conversation_id, include_sidechains: false) ⇒ Object
Build ordered chain from parent links.
-
.cancel_skill!(conversation_id) ⇒ Object
Reads current state, clears :skill_state, sets :skill_cancelled flag.
- .clear_cancel_flag(conversation_id) ⇒ Object
- .clear_skill_state(conversation_id) ⇒ Object
- .conversation_exists?(conversation_id) ⇒ Boolean
- .create_conversation(conversation_id, **metadata) ⇒ Object
- .in_memory?(conversation_id) ⇒ Boolean
-
.messages(conversation_id) ⇒ Object
Returns flat ordered message array — backward-compatible.
-
.migrate_parent_links!(conversation_id) ⇒ Object
Migrate existing sequential messages to use parent links.
-
.read_metadata(conversation_id, tail_n: 20) ⇒ Object
Read metadata stored by store_metadata; scans tail of message list.
- .replace(conversation_id, messages) ⇒ Object
- .reset! ⇒ Object
- .set_skill_state(conversation_id, skill_key:, resume_at:) ⇒ Object
-
.sidechain_messages(conversation_id, agent_id: nil) ⇒ Object
Return sidechain messages; optionally filter by agent_id.
-
.skill_cancelled?(conversation_id) ⇒ Boolean
:skill_cancelled is distinct from a nil :skill_state.
- .skill_state(conversation_id) ⇒ Object
-
.store_metadata(conversation_id, title: nil, tags: nil, model: nil) ⇒ Object
Store session metadata as a special entry (tail-window pattern).
Class Method Details
.append(conversation_id, role:, content:, parent_id: nil, sidechain: false, message_group_id: nil, agent_id: nil, **metadata) ⇒ Object
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
# File 'lib/legion/llm/conversation_store.rb', line 15 def append(conversation_id, role:, content:, parent_id: nil, sidechain: false, message_group_id: nil, agent_id: nil, **) ensure_conversation(conversation_id) id = SecureRandom.uuid seq = next_seq(conversation_id) msg = { id: id, seq: seq, role: role, content: content, parent_id: parent_id, sidechain: sidechain, message_group_id: , agent_id: agent_id, created_at: Time.now, ** } conversations[conversation_id][:messages] << msg touch(conversation_id) (conversation_id, msg) msg end |
.branch(conversation_id, from_message_id:) ⇒ Object
Create a new conversation branched from from_message_id. Copies all messages up to and including that message into a new conversation.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/legion/llm/conversation_store.rb', line 69 def branch(conversation_id, from_message_id:) raw = (conversation_id) target = raw.find { |m| m[:id] == } raise ArgumentError, "Message #{} not found in #{conversation_id}" unless target chain = reconstruct_chain(raw) # Only keep messages up to (and including) the target message by seq cutoff_seq = target[:seq] prefix = chain.select { |m| m[:seq] <= cutoff_seq } new_id = SecureRandom.uuid create_conversation(new_id) prefix.each_with_index do |msg, i| new_msg = msg.merge(seq: i + 1, id: SecureRandom.uuid, parent_id: nil, created_at: Time.now) conversations[new_id][:messages] << new_msg (new_id, new_msg) end touch(new_id) new_id end |
.build_chain(conversation_id, include_sidechains: false) ⇒ Object
Build ordered chain from parent links. Excludes sidechain messages by default.
52 53 54 55 56 57 |
# File 'lib/legion/llm/conversation_store.rb', line 52 def build_chain(conversation_id, include_sidechains: false) raw = (conversation_id) raw = raw.reject { |m| m[:sidechain] } unless include_sidechains raw = raw.reject { |m| m[:role] == METADATA_ROLE } reconstruct_chain(raw) end |
.cancel_skill!(conversation_id) ⇒ Object
Reads current state, clears :skill_state, sets :skill_cancelled flag. Returns the previous state (for use in skill.cancelled payload), or nil if none.
172 173 174 175 176 177 178 179 180 |
# File 'lib/legion/llm/conversation_store.rb', line 172 def cancel_skill!(conversation_id) ensure_conversation(conversation_id) state = conversations[conversation_id].delete(:skill_state) if state conversations[conversation_id][:skill_cancelled] = true touch(conversation_id) end state end |
.clear_cancel_flag(conversation_id) ⇒ Object
190 191 192 193 194 195 |
# File 'lib/legion/llm/conversation_store.rb', line 190 def clear_cancel_flag(conversation_id) return unless in_memory?(conversation_id) conversations[conversation_id].delete(:skill_cancelled) touch(conversation_id) end |
.clear_skill_state(conversation_id) ⇒ Object
163 164 165 166 167 168 |
# File 'lib/legion/llm/conversation_store.rb', line 163 def clear_skill_state(conversation_id) return unless in_memory?(conversation_id) conversations[conversation_id].delete(:skill_state) touch(conversation_id) end |
.conversation_exists?(conversation_id) ⇒ Boolean
137 138 139 |
# File 'lib/legion/llm/conversation_store.rb', line 137 def conversation_exists?(conversation_id) in_memory?(conversation_id) || db_conversation_exists?(conversation_id) end |
.create_conversation(conversation_id, **metadata) ⇒ Object
123 124 125 126 127 |
# File 'lib/legion/llm/conversation_store.rb', line 123 def create_conversation(conversation_id, **) conversations[conversation_id] = { messages: [], metadata: , lru_tick: next_tick } evict_if_needed persist_conversation(conversation_id, ) end |
.in_memory?(conversation_id) ⇒ Boolean
141 142 143 |
# File 'lib/legion/llm/conversation_store.rb', line 141 def in_memory?(conversation_id) conversations.key?(conversation_id) end |
.messages(conversation_id) ⇒ Object
Returns flat ordered message array — backward-compatible. Uses chain reconstruction when parent links exist; falls back to seq order.
40 41 42 43 44 45 46 47 48 |
# File 'lib/legion/llm/conversation_store.rb', line 40 def (conversation_id) if in_memory?(conversation_id) touch(conversation_id) raw = conversations[conversation_id][:messages].reject { |m| m[:role] == METADATA_ROLE } chain_or_seq(raw) else load_from_db(conversation_id) end end |
.migrate_parent_links!(conversation_id) ⇒ Object
Migrate existing sequential messages to use parent links. Safe to call on already-migrated data (no-op when parent links present).
199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/legion/llm/conversation_store.rb', line 199 def migrate_parent_links!(conversation_id) ensure_conversation(conversation_id) msgs = conversations[conversation_id][:messages].sort_by { |m| m[:seq] } return if msgs.empty? return if msgs.any? { |m| m[:parent_id] } prev_id = nil msgs.each do |msg| msg[:parent_id] = prev_id prev_id = msg[:id] ||= SecureRandom.uuid end touch(conversation_id) end |
.read_metadata(conversation_id, tail_n: 20) ⇒ Object
Read metadata stored by store_metadata; scans tail of message list.
112 113 114 115 116 117 118 119 120 121 |
# File 'lib/legion/llm/conversation_store.rb', line 112 def (conversation_id, tail_n: 20) raw = (conversation_id) tail = raw.last(tail_n).select { |m| m[:role] == METADATA_ROLE } return nil if tail.empty? entry = tail.last ::JSON.parse(entry[:content], symbolize_names: true) rescue ::JSON::ParserError nil end |
.replace(conversation_id, messages) ⇒ Object
129 130 131 132 133 134 135 |
# File 'lib/legion/llm/conversation_store.rb', line 129 def replace(conversation_id, ) ensure_conversation(conversation_id) conversations[conversation_id][:messages] = .each_with_index.map do |msg, i| msg.merge(seq: i + 1, created_at: msg[:created_at] || Time.now) end touch(conversation_id) end |
.reset! ⇒ Object
145 146 147 148 |
# File 'lib/legion/llm/conversation_store.rb', line 145 def reset! @conversations = {} @lru_counter = 0 end |
.set_skill_state(conversation_id, skill_key:, resume_at:) ⇒ Object
150 151 152 153 154 |
# File 'lib/legion/llm/conversation_store.rb', line 150 def set_skill_state(conversation_id, skill_key:, resume_at:) ensure_conversation(conversation_id) conversations[conversation_id][:skill_state] = { skill_key: skill_key, resume_at: resume_at } touch(conversation_id) end |
.sidechain_messages(conversation_id, agent_id: nil) ⇒ Object
Return sidechain messages; optionally filter by agent_id.
60 61 62 63 64 65 |
# File 'lib/legion/llm/conversation_store.rb', line 60 def (conversation_id, agent_id: nil) raw = (conversation_id) result = raw.select { |m| m[:sidechain] && m[:role] != METADATA_ROLE } result = result.select { |m| m[:agent_id] == agent_id } unless agent_id.nil? result.sort_by { |m| m[:seq] } end |
.skill_cancelled?(conversation_id) ⇒ Boolean
:skill_cancelled is distinct from a nil :skill_state. nil skill_state also occurs after normal completion — use this flag to detect cancel.
184 185 186 187 188 |
# File 'lib/legion/llm/conversation_store.rb', line 184 def skill_cancelled?(conversation_id) return false unless in_memory?(conversation_id) conversations[conversation_id][:skill_cancelled] == true end |
.skill_state(conversation_id) ⇒ Object
156 157 158 159 160 161 |
# File 'lib/legion/llm/conversation_store.rb', line 156 def skill_state(conversation_id) return nil unless in_memory?(conversation_id) touch(conversation_id) conversations[conversation_id][:skill_state]&.dup end |
.store_metadata(conversation_id, title: nil, tags: nil, model: nil) ⇒ Object
Store session metadata as a special entry (tail-window pattern).
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/legion/llm/conversation_store.rb', line 91 def (conversation_id, title: nil, tags: nil, model: nil) ensure_conversation(conversation_id) payload = { title: title, tags: , model: model }.compact msg = { id: SecureRandom.uuid, seq: next_seq(conversation_id), role: METADATA_ROLE, content: payload.to_json, parent_id: nil, sidechain: false, message_group_id: nil, agent_id: nil, created_at: Time.now } conversations[conversation_id][:messages] << msg touch(conversation_id) (conversation_id, msg) msg end |