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.
- checksums.yaml +4 -4
- data/README.md +144 -0
- data/Rakefile +1 -5
- data/examples/01_echo_tool.rb +5 -24
- data/examples/02_openai_streaming.rb +3 -3
- data/examples/02b_anthropic_streaming.rb +1 -4
- data/examples/03b_anthropic_tools.rb +1 -4
- data/examples/05_mcp_server_and_chattty.rb +63 -0
- data/examples/06_mcp_mount_reflection.rb +45 -0
- data/examples/07_connect_claude_mcp.rb +78 -0
- data/examples/08_custom_chattty.rb +63 -0
- data/examples/09_mcp_with_llm_calls.rb +49 -0
- data/examples/10_meta_read_only.rb +56 -0
- data/exe/vsm +17 -0
- data/lib/vsm/async_channel.rb +26 -3
- data/lib/vsm/capsule.rb +2 -0
- data/lib/vsm/cli.rb +78 -0
- data/lib/vsm/dsl.rb +41 -11
- data/lib/vsm/dsl_mcp.rb +36 -0
- data/lib/vsm/generator/new_project.rb +154 -0
- data/lib/vsm/generator/templates/Gemfile.erb +9 -0
- data/lib/vsm/generator/templates/README_md.erb +40 -0
- data/lib/vsm/generator/templates/Rakefile.erb +5 -0
- data/lib/vsm/generator/templates/bin_console.erb +11 -0
- data/lib/vsm/generator/templates/bin_setup.erb +7 -0
- data/lib/vsm/generator/templates/exe_name.erb +34 -0
- data/lib/vsm/generator/templates/gemspec.erb +24 -0
- data/lib/vsm/generator/templates/gitignore.erb +10 -0
- data/lib/vsm/generator/templates/lib_name_rb.erb +9 -0
- data/lib/vsm/generator/templates/lib_organism_rb.erb +44 -0
- data/lib/vsm/generator/templates/lib_ports_chat_tty_rb.erb +12 -0
- data/lib/vsm/generator/templates/lib_tools_read_file_rb.erb +32 -0
- data/lib/vsm/generator/templates/lib_version_rb.erb +6 -0
- data/lib/vsm/mcp/client.rb +80 -0
- data/lib/vsm/mcp/jsonrpc.rb +92 -0
- data/lib/vsm/mcp/remote_tool_capsule.rb +35 -0
- data/lib/vsm/meta/snapshot_builder.rb +121 -0
- data/lib/vsm/meta/snapshot_cache.rb +25 -0
- data/lib/vsm/meta/support.rb +35 -0
- data/lib/vsm/meta/tools.rb +498 -0
- data/lib/vsm/meta.rb +59 -0
- data/lib/vsm/ports/chat_tty.rb +112 -0
- data/lib/vsm/ports/mcp/server_stdio.rb +101 -0
- data/lib/vsm/roles/intelligence.rb +6 -2
- data/lib/vsm/version.rb +1 -1
- data/lib/vsm.rb +10 -0
- data/mcp_update.md +162 -0
- metadata +38 -18
- 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
|