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,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "open3"
|
3
|
+
require "shellwords"
|
4
|
+
require_relative "jsonrpc"
|
5
|
+
|
6
|
+
module VSM
|
7
|
+
module MCP
|
8
|
+
class Client
|
9
|
+
attr_reader :name
|
10
|
+
|
11
|
+
def initialize(cmd:, env: {}, cwd: nil, name: nil)
|
12
|
+
@cmd, @env, @cwd, @name = cmd, env, cwd, (name || cmd.split.first)
|
13
|
+
@stdin = @stdout = @stderr = @wait_thr = nil
|
14
|
+
@rpc = nil
|
15
|
+
@stderr_thread = nil
|
16
|
+
end
|
17
|
+
|
18
|
+
def start
|
19
|
+
opts = {}
|
20
|
+
opts[:chdir] = @cwd if @cwd
|
21
|
+
args = @cmd.is_a?(Array) ? @cmd : Shellwords.split(@cmd.to_s)
|
22
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(@env || {}, *args, **opts)
|
23
|
+
# Drain stderr to avoid blocking if the server writes logs
|
24
|
+
@stderr_thread = Thread.new do
|
25
|
+
begin
|
26
|
+
@stderr.each_line { |_line| }
|
27
|
+
rescue StandardError
|
28
|
+
end
|
29
|
+
end
|
30
|
+
@rpc = JSONRPC::Stdio.new(r: @stdout, w: @stdin)
|
31
|
+
self
|
32
|
+
end
|
33
|
+
|
34
|
+
def stop
|
35
|
+
begin
|
36
|
+
@stdin&.close
|
37
|
+
rescue StandardError
|
38
|
+
end
|
39
|
+
begin
|
40
|
+
@stdout&.close
|
41
|
+
rescue StandardError
|
42
|
+
end
|
43
|
+
begin
|
44
|
+
@stderr&.close
|
45
|
+
rescue StandardError
|
46
|
+
end
|
47
|
+
begin
|
48
|
+
@stderr_thread&.kill
|
49
|
+
rescue StandardError
|
50
|
+
end
|
51
|
+
begin
|
52
|
+
@wait_thr&.kill
|
53
|
+
rescue StandardError
|
54
|
+
end
|
55
|
+
nil
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns Array<Hash> with symbol keys: :name, :description, :input_schema
|
59
|
+
def list_tools
|
60
|
+
raw = @rpc.request("tools/list")
|
61
|
+
arr = (raw && raw["tools"]) || []
|
62
|
+
arr.map do |t|
|
63
|
+
{
|
64
|
+
name: t["name"].to_s,
|
65
|
+
description: t["description"].to_s,
|
66
|
+
input_schema: (t["input_schema"] || {})
|
67
|
+
}
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Returns a String (first text content) or a JSON string fallback
|
72
|
+
def call_tool(name:, arguments: {})
|
73
|
+
res = @rpc.request("tools/call", { "name" => name, "arguments" => arguments })
|
74
|
+
content = Array(res["content"])
|
75
|
+
item = content.find { |c| c["type"] == "text" } || content.first
|
76
|
+
item ? (item["text"] || item.to_s) : res.to_s
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "json"
|
3
|
+
require "monitor"
|
4
|
+
|
5
|
+
module VSM
|
6
|
+
module MCP
|
7
|
+
module JSONRPC
|
8
|
+
# Minimal NDJSON (one JSON per line) JSON-RPC transport over IO.
|
9
|
+
# Note: MCP servers often speak LSP framing; we can add that later.
|
10
|
+
class Stdio
|
11
|
+
include MonitorMixin
|
12
|
+
|
13
|
+
def initialize(r:, w:)
|
14
|
+
@r = r
|
15
|
+
@w = w
|
16
|
+
@seq = 0
|
17
|
+
mon_initialize
|
18
|
+
end
|
19
|
+
|
20
|
+
def request(method, params = {})
|
21
|
+
id = next_id
|
22
|
+
write({ jsonrpc: "2.0", id: id, method: method, params: params })
|
23
|
+
loop do
|
24
|
+
msg = read
|
25
|
+
next unless msg
|
26
|
+
if msg["id"].to_s == id.to_s
|
27
|
+
err = msg["error"]
|
28
|
+
raise(err.is_a?(Hash) ? (err["message"] || err.inspect) : err.to_s) if err
|
29
|
+
return msg["result"]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def notify(method, params = {})
|
35
|
+
write({ jsonrpc: "2.0", method: method, params: params })
|
36
|
+
end
|
37
|
+
|
38
|
+
def read
|
39
|
+
line = @r.gets
|
40
|
+
return nil unless line
|
41
|
+
# Handle LSP-style framing: "Content-Length: N" followed by blank line and JSON body.
|
42
|
+
if line =~ /\AContent-Length:\s*(\d+)\s*\r?\n?\z/i
|
43
|
+
length = Integer($1)
|
44
|
+
# Consume optional additional headers until blank line
|
45
|
+
while (hdr = @r.gets)
|
46
|
+
break if hdr.strip.empty?
|
47
|
+
end
|
48
|
+
body = read_exact(length)
|
49
|
+
$stderr.puts("[mcp-rpc] < #{body}") if ENV["VSM_MCP_DEBUG"] == "1"
|
50
|
+
return JSON.parse(body)
|
51
|
+
end
|
52
|
+
# Otherwise assume NDJSON (one JSON object per line)
|
53
|
+
$stderr.puts("[mcp-rpc] < #{line.strip}") if ENV["VSM_MCP_DEBUG"] == "1"
|
54
|
+
JSON.parse(line)
|
55
|
+
end
|
56
|
+
|
57
|
+
def write(obj)
|
58
|
+
body = JSON.dump(obj)
|
59
|
+
$stderr.puts("[mcp-rpc] > #{body}") if ENV["VSM_MCP_DEBUG"] == "1"
|
60
|
+
synchronize do
|
61
|
+
# Prefer NDJSON for broad compatibility; some servers require LSP.
|
62
|
+
# If VSM_MCP_LSP=1, use Content-Length framing.
|
63
|
+
if ENV["VSM_MCP_LSP"] == "1"
|
64
|
+
@w.write("Content-Length: #{body.bytesize}\r\n\r\n")
|
65
|
+
@w.write(body)
|
66
|
+
@w.flush
|
67
|
+
else
|
68
|
+
@w.puts(body)
|
69
|
+
@w.flush
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def next_id
|
77
|
+
synchronize { @seq += 1; @seq.to_s }
|
78
|
+
end
|
79
|
+
|
80
|
+
def read_exact(n)
|
81
|
+
data = +""
|
82
|
+
while data.bytesize < n
|
83
|
+
chunk = @r.read(n - data.bytesize)
|
84
|
+
break unless chunk
|
85
|
+
data << chunk
|
86
|
+
end
|
87
|
+
data
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module VSM
|
3
|
+
module MCP
|
4
|
+
class RemoteToolCapsule < VSM::ToolCapsule
|
5
|
+
attr_writer :bus
|
6
|
+
|
7
|
+
def initialize(client:, remote_name:, descriptor:)
|
8
|
+
@client = client
|
9
|
+
@remote_name = remote_name
|
10
|
+
@descriptor = descriptor # { name:, description:, input_schema: }
|
11
|
+
end
|
12
|
+
|
13
|
+
def tool_descriptor
|
14
|
+
VSM::Tool::Descriptor.new(
|
15
|
+
name: @descriptor[:name],
|
16
|
+
description: @descriptor[:description],
|
17
|
+
schema: @descriptor[:input_schema]
|
18
|
+
)
|
19
|
+
end
|
20
|
+
|
21
|
+
def execution_mode
|
22
|
+
:thread
|
23
|
+
end
|
24
|
+
|
25
|
+
def run(args)
|
26
|
+
@bus&.emit VSM::Message.new(kind: :progress, payload: "mcp call #{@client.name}.#{@remote_name}", path: [:mcp, :client, @client.name, @remote_name])
|
27
|
+
out = @client.call_tool(name: @remote_name, arguments: args || {})
|
28
|
+
@bus&.emit VSM::Message.new(kind: :progress, payload: "mcp result #{@client.name}.#{@remote_name}", path: [:mcp, :client, @client.name, @remote_name])
|
29
|
+
out.to_s
|
30
|
+
rescue => e
|
31
|
+
"ERROR: #{e.class}: #{e.message}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pathname"
|
4
|
+
|
5
|
+
require_relative "support"
|
6
|
+
|
7
|
+
module VSM
|
8
|
+
module Meta
|
9
|
+
class SnapshotBuilder
|
10
|
+
ROLE_METHOD_HINTS = {
|
11
|
+
identity: %i[handle alert observe initialize],
|
12
|
+
governance: %i[enforce observe initialize],
|
13
|
+
coordination: %i[stage drain order grant_floor! wait_for_turn_end initialize],
|
14
|
+
operations: %i[handle observe initialize],
|
15
|
+
intelligence: %i[handle system_prompt offer_tools? initialize],
|
16
|
+
monitoring: %i[observe handle initialize]
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
def initialize(root:)
|
20
|
+
@root = root
|
21
|
+
end
|
22
|
+
|
23
|
+
def call
|
24
|
+
snapshot_capsule(@root, path: [@root.name.to_s])
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def snapshot_capsule(capsule, path:)
|
30
|
+
{
|
31
|
+
kind: "capsule",
|
32
|
+
name: capsule.name.to_s,
|
33
|
+
class: capsule.class.name,
|
34
|
+
path: path.dup,
|
35
|
+
roles: snapshot_roles(capsule.roles),
|
36
|
+
operations: snapshot_operations(capsule.children, path: path),
|
37
|
+
meta: {}
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
def snapshot_roles(roles)
|
42
|
+
roles.each_with_object({}) do |(role_name, role_instance), acc|
|
43
|
+
acc[role_name.to_s] = snapshot_role(role_name, role_instance)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def snapshot_role(role_name, role_instance)
|
48
|
+
{
|
49
|
+
class: role_instance.class.name,
|
50
|
+
constructor_args: Support.fetch_constructor_args(role_instance),
|
51
|
+
source_locations: method_locations(role_instance.class, ROLE_METHOD_HINTS[role_name] || %i[initialize]),
|
52
|
+
summary: nil
|
53
|
+
}
|
54
|
+
end
|
55
|
+
|
56
|
+
def snapshot_operations(children, path:)
|
57
|
+
return { children: {} } if children.nil? || children.empty?
|
58
|
+
|
59
|
+
ops = {}
|
60
|
+
children.each do |name, child|
|
61
|
+
ops[name.to_s] = snapshot_child(child, path: path + [name.to_s])
|
62
|
+
end
|
63
|
+
{ children: ops }
|
64
|
+
end
|
65
|
+
|
66
|
+
def snapshot_child(child, path:)
|
67
|
+
base = {
|
68
|
+
name: path.last,
|
69
|
+
class: child.class.name,
|
70
|
+
path: path,
|
71
|
+
constructor_args: Support.fetch_constructor_args(child),
|
72
|
+
source_locations: [],
|
73
|
+
roles: nil,
|
74
|
+
operations: nil
|
75
|
+
}
|
76
|
+
|
77
|
+
if child.respond_to?(:roles) && child.respond_to?(:children)
|
78
|
+
base[:kind] = "capsule"
|
79
|
+
base[:roles] = snapshot_roles(child.roles)
|
80
|
+
base[:operations] = snapshot_operations(child.children || {}, path: path)
|
81
|
+
base[:source_locations] = method_locations(child.class, %i[initialize])
|
82
|
+
elsif child.respond_to?(:tool_descriptor)
|
83
|
+
base[:kind] = "tool"
|
84
|
+
descriptor = child.tool_descriptor
|
85
|
+
base[:tool] = {
|
86
|
+
name: descriptor.name,
|
87
|
+
description: descriptor.description,
|
88
|
+
schema: descriptor.schema
|
89
|
+
}
|
90
|
+
base[:source_locations] = method_locations(child.class, %i[run execution_mode initialize])
|
91
|
+
else
|
92
|
+
base[:kind] = "object"
|
93
|
+
base[:source_locations] = method_locations(child.class, %i[initialize])
|
94
|
+
end
|
95
|
+
|
96
|
+
base
|
97
|
+
end
|
98
|
+
|
99
|
+
def method_locations(klass, candidates)
|
100
|
+
candidates.filter_map do |meth|
|
101
|
+
next unless klass.instance_methods.include?(meth)
|
102
|
+
location = klass.instance_method(meth).source_location
|
103
|
+
next if location.nil?
|
104
|
+
{ method: meth.to_s, path: relative_path(location[0]), line: location[1] }
|
105
|
+
rescue NameError
|
106
|
+
nil
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def relative_path(path)
|
111
|
+
return path if path.nil?
|
112
|
+
root = Pathname.new(Dir.pwd)
|
113
|
+
begin
|
114
|
+
Pathname.new(path).relative_path_from(root).to_s
|
115
|
+
rescue ArgumentError
|
116
|
+
path
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thread"
|
4
|
+
|
5
|
+
module VSM
|
6
|
+
module Meta
|
7
|
+
class SnapshotCache
|
8
|
+
def initialize(builder)
|
9
|
+
@builder = builder
|
10
|
+
@mutex = Mutex.new
|
11
|
+
@snapshot = nil
|
12
|
+
end
|
13
|
+
|
14
|
+
def fetch
|
15
|
+
@mutex.synchronize do
|
16
|
+
@snapshot ||= { generated_at: Time.now.utc, data: @builder.call }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def invalidate!
|
21
|
+
@mutex.synchronize { @snapshot = nil }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module VSM
|
4
|
+
module Meta
|
5
|
+
module Support
|
6
|
+
CONFIG_IVAR = :@__vsm_constructor_args
|
7
|
+
|
8
|
+
module_function
|
9
|
+
|
10
|
+
def record_constructor_args(instance, args)
|
11
|
+
copied = copy_args(args)
|
12
|
+
instance.instance_variable_set(CONFIG_IVAR, copied)
|
13
|
+
instance
|
14
|
+
end
|
15
|
+
|
16
|
+
def fetch_constructor_args(instance)
|
17
|
+
instance.instance_variable_get(CONFIG_IVAR)
|
18
|
+
end
|
19
|
+
|
20
|
+
def copy_args(args)
|
21
|
+
return {} if args.nil?
|
22
|
+
case args
|
23
|
+
when Hash
|
24
|
+
args.transform_values { copy_args(_1) }
|
25
|
+
when Array
|
26
|
+
args.map { copy_args(_1) }
|
27
|
+
when Symbol, Numeric, NilClass, TrueClass, FalseClass
|
28
|
+
args
|
29
|
+
else
|
30
|
+
args.dup rescue args
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|