vsm 0.0.1 → 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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/settings.local.json +17 -0
  3. data/CLAUDE.md +134 -0
  4. data/README.md +675 -17
  5. data/Rakefile +1 -5
  6. data/examples/01_echo_tool.rb +51 -0
  7. data/examples/02_openai_streaming.rb +73 -0
  8. data/examples/02b_anthropic_streaming.rb +58 -0
  9. data/examples/02c_gemini_streaming.rb +60 -0
  10. data/examples/03_openai_tools.rb +106 -0
  11. data/examples/03b_anthropic_tools.rb +93 -0
  12. data/examples/03c_gemini_tools.rb +95 -0
  13. data/examples/05_mcp_server_and_chattty.rb +63 -0
  14. data/examples/06_mcp_mount_reflection.rb +45 -0
  15. data/examples/07_connect_claude_mcp.rb +78 -0
  16. data/examples/08_custom_chattty.rb +63 -0
  17. data/examples/09_mcp_with_llm_calls.rb +49 -0
  18. data/examples/10_meta_read_only.rb +56 -0
  19. data/exe/vsm +17 -0
  20. data/lib/vsm/async_channel.rb +44 -0
  21. data/lib/vsm/capsule.rb +46 -0
  22. data/lib/vsm/cli.rb +78 -0
  23. data/lib/vsm/drivers/anthropic/async_driver.rb +210 -0
  24. data/lib/vsm/drivers/family.rb +16 -0
  25. data/lib/vsm/drivers/gemini/async_driver.rb +149 -0
  26. data/lib/vsm/drivers/openai/async_driver.rb +202 -0
  27. data/lib/vsm/dsl.rb +80 -0
  28. data/lib/vsm/dsl_mcp.rb +36 -0
  29. data/lib/vsm/executors/fiber_executor.rb +10 -0
  30. data/lib/vsm/executors/thread_executor.rb +19 -0
  31. data/lib/vsm/generator/new_project.rb +154 -0
  32. data/lib/vsm/generator/templates/Gemfile.erb +9 -0
  33. data/lib/vsm/generator/templates/README_md.erb +40 -0
  34. data/lib/vsm/generator/templates/Rakefile.erb +5 -0
  35. data/lib/vsm/generator/templates/bin_console.erb +11 -0
  36. data/lib/vsm/generator/templates/bin_setup.erb +7 -0
  37. data/lib/vsm/generator/templates/exe_name.erb +34 -0
  38. data/lib/vsm/generator/templates/gemspec.erb +24 -0
  39. data/lib/vsm/generator/templates/gitignore.erb +10 -0
  40. data/lib/vsm/generator/templates/lib_name_rb.erb +9 -0
  41. data/lib/vsm/generator/templates/lib_organism_rb.erb +44 -0
  42. data/lib/vsm/generator/templates/lib_ports_chat_tty_rb.erb +12 -0
  43. data/lib/vsm/generator/templates/lib_tools_read_file_rb.erb +32 -0
  44. data/lib/vsm/generator/templates/lib_version_rb.erb +6 -0
  45. data/lib/vsm/homeostat.rb +19 -0
  46. data/lib/vsm/lens/event_hub.rb +73 -0
  47. data/lib/vsm/lens/server.rb +188 -0
  48. data/lib/vsm/lens/stats.rb +58 -0
  49. data/lib/vsm/lens/tui.rb +88 -0
  50. data/lib/vsm/lens.rb +79 -0
  51. data/lib/vsm/mcp/client.rb +80 -0
  52. data/lib/vsm/mcp/jsonrpc.rb +92 -0
  53. data/lib/vsm/mcp/remote_tool_capsule.rb +35 -0
  54. data/lib/vsm/message.rb +6 -0
  55. data/lib/vsm/meta/snapshot_builder.rb +121 -0
  56. data/lib/vsm/meta/snapshot_cache.rb +25 -0
  57. data/lib/vsm/meta/support.rb +35 -0
  58. data/lib/vsm/meta/tools.rb +498 -0
  59. data/lib/vsm/meta.rb +59 -0
  60. data/lib/vsm/observability/ledger.rb +25 -0
  61. data/lib/vsm/port.rb +11 -0
  62. data/lib/vsm/ports/chat_tty.rb +112 -0
  63. data/lib/vsm/ports/mcp/server_stdio.rb +101 -0
  64. data/lib/vsm/roles/coordination.rb +49 -0
  65. data/lib/vsm/roles/governance.rb +9 -0
  66. data/lib/vsm/roles/identity.rb +11 -0
  67. data/lib/vsm/roles/intelligence.rb +172 -0
  68. data/lib/vsm/roles/operations.rb +33 -0
  69. data/lib/vsm/runtime.rb +18 -0
  70. data/lib/vsm/tool/acts_as_tool.rb +20 -0
  71. data/lib/vsm/tool/capsule.rb +12 -0
  72. data/lib/vsm/tool/descriptor.rb +16 -0
  73. data/lib/vsm/version.rb +1 -1
  74. data/lib/vsm.rb +43 -0
  75. data/llms.txt +322 -0
  76. data/mcp_update.md +162 -0
  77. metadata +93 -31
  78. data/.rubocop.yml +0 -8
