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