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,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,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
|