vsm 0.0.1 → 0.1.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 (42) 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 +531 -17
  5. data/examples/01_echo_tool.rb +70 -0
  6. data/examples/02_openai_streaming.rb +73 -0
  7. data/examples/02b_anthropic_streaming.rb +61 -0
  8. data/examples/02c_gemini_streaming.rb +60 -0
  9. data/examples/03_openai_tools.rb +106 -0
  10. data/examples/03b_anthropic_tools.rb +96 -0
  11. data/examples/03c_gemini_tools.rb +95 -0
  12. data/lib/vsm/async_channel.rb +21 -0
  13. data/lib/vsm/capsule.rb +44 -0
  14. data/lib/vsm/drivers/anthropic/async_driver.rb +210 -0
  15. data/lib/vsm/drivers/family.rb +16 -0
  16. data/lib/vsm/drivers/gemini/async_driver.rb +149 -0
  17. data/lib/vsm/drivers/openai/async_driver.rb +202 -0
  18. data/lib/vsm/dsl.rb +50 -0
  19. data/lib/vsm/executors/fiber_executor.rb +10 -0
  20. data/lib/vsm/executors/thread_executor.rb +19 -0
  21. data/lib/vsm/homeostat.rb +19 -0
  22. data/lib/vsm/lens/event_hub.rb +73 -0
  23. data/lib/vsm/lens/server.rb +188 -0
  24. data/lib/vsm/lens/stats.rb +58 -0
  25. data/lib/vsm/lens/tui.rb +88 -0
  26. data/lib/vsm/lens.rb +79 -0
  27. data/lib/vsm/message.rb +6 -0
  28. data/lib/vsm/observability/ledger.rb +25 -0
  29. data/lib/vsm/port.rb +11 -0
  30. data/lib/vsm/roles/coordination.rb +49 -0
  31. data/lib/vsm/roles/governance.rb +9 -0
  32. data/lib/vsm/roles/identity.rb +11 -0
  33. data/lib/vsm/roles/intelligence.rb +168 -0
  34. data/lib/vsm/roles/operations.rb +33 -0
  35. data/lib/vsm/runtime.rb +18 -0
  36. data/lib/vsm/tool/acts_as_tool.rb +20 -0
  37. data/lib/vsm/tool/capsule.rb +12 -0
  38. data/lib/vsm/tool/descriptor.rb +16 -0
  39. data/lib/vsm/version.rb +1 -1
  40. data/lib/vsm.rb +33 -0
  41. data/llms.txt +322 -0
  42. metadata +67 -25
