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.
- checksums.yaml +4 -4
- data/.claude/settings.local.json +17 -0
- data/CLAUDE.md +134 -0
- data/README.md +675 -17
- data/Rakefile +1 -5
- data/examples/01_echo_tool.rb +51 -0
- data/examples/02_openai_streaming.rb +73 -0
- data/examples/02b_anthropic_streaming.rb +58 -0
- data/examples/02c_gemini_streaming.rb +60 -0
- data/examples/03_openai_tools.rb +106 -0
- data/examples/03b_anthropic_tools.rb +93 -0
- data/examples/03c_gemini_tools.rb +95 -0
- 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 +44 -0
- data/lib/vsm/capsule.rb +46 -0
- data/lib/vsm/cli.rb +78 -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 +80 -0
- data/lib/vsm/dsl_mcp.rb +36 -0
- data/lib/vsm/executors/fiber_executor.rb +10 -0
- data/lib/vsm/executors/thread_executor.rb +19 -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/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/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/message.rb +6 -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/observability/ledger.rb +25 -0
- data/lib/vsm/port.rb +11 -0
- data/lib/vsm/ports/chat_tty.rb +112 -0
- data/lib/vsm/ports/mcp/server_stdio.rb +101 -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 +172 -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 +43 -0
- data/llms.txt +322 -0
- data/mcp_update.md +162 -0
- metadata +93 -31
- 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,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,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,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,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,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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
+
|