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.
- checksums.yaml +4 -4
- data/.claude/settings.local.json +17 -0
- data/CLAUDE.md +134 -0
- data/README.md +531 -17
- data/examples/01_echo_tool.rb +70 -0
- data/examples/02_openai_streaming.rb +73 -0
- data/examples/02b_anthropic_streaming.rb +61 -0
- data/examples/02c_gemini_streaming.rb +60 -0
- data/examples/03_openai_tools.rb +106 -0
- data/examples/03b_anthropic_tools.rb +96 -0
- data/examples/03c_gemini_tools.rb +95 -0
- data/lib/vsm/async_channel.rb +21 -0
- data/lib/vsm/capsule.rb +44 -0
- data/lib/vsm/drivers/anthropic/async_driver.rb +210 -0
- data/lib/vsm/drivers/family.rb +16 -0
- data/lib/vsm/drivers/gemini/async_driver.rb +149 -0
- data/lib/vsm/drivers/openai/async_driver.rb +202 -0
- data/lib/vsm/dsl.rb +50 -0
- data/lib/vsm/executors/fiber_executor.rb +10 -0
- data/lib/vsm/executors/thread_executor.rb +19 -0
- data/lib/vsm/homeostat.rb +19 -0
- data/lib/vsm/lens/event_hub.rb +73 -0
- data/lib/vsm/lens/server.rb +188 -0
- data/lib/vsm/lens/stats.rb +58 -0
- data/lib/vsm/lens/tui.rb +88 -0
- data/lib/vsm/lens.rb +79 -0
- data/lib/vsm/message.rb +6 -0
- data/lib/vsm/observability/ledger.rb +25 -0
- data/lib/vsm/port.rb +11 -0
- data/lib/vsm/roles/coordination.rb +49 -0
- data/lib/vsm/roles/governance.rb +9 -0
- data/lib/vsm/roles/identity.rb +11 -0
- data/lib/vsm/roles/intelligence.rb +168 -0
- data/lib/vsm/roles/operations.rb +33 -0
- data/lib/vsm/runtime.rb +18 -0
- data/lib/vsm/tool/acts_as_tool.rb +20 -0
- data/lib/vsm/tool/capsule.rb +12 -0
- data/lib/vsm/tool/descriptor.rb +16 -0
- data/lib/vsm/version.rb +1 -1
- data/lib/vsm.rb +33 -0
- data/llms.txt +322 -0
- 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
+
|
data/lib/vsm/lens/tui.rb
ADDED
@@ -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
|
+
|
data/lib/vsm/message.rb
ADDED
@@ -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,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
|