vsm 0.1.0 → 0.2.0

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +144 -0
  3. data/Rakefile +1 -5
  4. data/examples/01_echo_tool.rb +5 -24
  5. data/examples/02_openai_streaming.rb +3 -3
  6. data/examples/02b_anthropic_streaming.rb +1 -4
  7. data/examples/03b_anthropic_tools.rb +1 -4
  8. data/examples/05_mcp_server_and_chattty.rb +63 -0
  9. data/examples/06_mcp_mount_reflection.rb +45 -0
  10. data/examples/07_connect_claude_mcp.rb +78 -0
  11. data/examples/08_custom_chattty.rb +63 -0
  12. data/examples/09_mcp_with_llm_calls.rb +49 -0
  13. data/examples/10_meta_read_only.rb +56 -0
  14. data/exe/vsm +17 -0
  15. data/lib/vsm/async_channel.rb +26 -3
  16. data/lib/vsm/capsule.rb +2 -0
  17. data/lib/vsm/cli.rb +78 -0
  18. data/lib/vsm/dsl.rb +41 -11
  19. data/lib/vsm/dsl_mcp.rb +36 -0
  20. data/lib/vsm/generator/new_project.rb +154 -0
  21. data/lib/vsm/generator/templates/Gemfile.erb +9 -0
  22. data/lib/vsm/generator/templates/README_md.erb +40 -0
  23. data/lib/vsm/generator/templates/Rakefile.erb +5 -0
  24. data/lib/vsm/generator/templates/bin_console.erb +11 -0
  25. data/lib/vsm/generator/templates/bin_setup.erb +7 -0
  26. data/lib/vsm/generator/templates/exe_name.erb +34 -0
  27. data/lib/vsm/generator/templates/gemspec.erb +24 -0
  28. data/lib/vsm/generator/templates/gitignore.erb +10 -0
  29. data/lib/vsm/generator/templates/lib_name_rb.erb +9 -0
  30. data/lib/vsm/generator/templates/lib_organism_rb.erb +44 -0
  31. data/lib/vsm/generator/templates/lib_ports_chat_tty_rb.erb +12 -0
  32. data/lib/vsm/generator/templates/lib_tools_read_file_rb.erb +32 -0
  33. data/lib/vsm/generator/templates/lib_version_rb.erb +6 -0
  34. data/lib/vsm/mcp/client.rb +80 -0
  35. data/lib/vsm/mcp/jsonrpc.rb +92 -0
  36. data/lib/vsm/mcp/remote_tool_capsule.rb +35 -0
  37. data/lib/vsm/meta/snapshot_builder.rb +121 -0
  38. data/lib/vsm/meta/snapshot_cache.rb +25 -0
  39. data/lib/vsm/meta/support.rb +35 -0
  40. data/lib/vsm/meta/tools.rb +498 -0
  41. data/lib/vsm/meta.rb +59 -0
  42. data/lib/vsm/ports/chat_tty.rb +112 -0
  43. data/lib/vsm/ports/mcp/server_stdio.rb +101 -0
  44. data/lib/vsm/roles/intelligence.rb +6 -2
  45. data/lib/vsm/version.rb +1 -1
  46. data/lib/vsm.rb +10 -0
  47. data/mcp_update.md +162 -0
  48. metadata +38 -18
  49. data/.rubocop.yml +0 -8
