asterism 0.9.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c3c929e3018c538fab3983c9fffce4fd997bfa8b37cad43fbaa6c0eb5890d65
4
- data.tar.gz: b4fc43d2d5d286139eb700c5711786827aab41aa8a7628686b45270a321f1807
3
+ metadata.gz: a3f9045afdf176405f969bbf97ec703a00f6dc97b777224dfe71d449ebfcda29
4
+ data.tar.gz: '0748cc260662719332fa6a17030577ceb55ca53d137a9723ad100214c0518d72'
5
5
  SHA512:
6
- metadata.gz: f971a84780f4a1ce1e24554530201f6bccaba7f8de6ec03c5b2dc6742c6c370e2567af5c82e4609fd82f582d32cbe314f82bc5c60d5815b5c33e805980b14a58
7
- data.tar.gz: c355695ad9b52f7513f791191eeae8ded62011cdc011c50d1e921f6fa1e58190f491cc3adc0b367fa0087def3cfd6cd03729a43143b19f608b3f3a5603879f3c
6
+ metadata.gz: afc211c874d21e5d73bbb94d0e5bd870bd8aa3d840ccd57f7aaf072d9ecebb0bae72abd7a9d3e9fdb742498cbb3eb3220fa0a35fd213a9ce727f1e65ca8b738c
7
+ data.tar.gz: e12f369efedb87918e86d1eb70346e0da7404d08f5b9fdb37b8ce244be3bc90c85cabf9e9d548a928ad48f683e26574a5062eaf55cba9167361cbe05735301ab
data/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ the earlier ones are only in this repository. From 0.4.0 the two Zenoh
6
6
  bindings (asterism-zenoh, picoruby-asterism-zenoh) carry the same minor
7
7
  version (a patch release of one may come without the other).
8
8
 
