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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module WritersRoom
7
+ class Config
8
+ attr_reader :path, :data
9
+
10
+ DEFAULT_CONFIG = {
11
+ "provider" => "ollama",
12
+ "model_name" => "gpt-oss",
13
+ }.freeze
14
+
15
+ def initialize(path = nil)
16
+ @path = path || default_config_path
17
+ @data = load_config
18
+ end
19
+
20
+ # Load configuration from file
21
+ #
22
+ # @return [Hash] configuration data
23
+ def load_config
24
+ return DEFAULT_CONFIG.dup unless File.exist?(path)
25
+
26
+ YAML.load_file(path) || DEFAULT_CONFIG.dup
27
+ rescue StandardError => e
28
+ warn "Error loading config from #{path}: #{e.message}"
29
+ DEFAULT_CONFIG.dup
30
+ end
31
+
32
+ # Save configuration to file
33
+ #
34
+ # @param config_data [Hash] configuration data to save
35
+ # @return [Boolean] true if successful
36
+ def save(config_data = @data)
37
+ FileUtils.mkdir_p(File.dirname(path))
38
+ File.write(path, YAML.dump(config_data))
39
+ @data = config_data
40
+ true
41
+ rescue StandardError => e
42
+ warn "Error saving config to #{path}: #{e.message}"
43
+ false
44
+ end
45
+
46
+ # Create a new project configuration
47
+ #
48
+ # @param project_path [String] path to project directory
49
+ # @param options [Hash] configuration options
50
+ # @return [WritersRoom::Config] new config instance
51
+ def self.create_project(project_path, options = {})
52
+ FileUtils.mkdir_p(project_path)
53
+
54
+ config_path = File.join(project_path, "config.yml")
55
+ config_data = DEFAULT_CONFIG.merge(options.transform_keys(&:to_s))
56
+
57
+ config = new(config_path)
58
+ config.save(config_data)
59
+ config
60
+ end
61
+
62
+ # Get configuration value
63
+ #
64
+ # @param key [String, Symbol] configuration key
65
+ # @return [Object] configuration value
66
+ def get(key)
67
+ @data[key.to_s]
68
+ end
69
+
70
+ # Set configuration value
71
+ #
72
+ # @param key [String, Symbol] configuration key
73
+ # @param value [Object] configuration value
74
+ def set(key, value)
75
+ @data[key.to_s] = value
76
+ end
77
+
78
+ # Get provider
79
+ #
80
+ # @return [String] LLM provider name
81
+ def provider
82
+ get("provider")
83
+ end
84
+
85
+ # Get model name
86
+ #
87
+ # @return [String] model name
88
+ def model_name
89
+ get("model_name")
90
+ end
91
+
92
+ private
93
+
94
+ def default_config_path
95
+ File.join(Dir.pwd, "config.yml")
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+ ##########################################################
3
+ ###
4
+ ## File: director.rb
5
+ ## Desc: Director to orchestrate multiple actors in a scene
6
+ ## By: Dewayne VanHoozer (dvanhoozer@gmail.com)
7
+ #
8
+
9
+ require "debug_me"
10
+ include DebugMe
11
+
12
+ require "yaml"
13
+ require "redis"
14
+ require "smart_message"
15
+
16
+ # Load message classes
17
+ require_relative "messages/dialog_message"
18
+ require_relative "messages/scene_control_message"
19
+ require_relative "messages/stage_direction_message"
20
+ require_relative "messages/meta_message"
21
+
22
+ module WritersRoom
23
+ class Director
24
+ attr_reader :scene_info, :actor_processes, :transcript
25
+
26
+ # Initialize the Director
27
+ #
28
+ # @param scene_file [String] Path to scene YAML file
29
+ # @param character_dir [String] Directory containing character YAML files (optional, auto-detected)
30
+ def initialize(scene_file:, character_dir: nil)
31
+ @scene_file = scene_file
32
+ @character_dir = character_dir || detect_character_dir(scene_file)
33
+ @scene_info = load_scene
34
+ @actor_processes = []
35
+ @transcript = []
36
+ @running = false
37
+ @redis = Redis.new
38
+
39
+ debug_me("Director initialized") {
40
+ [@scene_info[:scene_name], @scene_info[:characters].join(", "), @character_dir]
41
+ }
42
+ end
43
+
44
+ # Start the scene with all actors
45
+ def action!
46
+ puts "\n" + "=" * 60
47
+ puts "SCENE #{@scene_info[:scene_number]}: #{@scene_info[:scene_name]}"
48
+ puts "Location: #{@scene_info[:location]}"
49
+ puts "Characters: #{@scene_info[:characters].join(", ")}"
50
+ puts "=" * 60 + "\n"
51
+
52
+ @running = true
53
+
54
+ # Start all actor processes
55
+ start_actors
56
+
57
+ # Send start scene control message
58
+ send_control_message("start")
59
+
60
+ # Listen to dialog and manage the scene
61
+ monitor_scene
62
+
63
+ # Cleanup
64
+ stop_actors
65
+ end
66
+
67
+ # Stop the scene and all actors
68
+ def cut!
69
+ @running = false
70
+ send_control_message("stop")
71
+ puts "\n[DIRECTOR: CUT! Scene ended.]"
72
+ end
73
+
74
+ # Save the transcript to a file
75
+ #
76
+ # @param filename [String] Output filename
77
+ def save_transcript(filename = nil)
78
+ filename ||= "transcript_scene_#{@scene_info[:scene_number]}_#{Time.now.to_i}.txt"
79
+
80
+ File.open(filename, "w") do |file|
81
+ file.puts "SCENE #{@scene_info[:scene_number]}: #{@scene_info[:scene_name]}"
82
+ file.puts "Location: #{@scene_info[:location]}"
83
+ file.puts "Week: #{@scene_info[:week]}"
84
+ file.puts "\n" + "-" * 60 + "\n"
85
+
86
+ @transcript.each do |entry|
87
+ case entry[:type]
88
+ when :dialog
89
+ file.puts "#{entry[:character]}: #{entry[:line]}"
90
+ when :stage_direction
91
+ file.puts "[#{entry[:character].upcase} #{entry[:action]}]"
92
+ when :beat
93
+ file.puts "\n--- #{entry[:content]} ---\n"
94
+ end
95
+ end
96
+ end
97
+
98
+ puts "Transcript saved to: #{filename}"
99
+ filename
100
+ end
101
+
102
+ # Get scene statistics
103
+ def statistics
104
+ total_lines = @transcript.count { |e| e[:type] == :dialog }
105
+ lines_by_character = @transcript
106
+ .select { |e| e[:type] == :dialog }
107
+ .group_by { |e| e[:character] }
108
+ .transform_values(&:count)
109
+
110
+ {
111
+ total_lines: total_lines,
112
+ lines_by_character: lines_by_character,
113
+ duration: @transcript.last&.dig(:timestamp).to_i - @transcript.first&.dig(:timestamp).to_i,
114
+ scene: @scene_info[:scene_number],
115
+ }
116
+ end
117
+
118
+ private
119
+
120
+ # Auto-detect character directory from scene file path
121
+ # If scene is in projects/PROJECT_NAME/scenes/, look for projects/PROJECT_NAME/characters/
122
+ # Otherwise fall back to 'characters' in current directory
123
+ def detect_character_dir(scene_file)
124
+ scene_path = File.expand_path(scene_file)
125
+ scene_dir = File.dirname(scene_path)
126
+
127
+ # Check if scene is in a project structure (projects/PROJECT_NAME/scenes/)
128
+ if scene_dir =~ %r{projects/([^/]+)/scenes}
129
+ project_name = $1
130
+ character_dir = File.join("projects", project_name, "characters")
131
+
132
+ if Dir.exist?(character_dir)
133
+ debug_me("Auto-detected character directory") { character_dir }
134
+ return character_dir
135
+ end
136
+ end
137
+
138
+ # Fall back to looking for 'characters' relative to scene directory
139
+ character_dir = File.join(scene_dir, "..", "characters")
140
+ if Dir.exist?(character_dir)
141
+ debug_me("Found character directory relative to scene") { character_dir }
142
+ return File.expand_path(character_dir)
143
+ end
144
+
145
+ # Final fallback
146
+ debug_me("Using default character directory") { "characters" }
147
+ "characters"
148
+ end
149
+
150
+ def load_scene
151
+ unless File.exist?(@scene_file)
152
+ raise "Scene file not found: #{@scene_file}"
153
+ end
154
+
155
+ scene = YAML.load_file(@scene_file)
156
+
157
+ # Convert string keys to symbols
158
+ scene.transform_keys(&:to_sym)
159
+ end
160
+
161
+ def start_actors
162
+ puts "\n[DIRECTOR: Calling actors to the stage...]\n"
163
+
164
+ @scene_info[:characters].each do |character_name|
165
+ character_file = File.join(@character_dir, "#{character_name.downcase}.yml")
166
+
167
+ unless File.exist?(character_file)
168
+ puts "Warning: Character file not found: #{character_file}"
169
+ next
170
+ end
171
+
172
+ puts " - #{character_name} is taking their position..."
173
+
174
+ # Spawn actor process
175
+ pid = spawn(
176
+ "ruby", "actor.rb",
177
+ "-c", character_file,
178
+ "-s", @scene_file,
179
+ out: "logs/#{character_name.downcase}_#{Time.now.to_i}.log",
180
+ err: "logs/#{character_name.downcase}_#{Time.now.to_i}_err.log",
181
+ )
182
+
183
+ @actor_processes << { name: character_name, pid: pid }
184
+
185
+ # Give actors time to initialize
186
+ sleep 0.5
187
+ end
188
+
189
+ puts "\n[DIRECTOR: All actors ready!]\n"
190
+ sleep 1 # Give them time to subscribe to channels
191
+ end
192
+
193
+ def stop_actors
194
+ puts "\n[DIRECTOR: Dismissing actors...]\n"
195
+
196
+ @actor_processes.each do |actor|
197
+ begin
198
+ Process.kill("INT", actor[:pid])
199
+ Process.wait(actor[:pid])
200
+ puts " - #{actor[:name]} has left the stage"
201
+ rescue Errno::ESRCH
202
+ # Process already ended
203
+ end
204
+ end
205
+
206
+ @actor_processes.clear
207
+ end
208
+
209
+ def send_control_message(command)
210
+ message = case command
211
+ when "start"
212
+ SceneControlMessage.start_scene(@scene_info[:scene_number])
213
+ when "stop"
214
+ SceneControlMessage.stop_scene(@scene_info[:scene_number])
215
+ when "end"
216
+ SceneControlMessage.end_scene(@scene_info[:scene_number])
217
+ else
218
+ return
219
+ end
220
+
221
+ message.publish
222
+ debug_me("Sent control message: #{command}")
223
+ end
224
+
225
+ def monitor_scene
226
+ puts "\n[SCENE BEGINS]\n\n"
227
+
228
+ line_count = 0
229
+ max_lines = ENV["MAX_LINES"]&.to_i || 50 # Default to 50 lines
230
+
231
+ # Subscribe to dialog messages
232
+ DialogMessage.subscribe("writers_room:dialog") do |message|
233
+ break unless @running
234
+ break if line_count >= max_lines
235
+
236
+ # Only show messages for this scene
237
+ next unless message.scene == @scene_info[:scene_number]
238
+
239
+ # Record in transcript
240
+ @transcript << {
241
+ type: :dialog,
242
+ character: message.from,
243
+ line: message.content,
244
+ timestamp: message.timestamp,
245
+ emotion: message.emotion,
246
+ }
247
+
248
+ # Display dialog
249
+ emotion_tag = message.emotion ? " [#{message.emotion}]" : ""
250
+ puts "#{message.from}#{emotion_tag}: #{message.content}"
251
+
252
+ line_count += 1
253
+
254
+ # Check if we should end the scene
255
+ if line_count >= max_lines
256
+ puts "\n[DIRECTOR: Maximum lines reached]"
257
+ cut!
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WritersRoom
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ loader = Zeitwerk::Loader.for_gem
6
+ loader.setup
7
+
8
+ require_relative "writers_room/version"
9
+
10
+ module WritersRoom
11
+ class Error < StandardError; end
12
+ end
13
+
14
+ # Shortcut constant for convenience
15
+ WR = WritersRoom
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: writers_room
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Dewayne VanHoozer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: zeitwerk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: thor
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
40
+ description: under development
41
+ email:
42
+ - dvanhoozer@gmail.com
43
+ executables:
44
+ - wr
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".envrc"
49
+ - CHANGELOG.md
50
+ - COMMITS.md
51
+ - LICENSE
52
+ - README.md
53
+ - Rakefile
54
+ - bin/console
55
+ - bin/setup
56
+ - bin/wr
57
+ - docs/configuration.md
58
+ - docs/project_structure.md
59
+ - docs/quick_reference.md
60
+ - docs/quick_start.md
61
+ - lib/writers_room.rb
62
+ - lib/writers_room/actor.rb
63
+ - lib/writers_room/cli.rb
64
+ - lib/writers_room/cli/actor.rb
65
+ - lib/writers_room/cli/config.rb
66
+ - lib/writers_room/cli/direct.rb
67
+ - lib/writers_room/cli/init.rb
68
+ - lib/writers_room/cli/version.rb
69
+ - lib/writers_room/config.rb
70
+ - lib/writers_room/director.rb
71
+ - lib/writers_room/version.rb
72
+ homepage: https://github.com/madbomber/writers_room
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ allowed_push_host: https://rubygems.org
77
+ homepage_uri: https://github.com/madbomber/writers_room
78
+ source_code_uri: https://github.com/madbomber/writers_room
79
+ changelog_uri: https://github.com/madbomber/writers_room/blob/main/CHANGELOG.md
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.0.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.7.2
95
+ specification_version: 4
96
+ summary: A Ruby gem for managing a writers' room
97
+ test_files: []