Class: WritersRoom::ChatSession
- Inherits:
-
Object
- Object
- WritersRoom::ChatSession
- Defined in:
- lib/writers_room/chat_session.rb
Overview
Interactive chat session with LLM for creative consultation. Owns business logic only — no rendering. ChatTui handles the terminal UI.
Constant Summary collapse
- CONTEXT_DISPLAY_SKIP =
Context keys that contain large data structures meant for the LLM, not for display to the user or inclusion in saved transcripts.
i[additional existing_elements project_elements element_contents].freeze
Instance Attribute Summary collapse
-
#context ⇒ Object
readonly
Returns the value of attribute context.
-
#logger ⇒ Object
readonly
Returns the value of attribute logger.
-
#messages ⇒ Object
readonly
Returns the value of attribute messages.
-
#project_path ⇒ Object
readonly
Returns the value of attribute project_path.
-
#robot ⇒ Object
readonly
Returns the value of attribute robot.
Instance Method Summary collapse
-
#chat(user_message) ⇒ Object
Send a user message to the LLM.
-
#context_text ⇒ Object
Return the context as a markdown string.
-
#execute_auto_save ⇒ Object
Execute auto-save.
-
#execute_save(element_type, name) ⇒ Object
Execute a save operation.
-
#execute_save_last ⇒ Object
Save the last assistant response directly as a project element.
-
#execute_summary ⇒ Object
Execute summary.
-
#exit_command?(input) ⇒ Boolean
Check if input is an exit command.
-
#goodbye_text ⇒ Object
Return goodbye text as a markdown string.
-
#handle_command(input) ⇒ Object
Handle a command.
-
#help_text ⇒ Object
Return the help text as a markdown string.
-
#initialize(context: {}, template: nil, project_path: nil) ⇒ ChatSession
constructor
A new instance of ChatSession.
-
#log_session_end ⇒ Object
Log session end (called by ChatTui on exit).
-
#log_session_start ⇒ Object
Log session start (called by ChatTui on launch).
-
#save(filepath) ⇒ Object
Save conversation transcript to file.
-
#summary ⇒ Object
Get the conversation summary.
-
#welcome_text ⇒ Object
Return welcome text as a markdown string.
Constructor Details
#initialize(context: {}, template: nil, project_path: nil) ⇒ ChatSession
Returns a new instance of ChatSession.
18 19 20 21 22 23 24 25 |
# File 'lib/writers_room/chat_session.rb', line 18 def initialize(context: {}, template: nil, project_path: nil) @context = context @template = template @project_path = project_path = [] setup_logger setup_robot end |
Instance Attribute Details
#context ⇒ Object (readonly)
Returns the value of attribute context.
9 10 11 |
# File 'lib/writers_room/chat_session.rb', line 9 def context @context end |
#logger ⇒ Object (readonly)
Returns the value of attribute logger.
9 10 11 |
# File 'lib/writers_room/chat_session.rb', line 9 def logger @logger end |
#messages ⇒ Object (readonly)
Returns the value of attribute messages.
9 10 11 |
# File 'lib/writers_room/chat_session.rb', line 9 def end |
#project_path ⇒ Object (readonly)
Returns the value of attribute project_path.
9 10 11 |
# File 'lib/writers_room/chat_session.rb', line 9 def project_path @project_path end |
#robot ⇒ Object (readonly)
Returns the value of attribute robot.
9 10 11 |
# File 'lib/writers_room/chat_session.rb', line 9 def robot @robot end |
Instance Method Details
#chat(user_message) ⇒ Object
Send a user message to the LLM. Returns the assistant reply string. Does NOT render anything — ChatTui handles display.
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/writers_room/chat_session.rb', line 46 def chat() << { role: "user", content: } @logger.info("USER: #{user_message}") result = @robot.run() = result.reply || "" << { role: "assistant", content: } @logger.info("ASSISTANT: #{assistant_message}") if guardrail_refusal?() @logger.warn("GUARDRAIL: refusal detected, removing exchange from context") .pop(2) return { text: , guardrail: true } end { text: , guardrail: false } end |
#context_text ⇒ Object
Return the context as a markdown string
172 173 174 175 176 177 178 179 180 181 |
# File 'lib/writers_room/chat_session.rb', line 172 def context_text return "" if @context.empty? lines = ["## Context\n"] @context.each do |key, value| next if CONTEXT_DISPLAY_SKIP.include?(key) lines << "- **#{key}**: #{value}" end lines.join("\n") end |
#execute_auto_save ⇒ Object
Execute auto-save. Returns array of markdown output strings. Blocks on LLM calls.
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 |
# File 'lib/writers_room/chat_session.rb', line 247 def execute_auto_save outputs = [] unless @project_path @logger.warn("Auto-save failed: no project context") return ["**No project context** — cannot save elements."] end if .empty? @logger.warn("Auto-save failed: no conversation yet") return ["**Nothing discussed yet** — have a conversation first."] end types = valid_element_types type_list = types.keys.join(", ") @logger.info("Auto-detecting elements to save (valid types: #{type_list})") outputs << "*Analyzing conversation to determine what to save...*" detection_prompt = " Based on our conversation, what story elements did we discuss or develop?\n\n Respond with ONLY lines in this format, one per element. No other text:\n TYPE: NAME\n\n Where TYPE is EXACTLY one of: \#{type_list}\n And NAME is the element name.\n\n If we discussed multiple elements, list each on its own line.\n Only include elements where we developed meaningful content worth saving.\n Do NOT use any type not in the list above.\n PROMPT\n\n result = @robot.run(detection_prompt)\n reply = result.reply || \"\"\n\n elements = reply.strip.lines.filter_map { |line|\n if line =~ /\\A\\s*(\\w+):\\s*(.+)/i\n type = $1.strip.downcase\n name = $2.strip\n if types.key?(type)\n [type, name]\n else\n @logger.warn(\"Auto-save: skipping unknown type '\#{type}' for '\#{name}'\")\n nil\n end\n end\n }\n\n if elements.empty?\n outputs << <<~MSG\n **Could not detect any elements** to save from the conversation.\n\n Try: `save <type> <name>` (e.g. `save character Alice`)\n MSG\n return outputs\n end\n\n lines = [\"Found **\#{elements.size}** element(s) to save:\\n\"]\n elements.each { |type, name| lines << \"- \#{type}: **\#{name}**\" }\n outputs << lines.join(\"\\n\")\n\n elements.each do |type, name|\n outputs.concat(execute_save(type, name))\n end\n\n outputs\nend\n" |
#execute_save(element_type, name) ⇒ Object
Execute a save operation. Returns array of markdown output strings. Blocks on LLM calls.
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 |
# File 'lib/writers_room/chat_session.rb', line 200 def execute_save(element_type, name) outputs = [] unless @project_path @logger.warn("Save failed: no project context") return ["**No project context** — cannot save elements."] end types = valid_element_types unless types.key?(element_type) @logger.warn("Save failed: unknown element type '#{element_type}'") return ["**Unknown element type** `#{element_type}`. Valid types: #{types.keys.join(', ')}"] end if .empty? @logger.warn("Save failed: no conversation yet") return ["**Nothing discussed yet** — have a conversation first."] end @logger.info("Extracting #{element_type} details for '#{name}'") outputs << "*Extracting #{element_type} details for '#{name}'...*" extraction_prompt = " Based on our conversation, extract the key details for a \#{element_type} named \"\#{name}\".\n\n Respond with ONLY the following format, no other text:\n\n NAME: \#{name}\n PREVIOUS_NAMES: comma-separated list of any earlier names this \#{element_type} was known by during our conversation, or NONE\n ALIASES: comma-separated list of alternate names/nicknames, or NONE\n STATUS: draft\n ---BODY---\n Write a concise profile/description based on what we discussed. Include all important details: personality, background, motivation, relationships, or any other relevant information we covered. Use plain prose, not bullet points.\n PROMPT\n\n result = @robot.run(extraction_prompt)\n reply = result.reply || \"\"\n @logger.debug(\"LLM extraction response:\\n\#{reply}\")\n\n parsed = parse_extraction(reply, name)\n @logger.info(\"Parsed extraction: name=\#{parsed[:name]}, aliases=\#{parsed[:aliases].inspect}, status=\#{parsed[:status]}, body_length=\#{parsed[:body].length}\")\n outputs << save_element(element_type, parsed)\n outputs\nend\n" |
#execute_save_last ⇒ Object
Save the last assistant response directly as a project element. Uses a quick LLM call to identify type and name, then writes the raw content — no re-extraction or summarization. Returns array of markdown output strings.
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 |
# File 'lib/writers_room/chat_session.rb', line 320 def execute_save_last unless @project_path @logger.warn("Save-last failed: no project context") return ["**No project context** — cannot save elements."] end last_assistant = .reverse.find { |m| m[:role] == "assistant" } unless last_assistant @logger.warn("Save-last failed: no assistant message") return ["**Nothing to save** — no assistant response yet."] end types = valid_element_types type_list = types.keys.join(", ") @logger.info("Save-last: identifying element from last response") id_prompt = " Look at the last thing you wrote in our conversation.\n What single story element best describes it?\n\n Respond with ONLY one line in this format, no other text:\n TYPE: NAME\n\n Where TYPE is EXACTLY one of: \#{type_list}\n And NAME is the element name (e.g. \"Chapter 2\" or \"Mara Kode\").\n Do NOT use any type not in the list above.\n PROMPT\n\n result = @robot.run(id_prompt)\n reply = result.reply || \"\"\n\n match = reply.strip.match(/\\A\\s*(\\w+):\\s*(.+)/i)\n unless match\n @logger.warn(\"Save-last: could not identify element from LLM response: \#{reply}\")\n return [\"**Could not identify** what to save. Try: `save <type> <name>` (e.g. `save chapter Chapter 2`)\"]\n end\n\n element_type = match[1].strip.downcase\n name = match[2].strip\n\n unless types.key?(element_type)\n @logger.warn(\"Save-last: LLM returned invalid type '\#{element_type}'\")\n return [\"**Unknown element type** `\#{element_type}`. Try: `save <type> <name>` where type is one of: \#{type_list}\"]\n end\n\n content = last_assistant[:content]\n\n @logger.info(\"Save-last: saving \#{element_type} '\#{name}' (\#{content.length} chars)\")\n\n dir = resolve_element_dir(element_type)\n slug = Element.sanitize(name)\n path = File.join(dir, \"\#{slug}.md\")\n\n metadata = { \"name\" => name, \"status\" => \"draft\" }\n parsed = { name: name, previous_names: [], aliases: [], status: \"draft\", body: content }\n\n old_path = find_existing_element(dir, parsed)\n\n if old_path && old_path != path\n el = Element.load(old_path)\n el.metadata.merge!(FrontMatter.deep_symbolize_keys(metadata))\n el.instance_variable_set(:@body, content)\n el.instance_variable_set(:@path, path)\n el.instance_variable_set(:@slug, slug)\n el.save\n File.delete(old_path)\n @logger.info(\"Renamed \#{element_type}: \#{File.basename(old_path, '.md')} -> \#{name} (\#{path})\")\n [\"**Saved** \#{element_type}: \#{name} (`\#{path}`)\"]\n elsif File.exist?(path)\n el = Element.load(path)\n el.metadata.merge!(FrontMatter.deep_symbolize_keys(metadata))\n el.instance_variable_set(:@body, content)\n el.save\n @logger.info(\"Updated \#{element_type}: \#{name} -> \#{path}\")\n [\"**Saved** \#{element_type}: \#{name} (`\#{path}`)\"]\n else\n require \"fileutils\"\n FileUtils.mkdir_p(dir)\n file_content = FrontMatter.dump(metadata, content)\n File.write(path, file_content)\n @logger.info(\"Created \#{element_type}: \#{name} -> \#{path}\")\n [\"**Saved** \#{element_type}: \#{name} (`\#{path}`)\"]\n end\nrescue StandardError => e\n @logger.error(\"Error in save-last: \#{e.message}\")\n [\"**Error** saving: \#{e.message}\"]\nend\n" |
#execute_summary ⇒ Object
Execute summary. Returns markdown string.
410 411 412 |
# File 'lib/writers_room/chat_session.rb', line 410 def execute_summary "\n## Summary\n\n#{summary}" end |
#exit_command?(input) ⇒ Boolean
Check if input is an exit command
105 106 107 108 |
# File 'lib/writers_room/chat_session.rb', line 105 def exit_command?(input) return false if input.nil? %w[exit quit q bye].include?(input.strip.downcase) end |
#goodbye_text ⇒ Object
Return goodbye text as a markdown string
194 195 196 |
# File 'lib/writers_room/chat_session.rb', line 194 def goodbye_text "**Chat session ended** — #{@messages.count / 2} exchanges" end |
#handle_command(input) ⇒ Object
Handle a command. Returns a Hash describing the result:
{ handled: true, output: "markdown string" }
{ handled: true, output: "...", clear_display: true }
{ handled: true, async: :summary }
{ handled: true, async: :save, args: [type, name] }
{ handled: true, async: :save_last }
{ handled: true, async: :auto_save }
{ handled: false }
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/writers_room/chat_session.rb', line 118 def handle_command(input) stripped = input.strip lowered = stripped.downcase # Match save with args: "save <type> <name>" if stripped =~ /\Asave\s+(\w+)\s+(.+)\z/i @logger.info("COMMAND: save #{$1} #{$2.strip}") return { handled: true, async: :save, args: [$1.downcase, $2.strip] } end case lowered when "help" @logger.info("COMMAND: help") { handled: true, output: help_text } when "context" @logger.info("COMMAND: context") { handled: true, output: context_text } when "summary" @logger.info("COMMAND: summary") { handled: true, async: :summary } when "clear" @logger.info("COMMAND: clear") .clear { handled: true, output: "*Conversation cleared*", clear_display: true } when "save", "save all" @logger.info("COMMAND: save (auto-detect)") { handled: true, async: :auto_save } when /\Asave(\s|$)/ @logger.info("COMMAND: save last") { handled: true, async: :save_last } else { handled: false } end end |
#help_text ⇒ Object
Return the help text as a markdown string
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/writers_room/chat_session.rb', line 154 def help_text " ## Commands\n\n - `save it` \u2014 save the last assistant response as a project element\n - `save <type> <name>` \u2014 save discussed content as an element\n - `save` / `save all` \u2014 auto-detect all elements and save them\n - `help` \u2014 show this help\n - `context` \u2014 show current context\n - `summary` \u2014 get conversation summary\n - `clear` \u2014 clear conversation history\n - `exit` \u2014 end chat session (also: `quit`, `q`, `bye`)\n\n Just type your message to chat with the LLM.\n HELP\nend\n" |
#log_session_end ⇒ Object
Log session end (called by ChatTui on exit)
39 40 41 42 |
# File 'lib/writers_room/chat_session.rb', line 39 def log_session_end @logger.info("Session ended — #{@messages.count / 2} exchanges") @logger.info("=" * 60) end |
#log_session_start ⇒ Object
Log session start (called by ChatTui on launch)
28 29 30 31 32 33 34 35 36 |
# File 'lib/writers_room/chat_session.rb', line 28 def log_session_start @logger.info("=" * 60) @logger.info("Session started") @context.each do |key, value| next if CONTEXT_DISPLAY_SKIP.include?(key) @logger.info(" #{key}: #{value}") end @logger.info("=" * 60) end |
#save(filepath) ⇒ Object
Save conversation transcript to file. Does NOT call the LLM — exits instantly.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/writers_room/chat_session.rb', line 79 def save(filepath) require "fileutils" FileUtils.mkdir_p(File.dirname(filepath)) content = "# Chat Session: #{Time.now}\n\n" content += "## Context\n\n" @context.each do |key, value| next if CONTEXT_DISPLAY_SKIP.include?(key) content += "- **#{key}**: #{value}\n" end content += "\n## Conversation\n\n" .each do |msg| if msg[:role] == "user" content += "**You**: #{msg[:content]}\n\n" else content += "**Assistant**: #{msg[:content]}\n\n" end end File.write(filepath, content) filepath end |
#summary ⇒ Object
Get the conversation summary. Blocks on LLM call.
66 67 68 69 70 71 72 73 74 75 |
# File 'lib/writers_room/chat_session.rb', line 66 def summary return "No conversation yet." if .empty? @robot.update(system_prompt: build_system_prompt) result = @robot.run( "Please provide a concise summary of our conversation and any key decisions or ideas that emerged." ) result.reply || "Unable to generate summary." end |
#welcome_text ⇒ Object
Return welcome text as a markdown string
184 185 186 187 188 189 190 191 |
# File 'lib/writers_room/chat_session.rb', line 184 def welcome_text " # WritersRoom Chat Session\n\n Type your questions or ideas. Type `exit` or `quit` to end.\n Type `help` for available commands.\n WELCOME\nend\n" |