9
+ ## Unreleased
10
+
11
+ ## 0.11.0
12
+
13
+ - Needs CRuby 3.4 or newer (`required_ruby_version >= 3.4`; 3.2 is past its end of life). CI runs Ruby 3.4 and 4.0.
14
+
15
+ ## 0.10.0
16
+
17
+ The command line and its MCP bridge only; the library and the wire are as
18
+ in 0.9.0.
19
+
20
+ - `asterism mcp` has a tool `asterism_values`: the latest value of each
21
+ key the applications put on (`asterism/<node>/<app>/<object>/<more>`,
22
+ e.g. a game table's state), as the server received them since it
23
+ started (it subscribes to `asterism/**` from the start). Arguments
24
+ `pattern` (`*`, `**`; default every key) and `wait` (seconds, at most
25
+ 30: for the first put that matches when none has come yet). Each value
26
+ has its time, its age, its size and whether its application is still on
27
+ the network (`app`: `alive` / `gone` / `restarted`). Limits: 1000 keys
28
+ and 16 MiB kept (the oldest forgotten first), a put over 256 KiB as its
29
+ size only, a value's JSON cut at 8192 bytes in an answer
30
+ (`truncated`), 100 keys an answer (`more`). A viewer's certificate is
31
+ enough. README "MCP".
32
+ - `asterism object get PATH [KEY]`: the next value the application puts
33
+ there (`--timeout`, 5 s). `asterism object echo PATH [KEY]`: the values
34
+ as they come (the text: a key's value whole, then what changed;
35
+ `--raw`, `--once`, `--duration`). `-o json` / `-o yaml`, and Tab
36
+ completion of `PATH`. Both say so when nothing came (not readable, or
37
+ not changed).
38
+ - The other tools, commands and their output (`--json` too) are as they
39
+ were; `asterism --help`, `asterism object` and the MCP server's
40
+ instructions mention the new ones.
41
+
9
42
  ## 0.9.0
10
43
 
11
44
  The command line only; the library and the wire are as in 0.8.0 (0.8.1
data/README.md CHANGED
@@ -85,7 +85,7 @@ time is taken, on both APIs and every Ruby; giving one twice raises
85
85
 
86
86
  ## Using it
87
87
 
88
- CRuby (needs CRuby 3.2+ and a C compiler):
88
+ CRuby (needs CRuby 3.4+ and a C compiler):
89
89
 
90
90
  ```
91
91
  gem install asterism # also installs asterism-zenoh (>= 0.4.1, < 1.0) and msgpack
@@ -101,7 +101,7 @@ build but not yet tested. To install from the repositories instead:
101
101
 
102
102
  ```
103
103
  (cd ../asterism-zenoh && rake gem) && rake gem
104
- gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.9.0.gem
104
+ gem install --local ../asterism-zenoh/pkg/asterism-zenoh-0.4.1.gem pkg/asterism-0.10.0.gem
105
105
  ```
106
106
 
107
107
  From the working trees, without installing:
@@ -427,6 +427,8 @@ asterism app info games/games # its status and its objects
427
427
  asterism object list 'games/games/*' # node/app/object, a line each
428
428
  asterism object info games/games/othello1 # its description and its methods
429
429
  asterism object find join # the objects that have join (* as a wildcard)
430
+ asterism object get games/games/othello1 state # the next value the application puts there
431
+ asterism object echo games/games/othello1 state # the values as they come (what changed)
430
432
  asterism status get games/games # the status now
431
433
  asterism status echo games/games # the status, then each change (--once: now, once)
432
434
  asterism status hz games/games # how often it changes
@@ -553,6 +555,21 @@ $ asterism watch
553
555
  only what changed: `a.b 0→1`, `+a.c=…` (a key came), `-a.d` (a key
554
556
  went). `--timeout` (2.0 s) is each get's and call's limit, `--settle`
555
557
  (1.5 s) how long it waits for the tokens after connecting.
558
+ - **object get / echo PATH [KEY]** read what an application puts on its
559
+ own keys, `asterism/<node>/<app>/<object>/<more>` (docs/protocol.md 2.2;
560
+ a game table puts its state on `<table>/state`). `PATH` is
561
+ `node/app/object` (`*` for a part), `KEY` the rest (`*` and `**` as whole
562
+ parts; `**` by default). A put is kept nowhere and a get has no answer
563
+ for it, so both wait for puts: `object get` prints the next one and ends
564
+ (`--timeout`, 5 s; an application puts when its value changes, so a quiet
565
+ one gives nothing), `object echo` prints them as they come: a key's
566
+ value whole the first time, then only what changed in it, as
567
+ `status echo` does (`--raw`: each value whole; `--once`: the first put;
568
+ `--duration`). The value is MessagePack (Asterism's own) or JSON as it
569
+ is, other text as a string, bytes as their count; `-o json` gives each
570
+ put as `{"key", "t", "bytes", "format", "value"}`. When nothing comes
571
+ they say so: the router does not let this node read the key (a role
572
+ needs `read` on it), or nothing changed.
556
573
  - **status hz** counts the changes of the status (a put of the same status
557
574
  again is not one) and prints, every `--period` (1 s) in which a change
558
575
  came, the average rate and the intervals' min, max and standard
@@ -603,6 +620,7 @@ claude mcp add asterism -- asterism mcp --credentials admin.tar --allow 'games/o
603
620
  | `asterism_nodes` | the nodes, their profiles, applications (with their statuses) and objects |
604
621
  | `asterism_objects` | the objects and their methods (meta); `callable_as` names the tool for an allowed method |
605
622
  | `asterism_status` | the applications' statuses (`node` / `app` narrow it) |
623
+ | `asterism_values` | the latest value of each key the applications put on (`asterism/<node>/<app>/<object>/<more>`, e.g. a game table's state), as this server received them since it started; `pattern` (`node/app/object/more`, `*` and `**`; default `**`) and `wait` (seconds, at most 30) |
606
624
  | `<node>.<app>.<object>.<method>` | one per method an `--allow` pattern names, made from the meta: the arguments' names it tells are the inputs and its doc the description (with the object's doc after it); without names, arity n gives the inputs `arg1` .. `argN` (any JSON value), arity -1 an array `args`; `kwargs` is an object of keyword arguments. The answer is `{"value": ...}`, or an error with its kind |
607
625
 
608
626
  - `--allow PATTERN` (repeatable): `[node/][app/]object.method`, `*` within
@@ -617,6 +635,38 @@ claude mcp add asterism -- asterism mcp --credentials admin.tar --allow 'games/o
617
635
  gives the MCP client its own seat. `--app` names this application (a
618
636
  game host that follows its players by the application `game` sees an
619
637
  MCP client started with `--app game` come and go).
638
+ - **asterism_values**: from its start the server subscribes to
639
+ `asterism/**` (the applications' own keys; `@profile` and `@status` are
640
+ not under it) and keeps the last value of each key with the time it
641
+ came. A put comes only when a value changes and is kept nowhere else, so
642
+ a key that has not been put since the server started is not there:
643
+ `wait` waits (at most 30 s) for the first put that matches when none has
644
+ come yet. Each value has `key`, `value`, `t`, `age_s` (seconds since it
645
+ came), `bytes`, `format` and `app`: `alive`, `gone` (the application is
646
+ not on the network now; the value is its last one, not the present) or
647
+ `restarted` (it left and came back after this value). A value whose JSON
648
+ is longer than 8192 bytes is cut (`truncated: true`, the value the start
649
+ of its JSON text); the server keeps at most 1000 keys and 16 MiB (the
650
+ key received longest ago is forgotten first; `forgotten` counts them), a
651
+ single put over 256 KiB as its size only, and an answer lists at most
652
+ 100 keys (`more`). Nothing that matches: `note` says so (not put since
653
+ the start, or the router does not let the server read it). A console's
654
+ viewer role (`read` / `watch` on `asterism/**`) is enough.
655
+
656
+ Showing an othello table to an AI assistant (the game host puts the
657
+ table on `games/games/othello1/state` after every move):
658
+
659
+ ```
660
+ claude mcp add asterism -- asterism mcp --credentials viewer.tar
661
+ > Watch the othello game on games/games/othello1 and tell me who is winning.
662
+ (the assistant calls asterism_values with pattern games/games/othello1/state, wait 30)
663
+ {"since":"2026-10-20T02:16:50.191Z","values":[{"key":"games/games/othello1/state",
664
+ "t":"2026-10-20T02:16:58.583Z","age_s":0.4,"app":"alive","bytes":181,"format":"msgpack",
665
+ "value":{"v":15,"t":"othello1","st":"play","nm":["CPU","Alice"],"sk":[2,1],...,
666
+ "bd":"..W.......W.......WBWWW...WBB....W.BBB..........................",
667
+ "tn":0,"n":10,"last":"c1","lg":"b1b2e2f2g2b3h3b4a6","cnt":[6,8]}}]}
668
+ ```
669
+
620
670
  - An object that describes its methods at `expose` (`args:` and `doc:`,
621
671
  "Describing the methods" below) gives the AI assistant the names and the
622
672
  texts; without them it sees only the number of arguments.
@@ -82,7 +82,8 @@ module Asterism
82
82
  case [key, i]
83
83
  when ["node info", 0] then nodes
84
84
  when ["app info", 0], ["status get", 0], ["status echo", 0], ["status hz", 0] then staged(cur, apps, 2)
85
- when ["object list", 0], ["object info", 0], ["objects", 0], ["call", 0] then staged(cur, objects, 3)
85
+ when ["object list", 0], ["object info", 0], ["object get", 0], ["object echo", 0], ["objects", 0], ["call", 0]
86
+ staged(cur, objects, 3)
86
87
  when ["ping", 0], ["echo", 0] then (cur.include?("/") ? [] : nodes) + staged(cur, objects, 3)
87
88
  when ["object find", 0] then @source.all_methods || []
88
89
  when ["call", 1] then methods_of(args[0])
@@ -9,6 +9,10 @@
9
9
  # and objects (always; reads)
10
10
  # asterism_objects the objects and their methods (meta) (always; reads)
11
11
  # asterism_status the applications' statuses (always; reads)
12
+ # asterism_values the latest value of each key the applications put
13
+ # on (asterism/<node>/<app>/<object>/<more>), as this
14
+ # server received them since it started (always;
15
+ # reads; lib/asterism/cli/values.rb)
12
16
  # <node>.<app>.<object>.<method>
13
17
  # one per method that an --allow pattern names, made
14
18
  # from the object's meta: the arguments' names the
@@ -30,7 +34,8 @@ module Asterism
30
34
  These tools look at an Asterism network: Ruby programs and boards that expose objects to each other over
31
35
  Zenoh. Paths are <node>/<app>/<object>. asterism_nodes lists the nodes and their applications (with the
32
36
  status each application tells about itself, a JSON object of its own), asterism_objects the objects and
33
- their methods, asterism_status the statuses. The other tools call one method each, on the object their
37
+ their methods, asterism_status the statuses, asterism_values the latest values the applications put on
38
+ their own keys (a game table's state). The other tools call one method each, on the object their
34
39
  name says (<node>.<app>.<object>.<method>); only the methods this server was started to allow are there.
35
40
  A call's answer is {"value": ...}; an error says its kind (raised, not_exposed, arguments, ...).
36
41
  TEXT
@@ -43,13 +48,17 @@ module Asterism
43
48
  @call_tools = {} # tool name => [path, method, arity, meta entry]
44
49
  @signature = nil
45
50
  @lock = Mutex.new
51
+ @values = Values::Store.new(alive: ->(app) { !::Asterism.apps(app).empty? })
46
52
  end
47
53
 
54
+ attr_reader :values
55
+
48
56
  def run
49
57
  @server = ::MCP::Server.new(name: "asterism", version: ::Asterism::VERSION, instructions: INSTRUCTIONS,
50
58
  tools: read_tools)
51
59
  @transport = ::MCP::Server::Transports::StdioTransport.new(@server)
52
60
  @server.transport = @transport
61
+ remember_values
53
62
  refresh
54
63
  @log.puts "asterism mcp: #{@net.node_id}/#{@net.app}; #{@call_tools.size} call tools" \
55
64
  "#{@allow.empty? ? ' (no --allow: read-only)' : ''}"
@@ -59,6 +68,16 @@ module Asterism
59
68
  @refresher&.kill
60
69
  end
61
70
 
71
+ # From now on, the puts on the applications' own keys (asterism/**
72
+ # does not match @profile and @status), and the applications that
73
+ # leave (their values are marked).
74
+ def remember_values
75
+ @net.session.subscribe("#{Values::PREFIX}**", depth: 1024) { |s| @values.put(s.key, s.payload) }
76
+ @net.session.liveliness_watch("#{Values::PREFIX}*/*", depth: 1024) do |key, alive|
77
+ @values.left(key.delete_prefix(Values::PREFIX)) unless alive
78
+ end
79
+ end
80
+
62
81
  # ------------------------------------------------------------ the list
63
82
 
64
83
  def refresh_loop
@@ -153,10 +172,46 @@ module Asterism
153
172
  (node.nil? || node == n) && (app.nil? || app == a)
154
173
  end
155
174
  end
175
+ end,
176
+ ::MCP::Tool.define(
177
+ name: "asterism_values",
178
+ description: VALUES_DOC,
179
+ input_schema: {
180
+ properties: {
181
+ pattern: { type: "string",
182
+ description: "keys as node/app/object/more, with * (one part) and ** (any parts); " \
183
+ "default ** (every key). A key always has a part after the object: " \
184
+ "games/games/othello1/** or games/*/*/state, not games/games/othello1" },
185
+ wait: { type: "number",
186
+ description: "seconds (at most #{Values::Store::MAX_WAIT}) to wait for the first put " \
187
+ "that matches when none has come yet; default 0 (answer now)" }
188
+ }
189
+ },
190
+ annotations: { read_only_hint: true }
191
+ ) do |server_context:, pattern: "**", wait: 0|
192
+ bridge.answer { bridge.values.answer(pattern, wait: wait) }
193
+ rescue ArgumentError => e
194
+ bridge.error(e.message)
156
195
  end
157
196
  ]
158
197
  end
159
198
 
199
+ VALUES_DOC = "The latest value each application put on its own keys (asterism/<node>/<app>/<object>/<more>: " \
200
+ "a game table's state, a log), as this server received them: {\"since\", \"values\": " \
201
+ "[{\"key\", \"value\", \"t\", \"age_s\", \"app\", \"bytes\", \"format\"}]}. " \
202
+ "A put comes only when the value changes, and this server remembers only what it received " \
203
+ "since it started (since): a key that has not been put since then is not listed. Give wait " \
204
+ "(seconds) to wait for the first put that matches when none has come yet (a quiet table, " \
205
+ "just after the start). age_s: seconds since it came. app: alive; gone (the application is " \
206
+ "not on the network now: this is its last value, not the present); restarted (it left and " \
207
+ "came back after this value). value: MessagePack or JSON as it is, text as a string; a " \
208
+ "value whose JSON is longer than #{Values::Store::VALUE_CUT} bytes is cut (truncated: " \
209
+ "true, value: the start of its JSON text). It keeps at most #{Values::Store::MAX_KEYS} keys " \
210
+ "(those received longest ago are forgotten first) and lists at most " \
211
+ "#{Values::Store::MAX_ANSWER} (more: how many it left out). Nothing came: the note says so " \
212
+ "(not put since the start, or the router does not let this server read it). The statuses " \
213
+ "(@status) are asterism_status's."
214
+
160
215
  def objects(pattern)
161
216
  paths = @survey.objects(pattern)
162
217
  metas = @survey.metas(paths)
@@ -117,6 +117,68 @@ module Asterism
117
117
  0
118
118
  end
119
119
 
120
+ # The next value the application puts on its own keys (PATH/KEY: a put
121
+ # is kept nowhere, so it waits for one; --timeout, 5.0 s).
122
+ def cmd_object_get(rest)
123
+ pattern = put_pattern!(rest, "object get")
124
+ wait = @opts[:put_timeout] || 5.0
125
+ raise ArgumentError, "--timeout: more than 0" unless wait.positive?
126
+ code = 0
127
+ connect do |net, _sv|
128
+ q = Queue.new
129
+ net.session.subscribe(Values.key_expr(pattern), depth: 16) { |s| q << [s.key, s.payload, Time.now] }
130
+ got = q.pop(timeout: wait)
131
+ if got.nil?
132
+ @err.puts nothing_came(pattern, wait)
133
+ code = 1
134
+ next
135
+ end
136
+ e = Values.entry(*got)
137
+ if structured?
138
+ emit(e)
139
+ else
140
+ put_lines(got[2], e, nil).each { @out.puts _1 }
141
+ end
142
+ end
143
+ code
144
+ end
145
+
146
+ # The values the application puts on its own keys, as they come: the
147
+ # text shows a key's value whole the first time (and always with
148
+ # --raw), then what changed; -o json / yaml each put whole.
149
+ def cmd_object_echo(rest)
150
+ pattern = put_pattern!(rest, "object echo")
151
+ code = 0
152
+ connect do |net, _sv|
153
+ q = Queue.new
154
+ net.session.subscribe(Values.key_expr(pattern), depth: 256) { |s| q << [s.key, s.payload, Time.now] }
155
+ seen = 0
156
+ last = {}
157
+ until_stopped(net) do |left|
158
+ got = q.pop(timeout: [left || 0.2, 0.2].min)
159
+ next unless got
160
+ seen += 1
161
+ e = Values.entry(*got)
162
+ if structured?
163
+ emit_line(e)
164
+ else
165
+ whole = @opts[:raw] || !last.key?(e["key"])
166
+ old = last[e["key"]]
167
+ last[e["key"]] = e["value"]
168
+ lines = whole ? put_lines(got[2], e, nil) : put_lines(got[2], e, old, again: true)
169
+ lines&.each { @out.puts _1 }
170
+ end
171
+ @out.flush
172
+ break if @opts[:once]
173
+ end
174
+ if seen.zero?
175
+ @err.puts nothing_came(pattern, @opts[:duration])
176
+ code = 1 if @opts[:once]
177
+ end
178
+ end
179
+ code
180
+ end
181
+
120
182
  # ------------------------------------------------------------ status
121
183
 
122
184
  def cmd_status_get(rest)
@@ -314,6 +376,41 @@ module Asterism
314
376
  path
315
377
  end
316
378
 
379
+ # PATH (node/app/object, * for a part) and KEY (the rest, ** by
380
+ # default) as one pattern.
381
+ def put_pattern!(rest, what)
382
+ path = rest.shift
383
+ key = rest.shift || "**"
384
+ parts = path.to_s.split("/", -1)
385
+ unless parts.size == 3 && parts.none? { _1.empty? || (_1.include?("*") && _1 != "*") }
386
+ raise ArgumentError, "#{what}: PATH is node/app/object (* for a part), then KEY (asterism #{what} " \
387
+ "games/games/othello1 state)"
388
+ end
389
+ Values.check_pattern!("#{path}/#{key}")
390
+ end
391
+
392
+ def nothing_came(pattern, seconds)
393
+ "asterism: nothing came on #{pattern}#{seconds ? " in #{format('%g', seconds)} s" : ''} " \
394
+ "(the router does not let this node read it, or it has not changed)"
395
+ end
396
+
397
+ # The lines of one put received at t: the time, the key and the value
398
+ # whole; again (the key's value before was old): a line for each thing
399
+ # that changed in a Hash, the value whole otherwise (nil: no change).
400
+ def put_lines(t, e, old, again: false)
401
+ head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} "
402
+ v = e["value"]
403
+ if again && old.is_a?(Hash) && v.is_a?(Hash)
404
+ changes = Text.status_diff(old, v)
405
+ return nil if changes.empty?
406
+ return changes.map { |c| "#{head}#{paint(e['key'], :object)} #{change_text(c)}" }
407
+ end
408
+ return nil if again && old == v
409
+ first = "#{head}#{paint(e['key'], :object)}"
410
+ return ["#{first} #{Text.inline(v)}"] unless v.is_a?(Hash)
411
+ [first, *Text.status_lines(v, style.width, Text.width(head))]
412
+ end
413
+
317
414
  def no_status(path)