@@ -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
+
data/lib/vsm/lens.rb ADDED
@@ -0,0 +1,79 @@
1
+ # lib/vsm/lens.rb
2
+ # frozen_string_literal: true
3
+ require_relative "lens/event_hub"
4
+ require_relative "lens/server"
5
+ require_relative "lens/stats"
6
+ require_relative "lens/tui"
7
+
8
+ module VSM
9
+ module Lens
10
+ # Starts a tiny Rack server (Puma or WEBrick) and streams events via SSE.
11
+ # Returns the EventHub so other lenses (e.g., TUI) can also subscribe.
12
+ def self.attach!(capsule, host: "127.0.0.1", port: 9292, token: nil)
13
+ hub = EventHub.new
14
+ capsule.bus.subscribe { |msg| hub.publish(msg) }
15
+
16
+ require_relative "lens/stats"
17
+ stats = Stats.new(hub: hub, capsule: capsule)
18
+ server = Server.new(hub: hub, token: token, stats: stats)
19
+
20
+
21
+ Thread.new do
22
+ app = server.rack_app
23
+
24
+ # Prefer Puma if present:
25
+ if try_run_puma(app, host, port)
26
+ # ok
27
+ elsif try_run_webrick(app, host, port)
28
+ # ok
29
+ else
30
+ warn <<~MSG
31
+ vsm-lens: no Rack handler found. Install one of:
32
+ - `bundle add puma`
33
+ - or `bundle add webrick`
34
+ Then re-run with VSM_LENS=1.
35
+ MSG
36
+ end
37
+ end
38
+
39
+ hub
40
+ end
41
+
42
+ def self.try_run_puma(app, host, port)
43
+ begin
44
+ require "rack/handler/puma"
45
+ rescue LoadError
46
+ return false
47
+ end
48
+ Thread.new do
49
+ Rack::Handler::Puma.run(app, Host: host, Port: port, Silent: true)
50
+ end
51
+ true
52
+ rescue => e
53
+ warn "vsm-lens: Puma failed to start: #{e.class}: #{e.message}"
54
+ false
55
+ end
56
+
57
+ def self.try_run_webrick(app, host, port)
58
+ begin
59
+ require "webrick" # provide the server
60
+ require "rack/handler/webrick" # rack adapter (Rack 3 loads this if webrick gem is present)
61
+ rescue LoadError
62
+ return false
63
+ end
64
+ Thread.new do
65
+ Rack::Handler::WEBrick.run(
66
+ app,
67
+ Host: host, Port: port,
68
+ AccessLog: [],
69
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN)
70
+ )
71
+ end
72
+ true
73
+ rescue => e
74
+ warn "vsm-lens: WEBrick failed to start: #{e.class}: #{e.message}"
75
+ false
76
+ end
77
+ end
78
+ end
79
+
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ # kind: :user, :assistant_delta, :assistant, :tool_call, :tool_result, :plan, :policy, :audit, :confirm_request, :confirm_response
4
+ # path: optional addressing, e.g., [:airb, :operations, :fs]
5
+ Message = Struct.new(:kind, :payload, :path, :corr_id, :meta, keyword_init: true)
6
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "time"
4
+
5
+ module VSM
6
+ class Monitoring
7
+ LOG = File.expand_path(".vsm.log.jsonl", Dir.pwd)
8
+
9
+ def observe(bus)
10
+ bus.subscribe do |msg|
11
+ event = {
12
+ ts: Time.now.utc.iso8601,
13
+ kind: msg.kind,
14
+ path: msg.path,
15
+ corr_id: msg.corr_id,
16
+ meta: msg.meta
17
+ }
18
+ File.open(LOG, "a") { |f| f.puts(event.to_json) } rescue nil
19
+ end
20
+ end
21
+
22
+ def handle(*) = false
23
+ end
24
+ end
25
+
data/lib/vsm/port.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ class Port
4
+ def initialize(capsule:) = (@capsule = capsule)
5
+ def ingress(_event) = raise NotImplementedError
6
+ def egress_subscribe = @capsule.bus.subscribe { |m| render_out(m) if should_render?(m) }
7
+ def should_render?(message) = [:assistant, :tool_result].include?(message.kind)
8
+ def render_out(_message) = nil
9
+ end
10
+ end
11
+
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ class Coordination
4
+ def initialize
5
+ @queue = []
6
+ @floor_by_session = nil
7
+ @turn_waiters = {} # session_id => Async::Queue
8
+ end
9
+
10
+ def observe(bus)
11
+ # Note: staging is handled by the capsule loop, not by subscription
12
+ # This method exists for consistency but doesn't auto-stage messages
13
+ end
14
+
15
+ def stage(message) = (@queue << message)
16
+
17
+ def drain(bus)
18
+ return if @queue.empty?
19
+ @queue.sort_by! { order(_1) }
20
+ @queue.shift(@queue.size).each do |msg|
21
+ yield msg
22
+ if msg.kind == :assistant && (sid = msg.meta&.dig(:session_id)) && @turn_waiters[sid]
23
+ @turn_waiters[sid].enqueue(:done)
24
+ end
25
+ end
26
+ end
27
+
28
+ def grant_floor!(session_id) = (@floor_by_session = session_id)
29
+
30
+ def wait_for_turn_end(session_id)
31
+ q = (@turn_waiters[session_id] ||= Async::Queue.new)
32
+ q.dequeue
33
+ end
34
+
35
+ def order(m)
36
+ base =
37
+ case m.kind
38
+ when :user then 0
39
+ when :tool_result then 1
40
+ when :plan then 2
41
+ when :assistant_delta then 3
42
+ when :assistant then 4
43
+ else 9
44
+ end
45
+ sid = m.meta&.dig(:session_id)
46
+ sid == @floor_by_session ? base - 1 : base
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ class Governance
4
+ def observe(bus); end
5
+ def enforce(message)
6
+ yield message
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ module VSM
3
+ class Identity
4
+ def initialize(identity:, invariants: [])
5
+ @identity, @invariants = identity, invariants
6
+ end
7
+ def observe(bus); end
8
+ def handle(message, bus:, **) = false
9
+ def alert(message); end
10
+ end
11
+ end