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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/CHANGELOG.md +186 -0
- data/COMMITS.md +196 -0
- data/LICENSE +21 -0
- data/README.md +572 -0
- data/Rakefile +12 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/bin/wr +9 -0
- data/docs/configuration.md +361 -0
- data/docs/project_structure.md +226 -0
- data/docs/quick_reference.md +200 -0
- data/docs/quick_start.md +264 -0
- data/lib/writers_room/actor.rb +324 -0
- data/lib/writers_room/cli/actor.rb +57 -0
- data/lib/writers_room/cli/config.rb +29 -0
- data/lib/writers_room/cli/direct.rb +91 -0
- data/lib/writers_room/cli/init.rb +51 -0
- data/lib/writers_room/cli/version.rb +14 -0
- data/lib/writers_room/cli.rb +73 -0
- data/lib/writers_room/config.rb +98 -0
- data/lib/writers_room/director.rb +262 -0
- data/lib/writers_room/version.rb +5 -0
- data/lib/writers_room.rb +15 -0
- metadata +97 -0
|
@@ -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
|
data/lib/writers_room.rb
ADDED
|
@@ -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: []
|