@@ -0,0 +1,498 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require "pathname"
7
+
8
+ require_relative "snapshot_builder"
9
+ require_relative "snapshot_cache"
10
+
11
+ module VSM
12
+ module Meta
13
+ module Tools
14
+ class Base < VSM::ToolCapsule
15
+ attr_reader :root, :snapshot_cache, :draft_store
16
+
17
+ def initialize(root:, snapshot_cache:, draft_store: nil)
18
+ @root = root
19
+ @snapshot_cache = snapshot_cache
20
+ @draft_store = draft_store
21
+ end
22
+
23
+ def execution_mode = :fiber
24
+
25
+ private
26
+
27
+ def snapshot
28
+ snapshot_cache.fetch[:data]
29
+ end
30
+
31
+ def roles_summary(roles_hash)
32
+ return {} unless roles_hash
33
+
34
+ roles_hash.transform_values do |info|
35
+ {
36
+ class: info[:class],
37
+ constructor_args: info[:constructor_args]
38
+ }
39
+ end
40
+ end
41
+
42
+ def flatten_tools(node, acc)
43
+ ops = node.dig(:operations, :children) || {}
44
+ ops.each_value do |child|
45
+ case child[:kind]
46
+ when "tool"
47
+ acc << child
48
+ when "capsule"
49
+ acc << child if child[:tool]
50
+ flatten_tools(child, acc)
51
+ end
52
+ end
53
+ acc
54
+ end
55
+
56
+ def tool_index
57
+ flatten_tools(snapshot, []).each_with_object({}) do |entry, acc|
58
+ key = entry.dig(:tool, :name) || entry[:name]
59
+ acc[key] = entry
60
+ acc[entry[:path].join("/")] = entry
61
+ end
62
+ end
63
+
64
+ def flatten_capsules(node, acc)
65
+ # node is a capsule-like hash per SnapshotBuilder
66
+ acc << node if node.is_a?(Hash) && node[:kind] == "capsule"
67
+ children = node.dig(:operations, :children) || {}
68
+ children.each_value do |child|
69
+ next unless child[:kind] == "capsule"
70
+ flatten_capsules(child, acc)
71
+ end
72
+ acc
73
+ end
74
+
75
+ def capsules_index
76
+ flatten_capsules(snapshot, []).each_with_object({}) do |entry, acc|
77
+ path_key = entry[:path].join("/")
78
+ acc[path_key] = entry
79
+ # Also index by leaf name for convenience (non-unique, last wins)
80
+ acc[entry[:name]] = entry
81
+ end
82
+ end
83
+
84
+ def read_source(path, start_line, window: 120)
85
+ return nil if path.nil?
86
+ full = File.expand_path(path, Dir.pwd)
87
+ return nil unless File.file?(full)
88
+
89
+ lines = File.readlines(full, chomp: true)
90
+ slice = lines[[start_line - 1, 0].max, window] || []
91
+ slice.join("\n")
92
+ rescue StandardError => e
93
+ "ERROR reading source: #{e.class}: #{e.message}"
94
+ end
95
+
96
+ def emit_audit(payload, path: [:meta, tool_descriptor.name], meta: {})
97
+ root.bus.emit(
98
+ VSM::Message.new(
99
+ kind: :audit,
100
+ payload: payload,
101
+ path: path,
102
+ meta: meta.merge(tool: tool_descriptor.name)
103
+ )
104
+ )
105
+ end
106
+
107
+ def workspace_root
108
+ @workspace_root ||= File.expand_path(".", Dir.pwd)
109
+ end
110
+
111
+ def ensure_within_workspace!(path)
112
+ absolute = File.expand_path(path, Dir.pwd)
113
+ unless absolute.start_with?(workspace_root)
114
+ raise "path escapes workspace: #{path}"
115
+ end
116
+ absolute
117
+ end
118
+
119
+ def relative_to_workspace(path)
120
+ Pathname.new(path).relative_path_from(Pathname.new(workspace_root)).to_s
121
+ rescue ArgumentError
122
+ path
123
+ end
124
+
125
+ def draft_store!
126
+ draft_store || (raise "draft store not configured")
127
+ end
128
+ end
129
+
130
+ class SummarizeSelf < Base
131
+ tool_name "meta_summarize_self"
132
+ tool_description "Summarize the current capsule including roles and tools"
133
+ tool_schema({ type: "object", properties: {}, additionalProperties: false })
134
+
135
+ def run(_args)
136
+ data = snapshot
137
+ tools = flatten_tools(data, []).select { _1[:kind] == "tool" }
138
+ {
139
+ capsule: {
140
+ name: data[:name],
141
+ path: data[:path],
142
+ class: data[:class],
143
+ roles: roles_summary(data[:roles])
144
+ },
145
+ stats: {
146
+ total_tools: tools.size,
147
+ tool_names: tools.map { _1.dig(:tool, :name) }
148
+ },
149
+ snapshot: data
150
+ }
151
+ end
152
+ end
153
+
154
+ class ListTools < Base
155
+ tool_name "meta_list_tools"
156
+ tool_description "List all tools available in the current organism"
157
+ tool_schema({ type: "object", properties: {}, additionalProperties: false })
158
+
159
+ def run(_args)
160
+ tools = flatten_tools(snapshot, []).select { _1[:kind] == "tool" }
161
+ {
162
+ tools: tools.map do |entry|
163
+ descriptor = entry[:tool] || {}
164
+ {
165
+ tool_name: descriptor[:name] || entry[:name],
166
+ capsule_path: entry[:path],
167
+ description: descriptor[:description],
168
+ schema: descriptor[:schema],
169
+ class: entry[:class]
170
+ }
171
+ end
172
+ }
173
+ end
174
+ end
175
+
176
+ class ExplainTool < Base
177
+ tool_name "meta_explain_tool"
178
+ tool_description "Provide code and context for a specific tool"
179
+ tool_schema({
180
+ type: "object",
181
+ properties: {
182
+ tool: {
183
+ type: "string",
184
+ description: "Tool name or capsule path (e.g. meta/meta_explain_tool)"
185
+ }
186
+ },
187
+ required: ["tool"],
188
+ additionalProperties: false
189
+ })
190
+
191
+ def run(args)
192
+ target = args["tool"].to_s.strip
193
+ raise "tool name required" if target.empty?
194
+
195
+ entry = tool_index[target]
196
+ raise "unknown tool: #{target}" unless entry
197
+
198
+ descriptor = entry[:tool] || {}
199
+ run_source = source_for(entry, "run")
200
+
201
+ {
202
+ tool: {
203
+ name: descriptor[:name] || entry[:name],
204
+ capsule_path: entry[:path],
205
+ class: entry[:class],
206
+ description: descriptor[:description],
207
+ schema: descriptor[:schema]
208
+ },
209
+ code: run_source,
210
+ source_locations: entry[:source_locations],
211
+ parent_roles: roles_summary(snapshot[:roles])
212
+ }
213
+ end
214
+
215
+ private
216
+
217
+ def source_for(entry, method_name)
218
+ location = entry[:source_locations]&.find { _1[:method] == method_name } || entry[:source_locations]&.first
219
+ return nil unless location
220
+
221
+ {
222
+ path: location[:path],
223
+ start_line: location[:line],
224
+ method: location[:method],
225
+ snippet: read_source(location[:path], location[:line])
226
+ }
227
+ end
228
+ end
229
+
230
+ class ExplainRole < Base
231
+ tool_name "meta_explain_role"
232
+ tool_description "Explain a VSM role implementation with code context"
233
+ tool_schema({
234
+ type: "object",
235
+ properties: {
236
+ role: {
237
+ type: "string",
238
+ description: "Role name (identity, governance, coordination, intelligence, monitoring, operations)"
239
+ },
240
+ capsule: {
241
+ type: "string",
242
+ description: "Optional capsule path or name (e.g. meta or parent/child). Defaults to root capsule"
243
+ }
244
+ },
245
+ required: ["role"],
246
+ additionalProperties: false
247
+ })
248
+
249
+ ROLE_SUMMARIES = {
250
+ "identity" => "Defines purpose and invariants; handles alerts/escalation and represents the capsule’s identity.",
251
+ "governance" => "Applies policy and safety constraints; enforces budgets/limits around message handling.",
252
+ "coordination" => "Stages, orders, and drains messages; manages session floor/turn-taking across events.",
253
+ "intelligence" => "Plans and converses using an LLM; maintains conversation state and invokes tools.",
254
+ "operations" => "Executes tool calls by routing to child tool capsules with appropriate execution mode.",
255
+ "monitoring" => "Observes the bus and records telemetry/metrics for observability and debugging."
256
+ }.freeze
257
+
258
+ def run(args)
259
+ role_name = args["role"].to_s.strip
260
+ raise "role name required" if role_name.empty?
261
+
262
+ target_capsule = resolve_capsule(args["capsule"]) || snapshot
263
+ live_capsule = resolve_live_capsule(target_capsule)
264
+ role_instance = resolve_role_instance(role_name, live_capsule)
265
+ roles = target_capsule[:roles] || {}
266
+ role_info = roles[role_name]
267
+ raise "unknown role: #{role_name}" unless role_info
268
+
269
+ {
270
+ capsule: {
271
+ name: target_capsule[:name],
272
+ path: target_capsule[:path],
273
+ class: target_capsule[:class]
274
+ },
275
+ role: {
276
+ name: role_name,
277
+ class: role_info[:class],
278
+ constructor_args: role_info[:constructor_args]
279
+ },
280
+ vsm_summary: ROLE_SUMMARIES[role_name] || "",
281
+ source_locations: role_info[:source_locations],
282
+ code: source_blocks_for(role_info[:source_locations]),
283
+ sibling_roles: roles_summary(roles),
284
+ role_specific: role_specific_details(role_name, target_capsule, live_capsule, role_instance)
285
+ }
286
+ end
287
+
288
+ private
289
+
290
+ def resolve_capsule(capsule_arg)
291
+ return nil if capsule_arg.nil? || capsule_arg.to_s.strip.empty?
292
+ idx = capsules_index
293
+ idx[capsule_arg.to_s] || idx[capsule_arg.to_s.split("/").join("/")]
294
+ end
295
+
296
+ def source_blocks_for(locs)
297
+ Array(locs).filter_map do |loc|
298
+ next unless loc && loc[:path]
299
+ {
300
+ path: loc[:path],
301
+ start_line: loc[:line],
302
+ method: loc[:method],
303
+ snippet: read_source(loc[:path], loc[:line])
304
+ }
305
+ end
306
+ end
307
+
308
+ def role_specific_details(role_name, capsule_entry, live_capsule, role_instance)
309
+ case role_name
310
+ when "operations"
311
+ ops_children = operations_children_for(capsule_entry)
312
+ augmented = augment_children_with_live_data(live_capsule, ops_children)
313
+ { children: augmented }
314
+ when "intelligence"
315
+ ops_children = operations_children_for(capsule_entry)
316
+ tools = ops_children.select { |c| c[:kind] == "tool" }
317
+ {
318
+ driver_class: safe_class_name(role_instance&.driver),
319
+ system_prompt_present: !!(role_instance && safe_iv_get(role_instance, :@system_prompt)),
320
+ sessions_open: safe_sessions_size(role_instance),
321
+ available_tools: tools.map { |c| { tool_name: c[:tool_name], capsule_path: c[:capsule_path], class: c[:class] } }
322
+ }
323
+ when "monitoring"
324
+ { log_path_constant: monitoring_log_constant_for(capsule_entry) }
325
+ .merge(monitoring_file_stats(capsule_entry))
326
+ .compact
327
+ when "coordination"
328
+ {
329
+ supports_floor_control: role_instance.respond_to?(:grant_floor!) && role_instance.respond_to?(:wait_for_turn_end),
330
+ queue_size: safe_iv_size(role_instance, :@queue),
331
+ turn_waiters: safe_iv_size(role_instance, :@turn_waiters),
332
+ current_floor_session: safe_iv_get(role_instance, :@floor_by_session),
333
+ ordering_rank: sample_ordering_rank(role_instance)
334
+ }
335
+ when "identity"
336
+ {
337
+ identity: safe_iv_get(role_instance, :@identity),
338
+ invariants: safe_iv_get(role_instance, :@invariants) || [],
339
+ alerts_supported: role_instance.respond_to?(:alert)
340
+ }
341
+ when "governance"
342
+ ops_children = operations_children_for(capsule_entry)
343
+ augmented = augment_children_with_live_data(live_capsule, ops_children)
344
+ injected_into = augmented.select { |c| c[:accepts_governance] }.map { |c| c[:name] }
345
+ {
346
+ injected_into_children: injected_into,
347
+ observes_bus: role_instance.respond_to?(:observe),
348
+ wraps_enforce: role_instance.respond_to?(:enforce)
349
+ }
350
+ else
351
+ {}
352
+ end
353
+ end
354
+
355
+ def operations_children_for(capsule_entry)
356
+ ops = capsule_entry.dig(:operations, :children) || {}
357
+ ops.values.filter_map do |child|
358
+ next unless child # safety
359
+ descriptor = child[:tool] || {}
360
+ {
361
+ name: child[:name],
362
+ kind: child[:kind],
363
+ class: child[:class],
364
+ capsule_path: child[:path],
365
+ tool_name: descriptor[:name] || child[:name],
366
+ description: descriptor[:description],
367
+ schema: descriptor[:schema]
368
+ }
369
+ end
370
+ end
371
+
372
+ def augment_children_with_live_data(live_capsule, children_list)
373
+ children_list.map do |info|
374
+ child = live_child_from_path(info[:capsule_path]) || live_capsule&.children&.[](info[:name])
375
+ extra = {}
376
+ if child
377
+ if child.respond_to?(:execution_mode)
378
+ extra[:execution_mode] = child.execution_mode
379
+ end
380
+ extra[:accepts_governance] = child.respond_to?(:governance=)
381
+ if child.respond_to?(:tool_descriptor)
382
+ # confirm real descriptor name if available
383
+ begin
384
+ extra[:tool_name] = child.tool_descriptor.name
385
+ rescue StandardError
386
+ end
387
+ end
388
+ end
389
+ info.merge(extra)
390
+ end
391
+ end
392
+
393
+ def monitoring_file_stats(capsule_entry)
394
+ path = monitoring_log_constant_for(capsule_entry)
395
+ return {} unless path
396
+ begin
397
+ abs = ensure_within_workspace?(path)
398
+ exists = File.file?(abs)
399
+ size = exists ? File.size(abs) : 0
400
+ { log_exists: exists, log_size_bytes: size, log_relative_path: relative_to_workspace(abs) }
401
+ rescue StandardError
402
+ {}
403
+ end
404
+ end
405
+
406
+ def monitoring_log_constant_for(capsule_entry)
407
+ # Best effort: if class constant is VSM::Monitoring and defines LOG, surface it
408
+ klass_name = capsule_entry.dig(:roles, "monitoring", :class)
409
+ return nil unless klass_name
410
+ begin
411
+ klass = resolve_constant(klass_name)
412
+ return klass::LOG if klass && klass.const_defined?(:LOG)
413
+ rescue NameError
414
+ end
415
+ nil
416
+ end
417
+
418
+ def resolve_constant(name)
419
+ name.split("::").reject(&:empty?).inject(Object) { |ns, part| ns.const_get(part) }
420
+ end
421
+
422
+ def resolve_live_capsule(capsule_entry)
423
+ path = capsule_entry[:path] || []
424
+ cur = root
425
+ # skip the first element which is the root name
426
+ path.drop(1).each do |name|
427
+ cur = cur.children[name.to_s]
428
+ break unless cur
429
+ end
430
+ cur.is_a?(VSM::Capsule) ? cur : root
431
+ rescue StandardError
432
+ root
433
+ end
434
+
435
+ def live_child_from_path(path)
436
+ return nil unless path && !path.empty?
437
+ cur = root
438
+ path.drop(1).each_with_index do |name, idx|
439
+ child = cur.children[name.to_s] rescue nil
440
+ return child if child.nil? || idx == path.size - 2 # last hop returns child
441
+ # If child is a capsule, descend into its children
442
+ if child.respond_to?(:children)
443
+ cur = child
444
+ else
445
+ return child
446
+ end
447
+ end
448
+ rescue StandardError
449
+ nil
450
+ end
451
+
452
+ def resolve_role_instance(role_name, live_capsule)
453
+ sym = role_name.to_sym rescue role_name
454
+ live_capsule&.roles&.[](sym)
455
+ end
456
+
457
+ def safe_class_name(obj)
458
+ obj&.class&.name
459
+ end
460
+
461
+ def safe_sessions_size(role_instance)
462
+ return nil unless role_instance
463
+ st = role_instance.instance_variable_get(:@sessions) rescue nil
464
+ st.respond_to?(:size) ? st.size : nil
465
+ end
466
+
467
+ def safe_iv_size(obj, ivar)
468
+ return nil unless obj
469
+ val = obj.instance_variable_get(ivar) rescue nil
470
+ if val.respond_to?(:size)
471
+ val.size
472
+ elsif val.respond_to?(:length)
473
+ val.length
474
+ else
475
+ nil
476
+ end
477
+ end
478
+
479
+ def safe_iv_get(obj, ivar)
480
+ obj&.instance_variable_get(ivar) rescue nil
481
+ end
482
+
483
+ def sample_ordering_rank(role_instance)
484
+ return nil unless role_instance && role_instance.respond_to?(:order)
485
+ kinds = %i[user tool_result plan assistant_delta assistant]
486
+ kinds.each_with_object({}) do |k, acc|
487
+ msg = VSM::Message.new(kind: k, payload: nil)
488
+ acc[k] = role_instance.order(msg)
489
+ end
490
+ rescue StandardError
491
+ nil
492
+ end
493
+ end
494
+
495
+ # Write-path tools removed: keeping read-only meta tools only.
496
+ end
497
+ end
498
+ end
data/lib/vsm/meta.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "meta/support"
4
+ require_relative "meta/snapshot_builder"
5
+ require_relative "meta/snapshot_cache"
6
+ require_relative "meta/tools"
7
+
8
+ module VSM
9
+ module Meta
10
+ DEFAULT_TOOL_MAP = {
11
+ "meta_summarize_self" => Tools::SummarizeSelf,
12
+ "meta_list_tools" => Tools::ListTools,
13
+ "meta_explain_tool" => Tools::ExplainTool,
14
+ "meta_explain_role" => Tools::ExplainRole
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def attach!(capsule, prefix: "", only: nil, except: nil)
20
+ cache = SnapshotCache.new(SnapshotBuilder.new(root: capsule))
21
+ installed = {}
22
+
23
+ tool_map = select_tools(only:, except:).transform_keys { |name| "#{prefix}#{name}" }
24
+ tool_map.each do |tool_name, klass|
25
+ tool = klass.new(root: capsule, snapshot_cache: cache)
26
+ if capsule.roles[:governance] && tool.respond_to?(:governance=)
27
+ tool.governance = capsule.roles[:governance]
28
+ end
29
+ register_tool(capsule, tool_name, tool)
30
+ installed[tool_name] = tool
31
+ end
32
+
33
+ cache.fetch
34
+ installed
35
+ end
36
+
37
+ def select_tools(only:, except:)
38
+ map = DEFAULT_TOOL_MAP
39
+ if only && !Array(only).empty?
40
+ keys = Array(only).map(&:to_s)
41
+ map = map.select { |name, _| keys.include?(name) }
42
+ end
43
+ if except && !Array(except).empty?
44
+ rejects = Array(except).map(&:to_s)
45
+ map = map.reject { |name, _| rejects.include?(name) }
46
+ end
47
+ map
48
+ end
49
+
50
+ def register_tool(capsule, name, tool)
51
+ key = name.to_s
52
+ capsule.children[key] = tool
53
+ context_children = capsule.bus.context[:operations_children]
54
+ if context_children.is_a?(Hash)
55
+ context_children[key] = tool
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+ require "securerandom"
3
+ require "io/console"
4
+ require "async"
5
+
6
+
7
+ module VSM
8
+ module Ports
9
+ # Generic, customizable chat TTY port.
10
+ # - Safe to run alongside an MCP stdio port: prefers IO.console for I/O.
11
+ # - Override banner(io) and render_out(message) to customize without
12
+ # reimplementing the core input loop.
13
+ class ChatTTY < VSM::Port
14
+ DEFAULT_THEME = {
15
+ you: "\e[94mYou\e[0m: ",
16
+ tool: "\e[90m→ tool\e[0m ",
17
+ turn: "\e[2m(turn %s)\e[0m"
18
+ }.freeze
19
+
20
+ def initialize(capsule:, input: nil, output: nil, banner: nil, prompt: nil, theme: {}, show_tool_results: false)
21
+ super(capsule: capsule)
22
+ # Prefer STDIN/STDOUT if they are TTY. If not, try /dev/tty.
23
+ # Avoid IO.console to minimize kqueue/select issues under async.
24
+ tty_io = nil
25
+ if !$stdout.tty?
26
+ begin
27
+ tty_io = File.open("/dev/tty", "r+")
28
+ rescue StandardError
29
+ tty_io = nil
30
+ end
31
+ end
32
+
33
+ @in = input || (tty_io || ($stdin.tty? ? $stdin : nil))
34
+ @out = output || (tty_io || ($stdout.tty? ? $stdout : $stderr))
35
+ @banner = banner # String or ->(io) {}
36
+ @prompt = prompt || DEFAULT_THEME[:you]
37
+ @theme = DEFAULT_THEME.merge(theme)
38
+ @streaming = false
39
+ @show_tool_results = show_tool_results
40
+ end
41
+
42
+ def should_render?(message)
43
+ [:assistant_delta, :assistant, :tool_call, :tool_result].include?(message.kind)
44
+ end
45
+
46
+ def loop
47
+ sid = SecureRandom.uuid
48
+ @capsule.roles[:coordination].grant_floor!(sid) if @capsule.roles[:coordination].respond_to?(:grant_floor!)
49
+ banner(@out)
50
+
51
+ if @in.nil?
52
+ @out.puts "(no interactive TTY; ChatTTY input disabled)"
53
+ Async::Task.current.sleep # keep task alive for egress rendering
54
+ return
55
+ end
56
+
57
+ @out.print @prompt
58
+ while (line = @in.gets&.chomp)
59
+ @capsule.bus.emit VSM::Message.new(kind: :user, payload: line, meta: { session_id: sid })
60
+ if @capsule.roles[:coordination].respond_to?(:wait_for_turn_end)
61
+ @capsule.roles[:coordination].wait_for_turn_end(sid)
62
+ end
63
+ @out.print @prompt
64
+ end
65
+ end
66
+
67
+ def render_out(message)
68
+ case message.kind
69
+ when :assistant_delta
70
+ @streaming = true
71
+ @out.print(message.payload)
72
+ @out.flush
73
+ when :assistant
74
+ # If we didn't stream content, print the final content now.
75
+ unless @streaming
76
+ txt = message.payload.to_s
77
+ unless txt.empty?
78
+ @out.puts
79
+ @out.puts txt
80
+ end
81
+ end
82
+ turn = message.meta&.dig(:turn_id)
83
+ @out.puts(@theme[:turn] % turn) if turn
84
+ @streaming = false
85
+ when :tool_call
86
+ @out.puts
87
+ @out.puts "#{@theme[:tool]}#{message.payload[:tool]}"
88
+ when :tool_result
89
+ return unless @show_tool_results
90
+ out = message.payload.to_s
91
+ unless out.empty?
92
+ @out.puts
93
+ @out.puts out
94
+ end
95
+ end
96
+ end
97
+
98
+ # Overridable header/banner
99
+ def banner(io)
100
+ if @banner.respond_to?(:call)
101
+ @banner.call(io)
102
+ elsif @banner.is_a?(String)
103
+ io.puts @banner
104
+ else
105
+ io.puts "vsm chat — Ctrl-C to exit"
106
+ end
107
+ end
108
+
109
+ private
110
+ end
111
+ end
112
+ end