writers_room 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+ ##########################################################
3
+ ###
4
+ ## File: actor.rb
5
+ ## Desc: AI-powered Actor for multi-character dialog generation
6
+ ## By: Dewayne VanHoozer (dvanhoozer@gmail.com)
7
+ #
8
+
9
+ require "debug_me"
10
+ include DebugMe
11
+
12
+ require "json"
13
+ require "redis"
14
+ require "ruby_llm"
15
+ require "smart_message"
16
+
17
+ # Load message classes
18
+ require_relative "messages/dialog_message"
19
+ require_relative "messages/scene_control_message"
20
+
21
+ module WritersRoom
22
+ class Actor
23
+ attr_reader :character_name, :character_info, :scene_info, :conversation_history
24
+
25
+ # Initialize an Actor with character information
26
+ #
27
+ # @param character_info [Hash] Character details
28
+ # @option character_info [String] :name Character's name (required)
29
+ # @option character_info [String] :personality Character traits and behaviors
30
+ # @option character_info [String] :voice_pattern How the character speaks
31
+ # @option character_info [Hash] :relationships Current relationship statuses
32
+ # @option character_info [String] :current_arc Where they are in their character arc
33
+ # @option character_info [String] :sport Associated sport/activity
34
+ # @option character_info [Integer] :age Character's age
35
+ def initialize(character_info)
36
+ @character_info = character_info
37
+ @character_name = character_info[:name] || character_info["name"]
38
+ @scene_info = {}
39
+ @conversation_history = []
40
+ @llm = nil
41
+ @running = false
42
+
43
+ validate_character_info!
44
+ setup_llm
45
+
46
+ debug_me("Actor initialized") { :character_name }
47
+ end
48
+
49
+ # Set the current scene information
50
+ #
51
+ # @param scene_info [Hash] Scene details
52
+ # @option scene_info [Integer] :scene_number Which scene this is
53
+ # @option scene_info [String] :scene_name Name/title of the scene
54
+ # @option scene_info [String] :location Where the scene takes place
55
+ # @option scene_info [Array<String>] :characters List of characters in scene
56
+ # @option scene_info [String] :objectives What this character wants in scene
57
+ # @option scene_info [String] :context Additional scene context
58
+ # @option scene_info [Integer] :week Which week in the timeline
59
+ def set_scene(scene_info)
60
+ @scene_info = scene_info
61
+ @conversation_history = [] # Reset history for new scene
62
+
63
+ debug_me("Scene set for #{@character_name}") do
64
+ [@scene_info[:scene_name], @scene_info[:scene_number]]
65
+ end
66
+ end
67
+
68
+ # Start the actor listening and responding to messages
69
+ #
70
+ # @param channel [String] Redis channel to subscribe to
71
+ def perform(channel: "writers_room:dialog")
72
+ @running = true
73
+
74
+ debug_me("#{@character_name} starting performance on channel: #{channel}")
75
+
76
+ # Subscribe to the dialog channel
77
+ subscribe_to_dialog(channel) do |message_data|
78
+ break unless @running
79
+
80
+ process_message(message_data)
81
+ end
82
+ end
83
+
84
+ # Stop the actor
85
+ def stop
86
+ @running = false
87
+ debug_me("#{@character_name} stopping")
88
+ end
89
+
90
+ # Generate dialog based on current context
91
+ #
92
+ # @param prompt_context [String] Optional additional context
93
+ # @return [String] Generated dialog line
94
+ def generate_dialog(prompt_context: nil)
95
+ system_prompt = build_system_prompt
96
+ user_prompt = build_user_prompt(prompt_context)
97
+
98
+ debug_me("Generating dialog for #{@character_name}") do
99
+ [system_prompt.length, user_prompt.length]
100
+ end
101
+
102
+ response = @llm.chat([
103
+ { role: "system", content: system_prompt },
104
+ { role: "user", content: user_prompt },
105
+ ])
106
+
107
+ dialog = extract_dialog(response)
108
+ @conversation_history << { speaker: @character_name, line: dialog, timestamp: Time.now }
109
+
110
+ dialog
111
+ end
112
+
113
+ # Send dialog to the scene via SmartMessage/Redis
114
+ #
115
+ # @param dialog [String] The dialog to send
116
+ # @param channel [String] Redis channel to publish to
117
+ # @param emotion [String] Optional emotional tone
118
+ # @param addressing [String] Optional character being addressed
119
+ def speak(dialog, channel: "writers_room:dialog", emotion: nil, addressing: nil)
120
+ message = DialogMessage.new(
121
+ from: @character_name,
122
+ content: dialog,
123
+ scene: @scene_info[:scene_number],
124
+ timestamp: Time.now.to_i,
125
+ emotion: emotion,
126
+ addressing: addressing,
127
+ )
128
+
129
+ message.publish(channel)
130
+
131
+ debug_me("#{@character_name} spoke") { dialog }
132
+ end
133
+
134
+ # React to incoming dialog and decide whether to respond
135
+ #
136
+ # @param message_data [Hash] Incoming message data
137
+ # @return [Boolean] Whether the actor responded
138
+ def react_to(message_data)
139
+ # Don't react to own messages
140
+ return false if message_data[:from] == @character_name
141
+
142
+ # Add to conversation history
143
+ @conversation_history << {
144
+ speaker: message_data[:from],
145
+ line: message_data[:content],
146
+ timestamp: Time.now,
147
+ }
148
+
149
+ # Decide whether to respond based on context
150
+ if should_respond?(message_data)
151
+ debug_me("#{@character_name} deciding to respond to #{message_data[:from]}")
152
+
153
+ response = generate_dialog(
154
+ prompt_context: "Responding to #{message_data[:from]}: '#{message_data[:content]}'",
155
+ )
156
+
157
+ speak(response)
158
+ return true
159
+ end
160
+
161
+ false
162
+ end
163
+
164
+ private
165
+
166
+ def validate_character_info!
167
+ raise ArgumentError, "Character name is required" unless @character_name
168
+
169
+ debug_me("Validated character info for #{@character_name}")
170
+ end
171
+
172
+ def setup_llm
173
+ # Initialize RubyLLM client with Ollama provider and gpt-oss model
174
+ # Can be overridden with environment variables:
175
+ # RUBY_LLM_PROVIDER - provider name (default: ollama)
176
+ # RUBY_LLM_MODEL - model name (default: gpt-oss)
177
+ # OLLAMA_URL - Ollama server URL (default: http://localhost:11434)
178
+
179
+ provider = ENV["RUBY_LLM_PROVIDER"] || "ollama"
180
+ model = ENV["RUBY_LLM_MODEL"] || "gpt-oss"
181
+ base_url = ENV["OLLAMA_URL"] || "http://localhost:11434"
182
+
183
+ @llm = RubyLLM::Client.new(
184
+ provider: provider,
185
+ model: model,
186
+ base_url: base_url,
187
+ timeout: 120, # 2 minutes timeout for longer responses
188
+ )
189
+
190
+ debug_me("LLM setup complete for #{@character_name}") do
191
+ [provider, model, base_url]
192
+ end
193
+ end
194
+
195
+ # Build the system prompt that defines the character
196
+ def build_system_prompt
197
+ <<~SYSTEM
198
+ You are #{@character_name}, a character in a comedic teen play.
199
+
200
+ CHARACTER PROFILE:
201
+ Name: #{@character_name}
202
+ Age: #{@character_info[:age] || 16}
203
+ Personality: #{@character_info[:personality]}
204
+ Voice Pattern: #{@character_info[:voice_pattern]}
205
+ Sport/Activity: #{@character_info[:sport]}
206
+
207
+ CURRENT CHARACTER ARC:
208
+ #{@character_info[:current_arc]}
209
+
210
+ RELATIONSHIPS:
211
+ #{format_relationships}
212
+
213
+ SCENE CONTEXT:
214
+ Scene: #{@scene_info[:scene_name]} (Scene #{@scene_info[:scene_number]})
215
+ Location: #{@scene_info[:location]}
216
+ Week: #{@scene_info[:week]} of the semester
217
+ Your Objective: #{@scene_info[:objectives]}
218
+ Other Characters Present: #{@scene_info[:characters]&.join(", ")}
219
+
220
+ INSTRUCTIONS:
221
+ - Stay completely in character
222
+ - Use your unique voice pattern consistently
223
+ - Respond naturally to other characters based on your relationships
224
+ - Keep dialog authentic to a teenager
225
+ - Include appropriate humor based on your personality
226
+ - React to the scene objectives and context
227
+ - Do not narrate actions, only speak dialog
228
+ - Keep responses concise (1-3 sentences typically)
229
+ - Use contractions and natural speech patterns
230
+
231
+ RESPONSE FORMAT:
232
+ Respond with ONLY the dialog your character would say. No quotation marks, no stage directions, no character name prefix. Just the words #{@character_name} would speak.
233
+ SYSTEM
234
+ end
235
+
236
+ # Build the user prompt for the current situation
237
+ def build_user_prompt(additional_context = nil)
238
+ prompt = "CONVERSATION SO FAR:\n"
239
+
240
+ if @conversation_history.empty?
241
+ prompt += "(Scene just started - you may initiate conversation if appropriate)\n"
242
+ else
243
+ # Include last 10 exchanges for context
244
+ recent_history = @conversation_history.last(10)
245
+ recent_history.each do |exchange|
246
+ prompt += "#{exchange[:speaker]}: #{exchange[:line]}\n"
247
+ end
248
+ end
249
+
250
+ prompt += "\nADDITIONAL CONTEXT:\n#{additional_context}\n" if additional_context
251
+
252
+ prompt += "\nWhat does #{@character_name} say?"
253
+
254
+ prompt
255
+ end
256
+
257
+ # Format relationship information for the prompt
258
+ def format_relationships
259
+ return "No specific relationships defined" unless @character_info[:relationships]
260
+
261
+ @character_info[:relationships].map do |person, status|
262
+ "- #{person}: #{status}"
263
+ end.join("\n")
264
+ end
265
+
266
+ # Extract dialog from LLM response
267
+ def extract_dialog(response)
268
+ # RubyLLM response handling - adjust based on actual gem API
269
+ dialog = if response.is_a?(String)
270
+ response
271
+ elsif response.respond_to?(:content)
272
+ response.content
273
+ elsif response.respond_to?(:text)
274
+ response.text
275
+ elsif response.is_a?(Hash) && response[:content]
276
+ response[:content]
277
+ else
278
+ response.to_s
279
+ end
280
+
281
+ # Clean up the dialog
282
+ dialog.strip
283
+ .gsub(/^["']|["']$/, "") # Remove surrounding quotes
284
+ .gsub(/^\w+:\s*/, "") # Remove character name prefix if present
285
+ end
286
+
287
+ # Subscribe to dialog messages via SmartMessage
288
+ def subscribe_to_dialog(channel, &block)
289
+ DialogMessage.subscribe(channel) do |message|
290
+ next unless message.scene == @scene_info[:scene_number]
291
+
292
+ message_data = {
293
+ from: message.from,
294
+ content: message.content,
295
+ scene: message.scene,
296
+ timestamp: message.timestamp,
297
+ emotion: message.emotion,
298
+ addressing: message.addressing,
299
+ }
300
+
301
+ block.call(message_data)
302
+ end
303
+ end
304
+
305
+ # Decide whether to respond to a message
306
+ def should_respond?(message_data)
307
+ last_speaker = @conversation_history[-2]&.dig(:speaker)
308
+
309
+ # Always respond if directly addressed (name mentioned)
310
+ return true if message_data[:content].include?(@character_name)
311
+
312
+ # Respond if it's your turn in conversation flow
313
+ # (last speaker wasn't you, and you haven't spoken recently)
314
+ return true if last_speaker != @character_name &&
315
+ @conversation_history.last(3).count { |h| h[:speaker] == @character_name } < 2
316
+
317
+ # Random chance to interject (10%)
318
+ return true if rand < 0.10
319
+
320
+ # Otherwise, listen
321
+ false
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "yaml"
5
+
6
+ module WritersRoom
7
+ module Commands
8
+ class Actor < Thor
9
+ desc "actor CHARACTER_FILE SCENE_FILE", "Run a single actor"
10
+ method_option :channel,
11
+ aliases: "-r",
12
+ type: :string,
13
+ default: "writers_room:dialog",
14
+ desc: "Redis channel"
15
+
16
+ def actor(character_file, scene_file)
17
+ require_relative "../actor"
18
+
19
+ unless File.exist?(character_file)
20
+ say "Error: Character file not found: #{character_file}", :red
21
+ exit 1
22
+ end
23
+
24
+ unless File.exist?(scene_file)
25
+ say "Error: Scene file not found: #{scene_file}", :red
26
+ exit 1
27
+ end
28
+
29
+ # Load character and scene info
30
+ character_info = YAML.load_file(character_file)
31
+ scene_info = YAML.load_file(scene_file)
32
+
33
+ # Create and start the actor
34
+ actor = WritersRoom::Actor.new(character_info)
35
+ actor.set_scene(scene_info)
36
+
37
+ say "#{actor.character_name} entering scene: #{scene_info[:scene_name]}", :green
38
+ say "Listening on channel: #{options[:channel]}", :cyan
39
+ say "Press Ctrl+C to exit", :yellow
40
+
41
+ # Handle graceful shutdown
42
+ trap("INT") do
43
+ say "\n#{actor.character_name} exiting scene...", :yellow
44
+ actor.stop
45
+ exit 0
46
+ end
47
+
48
+ # Start performing
49
+ actor.perform(channel: options[:channel])
50
+ rescue StandardError => e
51
+ say "Error running actor: #{e.message}", :red
52
+ say e.backtrace.join("\n"), :red if ENV["DEBUG"]
53
+ exit 1
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module WritersRoom
6
+ module Commands
7
+ class Config < Thor
8
+ desc "config", "Show current configuration"
9
+
10
+ def config
11
+ config_path = File.join(Dir.pwd, "config.yml")
12
+
13
+ unless File.exist?(config_path)
14
+ say "No config.yml found in current directory.", :yellow
15
+ say "Run 'wr init <project_name>' to create a new project.", :yellow
16
+ exit 1
17
+ end
18
+
19
+ config = WritersRoom::Config.new(config_path)
20
+ say "Configuration (#{config_path}):", :cyan
21
+ say " Provider: #{config.provider}", :white
22
+ say " Model: #{config.model_name}", :white
23
+ rescue StandardError => e
24
+ say "Error reading configuration: #{e.message}", :red
25
+ exit 1
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "fileutils"
5
+
6
+ module WritersRoom
7
+ module Commands
8
+ class Direct < Thor
9
+ desc "direct SCENE_FILE", "Direct a scene with multiple actors"
10
+ method_option :characters,
11
+ aliases: "-c",
12
+ type: :string,
13
+ desc: "Character directory (auto-detected if not specified)"
14
+ method_option :output,
15
+ aliases: "-o",
16
+ type: :string,
17
+ desc: "Transcript output file"
18
+ method_option :max_lines,
19
+ aliases: "-l",
20
+ type: :numeric,
21
+ default: 50,
22
+ desc: "Maximum lines before ending"
23
+
24
+ def direct(scene_file)
25
+ require_relative "../director"
26
+
27
+ unless File.exist?(scene_file)
28
+ say "Error: Scene file not found: #{scene_file}", :red
29
+ exit 1
30
+ end
31
+
32
+ # Set max lines environment variable
33
+ ENV["MAX_LINES"] = options[:max_lines].to_s
34
+
35
+ # Create logs directory if it doesn't exist
36
+ FileUtils.mkdir_p("logs")
37
+
38
+ # Create and run director
39
+ director = WritersRoom::Director.new(
40
+ scene_file: scene_file,
41
+ character_dir: options[:characters]
42
+ )
43
+
44
+ # Handle graceful shutdown
45
+ trap("INT") do
46
+ say "\n[DIRECTOR: Interrupt received]", :yellow
47
+ director.cut!
48
+
49
+ # Save transcript
50
+ filename = director.save_transcript(options[:output])
51
+
52
+ # Show statistics
53
+ show_statistics(director)
54
+
55
+ exit 0
56
+ end
57
+
58
+ # Start the scene
59
+ director.action!
60
+
61
+ # Save transcript when done
62
+ filename = director.save_transcript(options[:output])
63
+
64
+ # Show statistics
65
+ show_statistics(director)
66
+ rescue StandardError => e
67
+ say "Error directing scene: #{e.message}", :red
68
+ say e.backtrace.join("\n"), :red if ENV["DEBUG"]
69
+ exit 1
70
+ end
71
+
72
+ private
73
+
74
+ def show_statistics(director)
75
+ stats = director.statistics
76
+
77
+ say "\n" + "=" * 60, :cyan
78
+ say "SCENE STATISTICS", :cyan
79
+ say "=" * 60, :cyan
80
+ say "Total lines: #{stats[:total_lines]}", :white
81
+ say "\nLines by character:", :white
82
+
83
+ stats[:lines_by_character].sort_by { |_, count| -count }.each do |char, count|
84
+ say " #{char}: #{count}", :white
85
+ end
86
+
87
+ say "=" * 60, :cyan
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module WritersRoom
6
+ module Commands
7
+ class Init < Thor
8
+ desc "init PROJECT_NAME", "Initialize a new WritersRoom project"
9
+ method_option :provider,
10
+ aliases: "-p",
11
+ type: :string,
12
+ default: "ollama",
13
+ desc: "LLM provider (ollama, openai, anthropic, etc.)"
14
+ method_option :model,
15
+ aliases: "-m",
16
+ type: :string,
17
+ default: "gpt-oss",
18
+ desc: "Model name to use"
19
+
20
+ def init(project_name)
21
+ project_path = File.join(Dir.pwd, project_name)
22
+
23
+ if File.exist?(project_path)
24
+ say "Error: Directory '#{project_name}' already exists!", :red
25
+ exit 1
26
+ end
27
+
28
+ say "Creating WritersRoom project: #{project_name}", :green
29
+
30
+ config_options = {
31
+ provider: options[:provider],
32
+ model_name: options[:model]
33
+ }
34
+
35
+ config = Config.create_project(project_path, config_options)
36
+
37
+ say "✓ Created project directory: #{project_path}", :green
38
+ say "✓ Created configuration file: #{config.path}", :green
39
+ say "", :green
40
+ say "Configuration:", :cyan
41
+ say " Provider: #{config.provider}", :white
42
+ say " Model: #{config.model_name}", :white
43
+ say "", :green
44
+ say "Your WritersRoom project is ready! 🎭", :green
45
+ rescue StandardError => e
46
+ say "Error initializing project: #{e.message}", :red
47
+ exit 1
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module WritersRoom
6
+ module Commands
7
+ class Version < Thor
8
+ desc "version", "Show WritersRoom version"
9
+ def version
10
+ puts "WritersRoom #{WritersRoom::VERSION}"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "writers_room"
5
+
6
+ module WritersRoom
7
+ class CLI < Thor
8
+ def self.exit_on_failure?
9
+ true
10
+ end
11
+
12
+ # Show help when no command is given
13
+ default_task :help
14
+
15
+ desc "version", "Show WritersRoom version"
16
+ def version
17
+ require_relative "cli/version"
18
+ Commands::Version.new.version
19
+ end
20
+
21
+ desc "init PROJECT_NAME", "Initialize a new WritersRoom project"
22
+ method_option :provider,
23
+ aliases: "-p",
24
+ type: :string,
25
+ default: "ollama",
26
+ desc: "LLM provider (ollama, openai, anthropic, etc.)"
27
+ method_option :model,
28
+ aliases: "-m",
29
+ type: :string,
30
+ default: "gpt-oss",
31
+ desc: "Model name to use"
32
+ def init(project_name)
33
+ require_relative "cli/init"
34
+ Commands::Init.new([], options).init(project_name)
35
+ end
36
+
37
+ desc "config", "Show current configuration"
38
+ def config
39
+ require_relative "cli/config"
40
+ Commands::Config.new.config
41
+ end
42
+
43
+ desc "actor CHARACTER_FILE SCENE_FILE", "Run a single actor"
44
+ method_option :channel,
45
+ aliases: "-r",
46
+ type: :string,
47
+ default: "writers_room:dialog",
48
+ desc: "Redis channel"
49
+ def actor(character_file, scene_file)
50
+ require_relative "cli/actor"
51
+ Commands::Actor.new([], options).actor(character_file, scene_file)
52
+ end
53
+
54
+ desc "direct SCENE_FILE", "Direct a scene with multiple actors"
55
+ method_option :characters,
56
+ aliases: "-c",
57
+ type: :string,
58
+ desc: "Character directory (auto-detected if not specified)"
59
+ method_option :output,
60
+ aliases: "-o",
61
+ type: :string,
62
+ desc: "Transcript output file"
63
+ method_option :max_lines,
64
+ aliases: "-l",
65
+ type: :numeric,
66
+ default: 50,
67
+ desc: "Maximum lines before ending"
68
+ def direct(scene_file)
69
+ require_relative "cli/direct"
70
+ Commands::Direct.new([], options).direct(scene_file)
71
+ end
72
+ end
73
+ end