@@ -0,0 +1,40 @@
1
+ # <%= module_name %>
2
+
3
+ A minimal VSM app scaffold. Starts a capsule with a ChatTTY interface, an LLM-backed intelligence (OpenAI by default), and a `read_file` tool.
4
+
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ bundle install
9
+ OPENAI_API_KEY=... bundle exec exe/<%= exe_name %>
10
+ ```
11
+
12
+ Ask the assistant questions, or request reading a file, e.g.:
13
+
14
+ ```
15
+ read README.md
16
+ ```
17
+
18
+ You can customize the banner and prompt in `lib/<%= lib_name %>/ports/chat_tty.rb` and add tools under `lib/<%= lib_name %>/tools`.
19
+
20
+ ## LLM Configuration
21
+
22
+ This scaffold includes LLM wiring. Configure provider via env vars (or choose at generation time):
23
+
24
+ - `<%= env_prefix %>_PROVIDER` — `openai` (default), `anthropic`, or `gemini`
25
+ - `<%= env_prefix %>_MODEL` — defaults to `<%= default_model %>` if not set
26
+ - API key env var depends on provider:
27
+ - `OPENAI_API_KEY`
28
+ - `ANTHROPIC_API_KEY`
29
+ - `GEMINI_API_KEY`
30
+
31
+ Run:
32
+
33
+ ```bash
34
+ <%= env_prefix %>_PROVIDER=<%= provider %> <%= env_prefix %>_MODEL=<%= default_model %> \
35
+ OPENAI_API_KEY=... bundle exec exe/<%= exe_name %>
36
+ ```
37
+
38
+ ## Lens (optional)
39
+
40
+ Set `VSM_LENS=1` to launch the Lens UI and print its URL. You can change `VSM_LENS_PORT` and provide `VSM_LENS_TOKEN`.
@@ -0,0 +1,5 @@
1
+ require "bundler/gem_tasks"
2
+ task :default do
3
+ sh "bundle exec rspec" if File.exist?("spec")
4
+ end
5
+
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup" if File.exist?(File.expand_path("../Gemfile", __dir__))
5
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
6
+ require "<%= lib_name %>"
7
+
8
+ puts "Starting console with <%= module_name %> loaded"
9
+ require 'irb'
10
+ IRB.start
11
+
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ bundle install
5
+
6
+ echo "OK"
7
+
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Keep CLI independent of any project's Bundler context.
5
+ ENV.delete('BUNDLE_GEMFILE')
6
+ ENV.delete('BUNDLE_BIN_PATH')
7
+ if (rubyopt = ENV['RUBYOPT'])
8
+ ENV['RUBYOPT'] = rubyopt.split.reject { |x| x.include?('bundler/setup') }.join(' ')
9
+ end
10
+ ENV.delete('RUBYGEMS_GEMDEPS')
11
+
12
+ $stdout.sync = true
13
+ $stderr.sync = true
14
+
15
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
16
+ require "vsm"
17
+ require "<%= lib_name %>"
18
+
19
+ capsule = <%= module_name %>::Organism.build
20
+
21
+ hub = nil
22
+ if ENV["VSM_LENS"] == "1"
23
+ hub = VSM::Lens.attach!(
24
+ capsule,
25
+ host: "127.0.0.1",
26
+ port: (ENV["VSM_LENS_PORT"] || 9292).to_i,
27
+ token: ENV["VSM_LENS_TOKEN"]
28
+ )
29
+ puts "Lens: http://127.0.0.1:#{ENV['VSM_LENS_PORT'] || 9292}"
30
+ end
31
+
32
+ port = <%= module_name %>::Ports::ChatTTY.new(capsule: capsule)
33
+ VSM::Runtime.start(capsule, ports: [port])
34
+
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/<%= lib_name %>/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "<%= lib_name %>"
7
+ spec.version = <%= module_name %>::VERSION
8
+ spec.authors = ["Your Name"]
9
+ spec.email = ["you@example.com"]
10
+
11
+ spec.summary = "VSM app scaffold"
12
+ spec.description = "A minimal VSM-based agent app with ChatTTY and sample tools."
13
+ spec.license = "MIT"
14
+
15
+ spec.required_ruby_version = ">= 3.2"
16
+
17
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
18
+ Dir["{bin,exe,lib}/**/*", "README.md", "LICENSE.txt", "Rakefile", "Gemfile"].select { |f| File.file?(f) }
19
+ end
20
+ spec.bindir = "exe"
21
+ spec.executables = ["<%= exe_name %>"]
22
+ spec.require_paths = ["lib"]
23
+ end
24
+
@@ -0,0 +1,10 @@
1
+ .bundle/
2
+ vendor/bundle/
3
+ pkg/
4
+ .vsm.log.jsonl
5
+ *.gem
6
+ .DS_Store
7
+ /.ruby-version
8
+ /.ruby-gemset
9
+ /.env
10
+
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vsm"
4
+ require_relative "<%= lib_name %>/organism"
5
+ require_relative "<%= lib_name %>/ports/chat_tty"
6
+
7
+ module <%= module_name %>
8
+ end
9
+
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "tools/read_file"
6
+
7
+ module <%= module_name %>
8
+ module Organism
9
+ def self.build
10
+ # Provider selection via env (default injected at generation time)
11
+ provider = (ENV["<%= env_prefix %>_PROVIDER"] || "<%= provider %>").downcase
12
+ driver =
13
+ case provider
14
+ when "anthropic"
15
+ VSM::Drivers::Anthropic::AsyncDriver.new(
16
+ api_key: ENV.fetch("ANTHROPIC_API_KEY"),
17
+ model: ENV["<%= env_prefix %>_MODEL"] || "<%= default_model %>"
18
+ )
19
+ when "gemini"
20
+ VSM::Drivers::Gemini::AsyncDriver.new(
21
+ api_key: ENV.fetch("GEMINI_API_KEY"),
22
+ model: ENV["<%= env_prefix %>_MODEL"] || "<%= default_model %>"
23
+ )
24
+ else
25
+ VSM::Drivers::OpenAI::AsyncDriver.new(
26
+ api_key: ENV.fetch("OPENAI_API_KEY"),
27
+ model: ENV["<%= env_prefix %>_MODEL"] || "<%= default_model %>"
28
+ )
29
+ end
30
+
31
+ VSM::DSL.define(:<%= lib_name %>) do
32
+ identity klass: VSM::Identity, args: { identity: "<%= lib_name %>", invariants: [] }
33
+ governance klass: VSM::Governance
34
+ coordination klass: VSM::Coordination
35
+ intelligence klass: VSM::Intelligence, args: { driver: driver, system_prompt: "You are a helpful assistant. Use tools when helpful." }
36
+ monitoring klass: VSM::Monitoring
37
+
38
+ operations do
39
+ capsule :read_file, klass: <%= module_name %>::Tools::ReadFile
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= module_name %>
4
+ module Ports
5
+ class ChatTTY < VSM::Ports::ChatTTY
6
+ def banner(io)
7
+ io.puts "\e[96m<%= lib_name %>\e[0m — Ctrl-C to exit"
8
+ end
9
+ end
10
+ end
11
+ end
12
+
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= module_name %>
4
+ module Tools
5
+ class ReadFile < VSM::ToolCapsule
6
+ tool_name "read_file"
7
+ tool_description "Read the contents of a UTF-8 text file at a relative path within the current workspace."
8
+ tool_schema({
9
+ type: "object",
10
+ properties: {
11
+ path: { type: "string", description: "Relative path to a text file (UTF-8)." }
12
+ },
13
+ required: ["path"]
14
+ })
15
+
16
+ def run(args)
17
+ rel = args["path"].to_s
18
+ raise "path required" if rel.strip.empty?
19
+ root = Dir.pwd
20
+ full = File.expand_path(File.join(root, rel))
21
+ # Prevent escaping outside workspace root
22
+ unless full.start_with?(root + File::SEPARATOR) || full == root
23
+ raise "outside workspace"
24
+ end
25
+ File.read(full, mode: "r:UTF-8")
26
+ rescue Errno::ENOENT
27
+ raise "file not found: #{rel}"
28
+ end
29
+ end
30
+ end
31
+ end
32
+
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= module_name %>
4
+ VERSION = "0.1.0"
5
+ end
6
+
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ class Homeostat
4
+ attr_reader :limits
5
+
6
+ def initialize
7
+ @limits = { tokens: 8_000, time_ms: 15_000, bytes: 2_000_000 }
8
+ @usage = Hash.new(0)
9
+ end
10
+
11
+ def usage_snapshot
12
+ @usage.dup
13
+ end
14
+
15
+ def alarm?(message)
16
+ message.meta&.dig(:severity) == :algedonic
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "time"
4
+ require "securerandom"
5
+
6
+ module VSM
7
+ module Lens
8
+ class EventHub
9
+ DEFAULT_BUFFER = 500
10
+
11
+ def initialize(buffer_size: DEFAULT_BUFFER)
12
+ @subs = [] # Array of SizedQueue
13
+ @mutex = Mutex.new
14
+ @buffer = []
15
+ @buffer_size = buffer_size
16
+ end
17
+
18
+ def publish(message)
19
+ event = format_event(message)
20
+ @mutex.synchronize do
21
+ @buffer << event
22
+ @buffer.shift(@buffer.size - @buffer_size) if @buffer.size > @buffer_size
23
+ @subs.each { |q| try_push(q, event) }
24
+ end
25
+ end
26
+
27
+ def subscribe
28
+ q = SizedQueue.new(100)
29
+ snapshot = nil
30
+ @mutex.synchronize do
31
+ @subs << q
32
+ snapshot = @buffer.dup
33
+ end
34
+ [q, snapshot]
35
+ end
36
+
37
+ def unsubscribe(queue)
38
+ @mutex.synchronize { @subs.delete(queue) }
39
+ end
40
+
41
+ private
42
+
43
+ def try_push(queue, event)
44
+ queue.push(event)
45
+ rescue ThreadError
46
+ # queue full; drop event to avoid blocking the pipeline
47
+ end
48
+
49
+ def format_event(msg)
50
+ {
51
+ id: SecureRandom.uuid,
52
+ ts: Time.now.utc.iso8601(6),
53
+ kind: msg.kind,
54
+ path: msg.path,
55
+ corr_id: msg.corr_id,
56
+ meta: msg.meta,
57
+ # Small preview to avoid huge payloads; the UI can request details later if you add a /event/:id endpoint
58
+ payload: preview(msg.payload)
59
+ }
60
+ end
61
+
62
+ def preview(payload)
63
+ case payload
64
+ when String
65
+ payload.bytesize > 2_000 ? payload.byteslice(0, 2_000) + "… (truncated)" : payload
66
+ else
67
+ payload
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+ require "rack"
3
+ require "rack/utils"
4
+
5
+ module VSM
6
+ module Lens
7
+ class Server
8
+ INDEX_HTML = <<~HTML
9
+ <!doctype html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
14
+ <title>VSM Lens</title>
15
+ <style>
16
+ :root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
17
+ body { margin: 0; background: #0b0f14; color: #cfd8e3; }
18
+ header { padding: 12px 16px; background: #111827; border-bottom: 1px solid #1f2937; display:flex; align-items:center; gap:12px;}
19
+ header .dot { width:10px; height:10px; border-radius:50%; background:#10b981; }
20
+ main { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 50px); }
21
+ aside { border-right: 1px solid #1f2937; padding: 12px; overflow:auto;}
22
+ section { padding: 12px; overflow:auto;}
23
+ h2 { font-size: 14px; color:#93c5fd; margin:0 0 8px 0; }
24
+ .card { background:#0f172a; border:1px solid #1f2937; border-radius:8px; padding:10px; margin-bottom:8px;}
25
+ .row { display:flex; align-items:flex-start; gap:8px; padding:8px; border-bottom:1px solid #1f2937; }
26
+ .row:last-child { border-bottom:none; }
27
+ .kind { font-weight:600; min-width:120px; color:#e5e7eb; }
28
+ .meta { color:#9ca3af; font-size:12px; }
29
+ .payload { white-space: pre-wrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size:12px; color:#d1fae5; }
30
+ .pill { display:inline-block; padding:2px 6px; border-radius:999px; font-size:11px; border:1px solid #374151; color:#c7d2fe;}
31
+ .pill.session { color:#fcd34d; }
32
+ .pill.tool { color:#a7f3d0; }
33
+ .toolbar { display:flex; gap:8px; margin-bottom:8px; }
34
+ input[type="text"] { background:#0b1220; color:#e5e7eb; border:1px solid #374151; border-radius:6px; padding:6px 8px; width:100%; }
35
+ .small { font-size:11px; color:#9ca3af; }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <header><div class="dot"></div><div><strong>VSM Lens</strong> <span class="small">live</span></div></header>
40
+ <main>
41
+ <aside>
42
+ <h2>Sessions</h2>
43
+ <div id="sessions"></div>
44
+ <h2>Filters</h2>
45
+ <div class="card">
46
+ <label class="small">Search</label>
47
+ <input id="filter" type="text" placeholder="text, kind, tool, session…" />
48
+ </div>
49
+ </aside>
50
+ <section>
51
+ <h2>Timeline</h2>
52
+ <div id="timeline"></div>
53
+ </section>
54
+ </main>
55
+ <script>
56
+ const params = new URLSearchParams(window.location.search);
57
+ const es = new EventSource("/events" + (params.get("token") ? ("?token=" + encodeURIComponent(params.get("token"))) : ""));
58
+ const sessions = {};
59
+ const timeline = document.getElementById("timeline");
60
+ const sessionsDiv = document.getElementById("sessions");
61
+ const filterInput = document.getElementById("filter");
62
+ let filter = "";
63
+
64
+ filterInput.addEventListener("input", () => { filter = filterInput.value.toLowerCase(); render(); });
65
+
66
+ const ring = [];
67
+ const RING_MAX = 1000;
68
+
69
+ es.onmessage = (e) => {
70
+ const ev = JSON.parse(e.data);
71
+ ring.push(ev);
72
+ if (ring.length > RING_MAX) ring.shift();
73
+
74
+ const sid = ev.meta && ev.meta.session_id;
75
+ if (sid) {
76
+ sessions[sid] = sessions[sid] || { count: 0, last: ev.ts };
77
+ sessions[sid].count += 1; sessions[sid].last = ev.ts;
78
+ }
79
+ render();
80
+ };
81
+
82
+ function render() {
83
+ // Sessions
84
+ sessionsDiv.innerHTML = Object.entries(sessions)
85
+ .sort((a,b)=> a[1].last < b[1].last ? 1 : -1)
86
+ .map(([sid, s]) => `<div class="card"><div><span class="pill session">${sid.slice(0,8)}</span></div><div class="small">${s.count} events • last ${s.last}</div></div>`)
87
+ .join("");
88
+
89
+ // Timeline
90
+ const rows = ring.filter(ev => {
91
+ if (!filter) return true;
92
+ const hay = JSON.stringify(ev).toLowerCase();
93
+ return hay.includes(filter);
94
+ }).slice(-200).map(ev => row(ev)).join("");
95
+
96
+ timeline.innerHTML = rows || "<div class='small'>Waiting for events…</div>";
97
+ }
98
+
99
+ function row(ev) {
100
+ const sid = ev.meta && ev.meta.session_id ? `<span class="pill session">${ev.meta.session_id.slice(0,8)}</span>` : "";
101
+ const tool = (ev.kind === "tool_call" && ev.meta && ev.meta.tool) ? `<span class="pill tool">${ev.meta.tool}</span>` : "";
102
+ const path = ev.path ? `<div class="small">path: ${ev.path.join(" › ")}</div>` : "";
103
+ const meta = `<div class="meta">${sid} ${tool} corr:${ev.corr_id || "–"} • ${ev.ts}</div>${path}`;
104
+ const payload = (typeof ev.payload === "string") ? `<div class="payload">${escapeHtml(ev.payload)}</div>` : `<div class="payload">${escapeHtml(JSON.stringify(ev.payload))}</div>`;
105
+ return `<div class="row"><div class="kind">${ev.kind}</div><div>${meta}${payload}</div></div>`;
106
+ }
107
+
108
+ function escapeHtml(s) {
109
+ return s.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
110
+ }
111
+ </script>
112
+ </body>
113
+ </html>
114
+ HTML
115
+
116
+ def initialize(hub:, token: nil, stats: nil)
117
+ @hub, @token, @stats = hub, token, stats
118
+ end
119
+
120
+ def rack_app
121
+ hub = @hub
122
+ token = @token
123
+ stats = @stats
124
+ Rack::Builder.new do
125
+ use Rack::ContentLength
126
+
127
+ map "/" do
128
+ run proc { |_env| [200, {"Content-Type"=>"text/html; charset=utf-8"}, [Server::INDEX_HTML]] }
129
+ end
130
+
131
+ map "/events" do
132
+ run proc { |env|
133
+ req = Rack::Request.new(env)
134
+ if token && req.params["token"] != token
135
+ [401, {"Content-Type"=>"text/plain"}, ["unauthorized"]]
136
+ else
137
+ queue, snapshot = hub.subscribe
138
+ headers = {"Content-Type"=>"text/event-stream", "Cache-Control"=>"no-cache", "Connection"=>"keep-alive"}
139
+ body = SSEBody.new(hub, queue, snapshot)
140
+ [200, headers, body]
141
+ end
142
+ }
143
+ end
144
+
145
+ map "/state" do
146
+ run proc { |env|
147
+ req = Rack::Request.new(env)
148
+ if token && req.params["token"] != token
149
+ [401, {"Content-Type"=>"application/json"}, [JSON.dump({error: "unauthorized"})]]
150
+ else
151
+ payload = stats ? stats.state : { error: "stats_unavailable" }
152
+ [200, {"Content-Type"=>"application/json"}, [JSON.dump(payload)]]
153
+ end
154
+ }
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ class SSEBody
161
+ def initialize(hub, queue, snapshot)
162
+ @hub, @queue, @snapshot = hub, queue, snapshot
163
+ @heartbeat = true
164
+ end
165
+
166
+ def each
167
+ # Send snapshot first
168
+ @snapshot.each { |ev| yield "data: #{JSON.generate(ev)}\n\n" }
169
+ # Heartbeat thread to keep connections alive
170
+ hb = Thread.new do
171
+ while @heartbeat
172
+ sleep 15
173
+ yield ": ping\n\n" # SSE comment line
174
+ end
175
+ end
176
+ # Stream live events
177
+ loop do
178
+ ev = @queue.pop
179
+ yield "data: #{JSON.generate(ev)}\n\n"
180
+ end
181
+ ensure
182
+ @heartbeat = false
183
+ @hub.unsubscribe(@queue) rescue nil
184
+ hb.kill if hb&.alive?
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+ require "time"
3
+
4
+ module VSM
5
+ module Lens
6
+ class Stats
7
+ def initialize(hub:, capsule:)
8
+ @sessions = Hash.new { |h,k| h[k] = { count: 0, last: nil, kinds: Hash.new(0) } }
9
+ @kinds = Hash.new(0)
10
+ @capsule = capsule
11
+
12
+ queue, snapshot = hub.subscribe
13
+ snapshot.each { |ev| ingest(ev) }
14
+
15
+ @thread = Thread.new do
16
+ loop do
17
+ ev = queue.pop
18
+ ingest(ev)
19
+ end
20
+ end
21
+ end
22
+
23
+ def state
24
+ {
25
+ ts: Time.now.utc.iso8601(6),
26
+ sessions: sort_sessions(@sessions),
27
+ kinds: @kinds.dup,
28
+ tools: tool_inventory,
29
+ budgets: {
30
+ limits: @capsule.homeostat.limits,
31
+ usage: @capsule.homeostat.usage_snapshot
32
+ }
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def ingest(ev)
39
+ @kinds[ev[:kind]] += 1
40
+ sid = ev.dig(:meta, :session_id)
41
+ return unless sid
42
+ @sessions[sid][:count] += 1
43
+ @sessions[sid][:last] = ev[:ts]
44
+ @sessions[sid][:kinds][ev[:kind]] += 1
45
+ end
46
+
47
+ def sort_sessions(h)
48
+ h.sort_by { |_sid, s| s[:last].to_s }.reverse.to_h
49
+ end
50
+
51
+ def tool_inventory
52
+ ops = @capsule.bus.context[:operations_children] || {}
53
+ ops.keys.sort
54
+ end
55
+ end
56
+ end
57
+ end
58
+
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+ require "io/console"
3
+ require "json"
4
+
5
+ module VSM
6
+ module Lens
7
+ module TUI
8
+ # Start a simple TUI that renders the last N events and sessions.
9
+ # Usage:
10
+ # hub = VSM::Lens.attach!(capsule)
11
+ # VSM::Lens::TUI.start(hub)
12
+ def self.start(hub, ring_max: 500)
13
+ queue, snapshot = hub.subscribe
14
+ ring = snapshot.last(ring_max)
15
+
16
+ reader = Thread.new do
17
+ loop { ring << queue.pop; ring.shift if ring.size > ring_max }
18
+ end
19
+
20
+ trap("INT") { exit }
21
+ trap("TERM") { exit }
22
+
23
+ STDIN.raw do
24
+ loop do
25
+ draw(ring)
26
+ # Non-blocking single-char read; press 'q' to quit
27
+ ch = if IO.select([STDIN], nil, nil, 0.1) then STDIN.read_nonblock(1) rescue nil end
28
+ exit if ch == "q"
29
+ end
30
+ end
31
+ ensure
32
+ reader&.kill
33
+ end
34
+
35
+ def self.draw(ring)
36
+ cols, rows = IO.console.winsize.reverse # => [rows, cols]
37
+ rows ||= 24; cols ||= 80
38
+ system("printf", "\e[2J\e[H") # clear
39
+
40
+ # Split: left sessions, right timeline
41
+ left_w = [28, cols * 0.3].max.to_i
42
+ right_w = cols - left_w - 1
43
+ puts header("VSM Lens TUI — press 'q' to quit", cols)
44
+
45
+ # Sessions (left)
46
+ sessions = Hash.new { |h,k| h[k] = { count: 0, last: "" } }
47
+ ring.each do |ev|
48
+ sid = ev.dig(:meta, :session_id) or next
49
+ sessions[sid][:count] += 1
50
+ sessions[sid][:last] = ev[:ts]
51
+ end
52
+ sess_lines = sessions.sort_by { |_id, s| s[:last].to_s }.reverse.first(rows-3).map do |sid, s|
53
+ "#{sid[0,8]} #{s[:count].to_s.rjust(5)} #{s[:last]}"
54
+ end
55
+
56
+ puts box("Sessions", sess_lines, left_w)
57
+
58
+ # Timeline (right)
59
+ tl = ring.last(rows-3).map do |ev|
60
+ kind = ev[:kind].to_s.ljust(16)
61
+ sid = ev.dig(:meta, :session_id)&.slice(0,8) || "–"
62
+ txt = case ev[:payload]
63
+ when String then ev[:payload].gsub(/\s+/, " ")[0, right_w-40]
64
+ else ev[:payload].to_s[0, right_w-40]
65
+ end
66
+ "#{ev[:ts]} #{kind} #{sid} #{txt}"
67
+ end
68
+ puts box("Timeline", tl, right_w)
69
+ end
70
+
71
+ def self.header(text, width)
72
+ "\e[7m #{text.ljust(width-2)} \e[0m"
73
+ end
74
+
75
+ def self.box(title, lines, width)
76
+ out = +"+" + "-"*(width-2) + "+\n"
77
+ out << "| #{title.ljust(width-4)} |\n"
78
+ out << "+" + "-"*(width-2) + "+\n"
79
+ lines.each do |l|
80
+ out << "| #{l.ljust(width-4)} |\n"
81
+ end
82
+ out << "+" + "-"*(width-2) + "+\n"
83
+ out
84
+ end
85
+ end
86
+ end
87
+ end
88
+