318
415
  "asterism: no status from #{path} (not there, no status, or no answer in time)"
319
416
  end
@@ -0,0 +1,216 @@
1
+ # The values the applications put on their own keys
2
+ # (asterism/<node>/<app>/<object>/<more>, docs/protocol.md 2.2: a game
3
+ # table's state, a log), for `asterism object get / echo` and the MCP
4
+ # bridge's asterism_values. A put is not kept anywhere and a get has no
5
+ # answer for it (an application's queryable answers only the key shapes of
6
+ # 4.3), so the latest value is only what a subscriber received and kept:
7
+ # Values::Store keeps it, a key at a time, within limits.
8
+ require "json"
9
+
10
+ module Asterism
11
+ class CLI
12
+ module Values
13
+ PREFIX = "asterism/"
14
+ # Characters a part of a key cannot have (2.1); * and ** are whole
15
+ # parts.
16
+ BAD = /[*$?#]/
17
+
18
+ module_function
19
+
20
+ # The pattern (node/app/object/more, * and ** as whole parts) checked;
21
+ # ArgumentError otherwise.
22
+ def check_pattern!(pattern)
23
+ parts = pattern.to_s.split("/", -1)
24
+ bad = parts.empty? || parts.any? do |p|
25
+ p.empty? || p.start_with?("@") || (p.match?(BAD) && !%w[* **].include?(p))
26
+ end
27
+ raise ArgumentError, "#{pattern.inspect}: a key's parts are names, * (one part) or ** (any parts)" if bad
28
+ pattern
29
+ end
30
+
31
+ # The Zenoh key expression of a pattern.
32
+ def key_expr(pattern)
33
+ "#{PREFIX}#{pattern}"
34
+ end
35
+
36
+ def match?(pattern, key)
37
+ ::Asterism.glob?(pattern, key)
38
+ end
39
+
40
+ # [value, format] of a payload: MessagePack (Asterism's own, 3.1);
41
+ # else JSON text; else UTF-8 text; else "(n bytes)". The value is
42
+ # what JSON can hold (bytes inside as "(n bytes)").
43
+ def decode(payload)
44
+ bytes = payload.to_s.b
45
+ return [nil, "empty"] if bytes.empty?
46
+ begin
47
+ return [json_safe(::MessagePack.unpack(bytes)), "msgpack"]
48
+ rescue StandardError
49
+ nil
50
+ end
51
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
52
+ return ["(#{bytes.bytesize} bytes)", "bytes"] unless text.valid_encoding?
53
+ begin
54
+ [json_safe(JSON.parse(text)), "json"]
55
+ rescue JSON::ParserError
56
+ [text, "text"]
57
+ end
58
+ end
59
+
60
+ def json_safe(v)
61
+ case v
62
+ when Hash then v.to_h { |k, x| [k.is_a?(String) ? k : k.to_s, json_safe(x)] }
63
+ when Array then v.map { json_safe(_1) }
64
+ when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
65
+ when Float then v.finite? ? v : v.to_s
66
+ when Integer, true, false, nil then v
67
+ when Symbol then v.to_s
68
+ else v.inspect
69
+ end
70
+ end
71
+
72
+ def time(t)
73
+ t.getutc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
74
+ end
75
+
76
+ # One put as the command line and the MCP tool show it: {"key"
77
+ # (without asterism/), "t", "bytes", "format", "value"}.
78
+ def entry(key, payload, t = Time.now)
79
+ value, format = decode(payload)
80
+ { "key" => key.delete_prefix(PREFIX), "t" => time(t), "bytes" => payload.to_s.bytesize,
81
+ "format" => format, "value" => value }
82
+ end
83
+
84
+ # The latest put of each key, as received since it was made: at most
85
+ # max_keys keys and max_total payload bytes (the key received
86
+ # longest ago is forgotten first); a payload over max_bytes is kept
87
+ # as its size only. Thread safe; wait blocks until a key matches.
88
+ class Store
89
+ MAX_KEYS = 1000
90
+ MAX_BYTES = 256 * 1024
91
+ MAX_TOTAL = 16 * 1024 * 1024
92
+ # The JSON text of a value longer than this is cut in an answer.
93
+ VALUE_CUT = 8192
94
+ # The most keys an answer lists (the rest counted as "more").
95
+ MAX_ANSWER = 100
96
+ # The longest wait an answer may ask for (seconds).
97
+ MAX_WAIT = 30
98
+
99
+ attr_reader :since, :forgotten
100
+
101
+ # alive: given "<node>/<app>", true when the application is on the
102
+ # network now. clock: the monotonic seconds (for the tests).
103
+ def initialize(max_keys: MAX_KEYS, max_bytes: MAX_BYTES, max_total: MAX_TOTAL, alive: nil,
104
+ clock: nil, now: nil)
105
+ @max_keys = max_keys
106
+ @max_bytes = max_bytes
107
+ @max_total = max_total
108
+ @alive = alive || ->(_app) { true }
109
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
110
+ @now = now || -> { Time.now }
111
+ @since = @now.call
112
+ @keys = {} # key => record, the oldest received first
113
+ @total = 0
114
+ @forgotten = 0
115
+ @lock = Mutex.new
116
+ @cond = ConditionVariable.new
117
+ end
118
+
119
+ # A put received (key with or without asterism/).
120
+ def put(key, payload)
121
+ key = key.delete_prefix(PREFIX)
122
+ payload = payload.to_s
123
+ size = payload.bytesize
124
+ rec = if size > @max_bytes
125
+ { "value" => "(#{size} bytes: too large to keep)", "format" => "dropped", "kept" => false }
126
+ else
127
+ v, f = Values.decode(payload)
128
+ { "value" => v, "format" => f }
129
+ end
130
+ rec.merge!("key" => key, "bytes" => size, "t" => @now.call, "mono" => @clock.call, "left" => false)
131
+ @lock.synchronize do
132
+ old = @keys.delete(key)
133
+ @total -= old["kept"] == false ? 0 : old["bytes"] if old
134
+ @keys[key] = rec
135
+ @total += size unless rec["kept"] == false
136
+ while @keys.size > @max_keys || (@total > @max_total && @keys.size > 1)
137
+ _, gone = @keys.shift
138
+ @total -= gone["bytes"] unless gone["kept"] == false
139
+ @forgotten += 1
140
+ end
141
+ @cond.broadcast
142
+ end
143
+ self
144
+ end
145
+
146
+ # The application "<node>/<app>" left the network: its values are
147
+ # from before (kept, marked).
148
+ def left(app)
149
+ prefix = "#{app}/"
150
+ @lock.synchronize { @keys.each_value { |r| r["left"] = true if r["key"].start_with?(prefix) } }
151
+ self
152
+ end
153
+
154
+ def size
155
+ @lock.synchronize { @keys.size }
156
+ end
157
+
158
+ def keys
159
+ @lock.synchronize { @keys.keys }
160
+ end
161
+
162
+ # The records whose keys match (sorted by key); with wait (seconds),
163
+ # until one matches or the time is up.
164
+ def match(pattern, wait: 0)
165
+ deadline = @clock.call + wait.to_f
166
+ @lock.synchronize do
167
+ loop do
168
+ got = @keys.values.select { Values.match?(pattern, _1["key"]) }
169
+ return got.sort_by { _1["key"] } unless got.empty?
170
+ left = deadline - @clock.call
171
+ return [] if left <= 0
172
+ @cond.wait(@lock, left)
173
+ end
174
+ end
175
+ end
176
+
177
+ # What asterism_values answers: {"since", "values" => [{"key", "t",
178
+ # "age_s", "app", "bytes", "format", "value"(, "truncated")}]}, with
179
+ # "more" when more keys matched than an answer lists, "forgotten"
180
+ # when the limits made it forget keys, and "note" when nothing came.
181
+ def answer(pattern = "**", wait: 0)
182
+ Values.check_pattern!(pattern)
183
+ wait = wait.to_f.clamp(0, MAX_WAIT)
184
+ recs = match(pattern, wait: wait)
185
+ now = @clock.call
186
+ alive = {}
187
+ out = { "since" => Values.time(@since) }
188
+ out["values"] = recs.first(MAX_ANSWER).map do |r|
189
+ app = r["key"].split("/")[0, 2].join("/")
190
+ here = alive.key?(app) ? alive[app] : (alive[app] = @alive.call(app) ? true : false)
191
+ h = { "key" => r["key"], "t" => Values.time(r["t"]), "age_s" => (now - r["mono"]).round(1),
192
+ "app" => here ? (r["left"] ? "restarted" : "alive") : "gone", "bytes" => r["bytes"],
193
+ "format" => r["format"] }
194
+ text = JSON.generate(r["value"])
195
+ if text.bytesize > VALUE_CUT
196
+ h["value"] = "#{text.byteslice(0, VALUE_CUT).scrub('')}…"
197
+ h["truncated"] = true
198
+ else
199
+ h["value"] = r["value"]
200
+ end
201
+ h["truncated"] = true if r["kept"] == false
202
+ h
203
+ end
204
+ out["more"] = recs.size - MAX_ANSWER if recs.size > MAX_ANSWER
205
+ out["forgotten"] = @forgotten if @forgotten.positive?
206
+ if recs.empty?
207
+ out["note"] = "nothing came for #{pattern}#{wait.positive? ? " in #{format('%g', wait)} s" : ''}: " \
208
+ "it has not been put since this server started (puts come only when a value " \
209
+ "changes), or the router does not let this server read it"
210
+ end
211
+ out
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
data/lib/asterism/cli.rb CHANGED
@@ -8,6 +8,7 @@
8
8
  # asterism object list 'games/games/*' / object info games/games/othello1
9
9
  # asterism object find join # the objects that have join
10
10
  # asterism status get games/games # echo: as it changes, hz: how often
11
+ # asterism object get games/games/othello1 state # the next put there
11
12
  # asterism call games/games/othello1 view '"<token>"'
12
13
  # asterism ping / watch / tree / mcp # as before
13
14
  # asterism completion bash # the shell's Tab completion
@@ -30,6 +31,7 @@ require_relative "cli/allow"
30
31
  require_relative "cli/text"
31
32
  require_relative "cli/hz"
32
33
  require_relative "cli/complete"
34
+ require_relative "cli/values"
33
35
 
34
36
  module Asterism
35
37
  class CLI
@@ -46,7 +48,11 @@ module Asterism
46
48
  "object" => {
47
49
  "list" => ["[PATTERN]", "the objects, node/app/object a line (PATTERN: * and ** for whole parts)"],
48
50
  "info" => ["PATH", "one object (node/app/object): its description and its methods"],
49
- "find" => ["METHOD", "the objects that have the method (* as a wildcard)"]
51
+ "find" => ["METHOD", "the objects that have the method (* as a wildcard)"],
52
+ "get" => ["PATH [KEY]", "the next value the application puts on PATH/KEY (its own keys; KEY: ** " \
53
+ "by default; --timeout 5.0)"],
54
+ "echo" => ["PATH [KEY]", "the values it puts there as they come (the text: what changed; --once: " \
55
+ "the first; --raw: each whole)"]
50
56
  },
51
57
  "status" => {
52
58
  "get" => ["NODE/APP", "an application's status, now"],
@@ -199,7 +205,11 @@ module Asterism
199
205
  end
200
206
  o.on("--json", "JSON instead of text (-o json)") { @opts[:format] = :json }
201
207
  end
202
- o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
208
+ if key == "object get"
209
+ o.on("--timeout SECONDS", Float, "how long to wait for a put (5.0)") { @opts[:put_timeout] = _1 }
210
+ else
211
+ o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
212
+ end
203
213
  o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
204
214
  end
205
215
  case key
@@ -216,6 +226,10 @@ module Asterism
216
226
  when "mcp"
217
227
  o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
218
228
  "(repeatable)") { @opts[:allow] << _1 }
229
+ when "object echo"
230
+ o.on("--once", "the first put, then stop") { @opts[:once] = true }
231
+ o.on("--raw", "each value whole (the text; else what changed)") { @opts[:raw] = true }
232
+ o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
219
233
  when "status echo"
220
234
  o.on("--once", "the status now, once, then stop") { @opts[:once] = true }
221
235
  o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
@@ -3,5 +3,5 @@ module Asterism
3
3
  # is in asterism.gemspec (asterism-zenoh ~> 0.4.0 for 0.5.0 to 0.7.0:
4
4
  # the bindings did not change; see CHANGELOG.md). 0.7.0 speaks wire
5
5
  # protocol 1 only (Asterism::PROTOCOL, docs/protocol.md).
6
- VERSION = "0.9.0"
6
+ VERSION = "0.11.0"
7
7
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asterism
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katsuhiko Kageyama
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-10-19 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: asterism-zenoh
@@ -47,7 +46,6 @@ dependencies:
47
46
  description: Proxies for objects exposed by other Ruby processes and boards, a ROS
48
47
  2 node speaking rmw_zenoh's wire format, CDR and generated message types. Pure Ruby;
49
48
  the same files are the mrbgem for mruby / PicoRuby.
50
- email:
51
49
  executables:
52
50
  - asterism
53
51
  extensions: []
@@ -156,6 +154,7 @@ files:
156
154
  - lib/asterism/cli/nouns.rb
157
155
  - lib/asterism/cli/survey.rb
158
156
  - lib/asterism/cli/text.rb
157
+ - lib/asterism/cli/values.rb
159
158
  - lib/asterism/cruby.rb
160
159
  - lib/asterism/cruby/credentials.rb
161
160
  - lib/asterism/cruby/objects.rb
@@ -182,7 +181,6 @@ metadata:
182
181
  source_code_uri: https://github.com/ruby-asterism/asterism
183
182
  bug_tracker_uri: https://github.com/ruby-asterism/asterism/issues
184
183
  rubygems_mfa_required: 'true'
185
- post_install_message:
186
184
  rdoc_options: []
187
185
  require_paths:
188
186
  - lib
@@ -190,15 +188,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
190
188
  requirements:
191
189
  - - ">="
192
190
  - !ruby/object:Gem::Version
193
- version: '3.2'
191
+ version: '3.4'
194
192
  required_rubygems_version: !ruby/object:Gem::Requirement
195
193
  requirements:
196
194
  - - ">="
197
195
  - !ruby/object:Gem::Version
198
196
  version: '0'
199
197
  requirements: []
200
- rubygems_version: 3.4.19
201
- signing_key:
198
+ rubygems_version: 3.6.9
202
199
  specification_version: 4
203
200
  summary: 'Asterism: Ruby objects on other machines, and ROS 2 (rmw_zenoh) nodes, over
204
201
  Zenoh'