Class: WritersRoom::ChatSession

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initialize(context: {}, template: nil, project_path: nil) ⇒ ChatSession

Returns a new instance of ChatSession.

Parameters:

  • (defaults to: {})

    conversation context (project info, task, etc.)

  • (defaults to: nil)

    optional RobotLab template for the session

  • (defaults to: nil)

    path to the WritersRoom project for saving elements



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
  @messages     = []
  setup_logger
  setup_robot
end

Instance Attribute Details

#contextObject (readonly)

Returns the value of attribute context.



9
10
11
# File 'lib/writers_room/chat_session.rb', line 9

def context
  @context
end

#loggerObject (readonly)

Returns the value of attribute logger.



9
10
11
# File 'lib/writers_room/chat_session.rb', line 9

def logger
  @logger
end

#messagesObject (readonly)

Returns the value of attribute messages.



9
10
11
# File 'lib/writers_room/chat_session.rb', line 9

def messages
  @messages
end

#project_pathObject (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

#robotObject (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(user_message)
  @messages << { role: "user", content: user_message }
  @logger.info("USER: #{user_message}")

  result = @robot.run(user_message)
  assistant_message = result.reply || ""

  @messages << { role: "assistant", content: assistant_message }
  @logger.info("ASSISTANT: #{assistant_message}")

  if guardrail_refusal?(assistant_message)
    @logger.warn("GUARDRAIL: refusal detected, removing exchange from context")
    @messages.pop(2)
    return { text: assistant_message, guardrail: true }
  end

  { text: assistant_message, guardrail: false }
end

#context_textObject

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_saveObject

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 @messages.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 @messages.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_lastObject

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 = @messages.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_summaryObject

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

Returns:



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_textObject

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")
    @messages.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_textObject

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_endObject

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_startObject

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"

  @messages.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

#summaryObject

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 @messages.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_textObject

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"