yrby-agent 0.1.0.alpha1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bc39c696ec3eda40ebaa1aca8cbca2193d47f45fa255d03684276b6b3d1f0830
4
+ data.tar.gz: eff30a5794a471040bcc6bae86a359c1b89d34c7727e9b69ef3aeb848d56b8c2
5
+ SHA512:
6
+ metadata.gz: 3c76f601752b0fd45c385897eaec1e8dfdd6119703a4eb7c193a25bfd514a383cbcec851bbcd89e0e1bcad07c4efda2958303d3aeebe692fd51a8553f0a382e2
7
+ data.tar.gz: 71dadfd11c7920843dc982d6659d644f022af574306dcdc19bc99c84d748234e737643daec03f40e8cd35d9e37e001af60048517688c957a5c3c3dd53183cdf0
@@ -0,0 +1,37 @@
1
+ # Changelog: yrby-agent
2
+
3
+ All notable changes to the `yrby-agent` gem are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
5
+ project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ The first release. Everything here is experimental and may change in any
10
+ release. See [docs/experimental.md](docs/experimental.md).
11
+
12
+ ### Added
13
+
14
+ - `Y::ActionCable::Client` joins a document over the cable's websocket with a
15
+ grant, as a browser does. `edit` sends a change, every update is resent
16
+ until acked, and a dropped socket reconnects. `presence=` shows the client
17
+ to others and renews itself. It sends the URL's origin, and `headers:`
18
+ add anything else the connection checks.
19
+ - `Y::Collaborator` takes part in a document the way a person does, from a
20
+ job or anywhere else. A subclass defines `joined` and `changed(blocks)`,
21
+ where an AI agent calls its model, and sets `self.presence =`.
22
+ `Reviewer.run(post.collaborative_document(:body), url:, headers:)` joins
23
+ with a grant, calls `changed` with live handles to the blocks people
24
+ changed once their typing settles, and returns when nobody else has been
25
+ present for `idle` seconds, after `max_stay`, or on `leave`. One
26
+ collaborator works on a document at a time, through a lock in
27
+ `Rails.cache`, or one per person with `as: person`. An error in `joined`
28
+ or `changed` ends the collaborator and raises out of `run`.
29
+ `require "y/collaborator"` to use it.
30
+ - `Y::Agent`, a `Y::Collaborator` with a model. A subclass sets `model` and
31
+ `instructions`, which are RubyLLM::Agent's, and runs from a job. When
32
+ people's typing settles, `changed` asks the model about the blocks they
33
+ changed, in a fresh chat with tools that read the document
34
+ (`read_document`) and edit its blocks (`rewrite_block`, `insert_paragraph`,
35
+ `delete_block`). The tools name blocks by short ids that stay on their
36
+ blocks, and write through `edit`. `ask` asks anything else, and
37
+ `document_tools` picks which tools the model gets.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
4
+ class Client
5
+ # The local updates the server has not acknowledged yet, in the order
6
+ # they were made. Each carries the id the server acks, and an ack
7
+ # confirms only its own update: the server may record the updates of one
8
+ # connection out of order, so an ack for a later update says nothing about
9
+ # an earlier one. Nothing here touches the socket: the session sends what
10
+ # `pending` holds and this drops what `ack` confirms. A retransmit sends
11
+ # the pending updates one by one, in order.
12
+ class Outbox
13
+ Pending = Data.define(:id, :update)
14
+
15
+ def initialize
16
+ @pending = []
17
+ @next_id = 1
18
+ end
19
+
20
+ def pending = @pending.dup
21
+
22
+ def pending? = !@pending.empty?
23
+
24
+ # Queue a local update. Returns it with the id it will be acked by.
25
+ def push(update)
26
+ entry = Pending.new(id: @next_id, update: update)
27
+ @next_id += 1
28
+ @pending << entry
29
+ entry
30
+ end
31
+
32
+ # Confirm delivery of the update sent as `id`. The value comes off the
33
+ # wire: one that is not an integer, or names an id not pending, drops
34
+ # nothing. Returns true when something was confirmed.
35
+ def ack(id) # rubocop:disable Naming/PredicateMethod -- an action that reports whether it did anything
36
+ return false unless id.is_a?(Integer)
37
+
38
+ !@pending.reject! { |entry| entry.id == id }.nil?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "y/action_cable/sync"
6
+ require "y/action_cable/client/outbox"
7
+
8
+ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
9
+ class Client
10
+ # The client side of the protocol with no socket: cable messages in,
11
+ # cable messages out through the block, and the document kept current.
12
+ # It is what the JS provider's session is, in Ruby, so it can be driven
13
+ # in a test with nothing listening. The client feeds it every message
14
+ # the socket delivers and every command the caller makes, all on one
15
+ # reactor, so its own state needs no lock.
16
+ #
17
+ # Messages are the JSON hashes the cable carries. A command goes out as
18
+ # { command:, identifier:, data: } and a channel message comes in as
19
+ # { identifier:, message: }, the message being Sync's envelope.
20
+ class Session
21
+ # The second byte of a sync frame is y-protocols' sync type; 1 is
22
+ # SyncStep2, the server's answer to our SyncStep1.
23
+ SYNC_STEP2 = 1
24
+
25
+ # `root:` names the root XmlText whose top-level blocks on_update
26
+ # reports as changed; nil turns block tracking off.
27
+ def initialize(doc, identifier, root: "root", &transmit)
28
+ @doc = doc
29
+ @identifier = identifier
30
+ @root = root
31
+ @transmit = transmit
32
+ @outbox = Outbox.new
33
+ @subscribed = false
34
+ @synced = false
35
+ @loaded = false
36
+ @presence = nil
37
+ @asked = nil
38
+ @on_update = nil
39
+ @on_awareness = nil
40
+ end
41
+
42
+ def on_update(&block) = @on_update = block
43
+ def on_awareness(&block) = @on_awareness = block
44
+ def synced? = @synced
45
+ def pending? = @outbox.pending?
46
+
47
+ # One message from the server. Returns what it was: :welcome, :ping,
48
+ # :confirmed, :rejected, :disconnect, :synced for the message that
49
+ # completed a handshake, :ack, :update, or :awareness for a message of
50
+ # this subscription, :message for one it ignored, and nil for anything
51
+ # else. The connection acts on :synced, :rejected, and :disconnect.
52
+ def receive(message)
53
+ case message["type"]
54
+ when "welcome" then welcome
55
+ when "ping" then :ping
56
+ when "confirm_subscription" then confirmed if mine?(message)
57
+ when "reject_subscription" then :rejected if mine?(message)
58
+ when "disconnect" then :disconnect
59
+ else
60
+ payload = message["message"]
61
+ receive_payload(payload) || :message if mine?(message) && payload.is_a?(Hash)
62
+ end
63
+ end
64
+
65
+ # The socket dropped. Pending updates stay for the next confirmation.
66
+ def dropped
67
+ @subscribed = false
68
+ @synced = false
69
+ end
70
+
71
+ # A local update: kept until the server acks it, sent now when
72
+ # subscribed and otherwise when the subscription is next confirmed.
73
+ def send_update(update)
74
+ entry = @outbox.push(update)
75
+ send_entry(entry) if @subscribed
76
+ end
77
+
78
+ # Presence goes out as is. The last frame is said again on a reconnect,
79
+ # so peers see this client back without waiting for its next refresh.
80
+ def send_awareness(frame)
81
+ @presence = frame
82
+ send_frame(frame) if @subscribed
83
+ end
84
+
85
+ # Send everything unacked, in order. The retransmit timer calls this.
86
+ def resend
87
+ return unless @subscribed
88
+
89
+ @outbox.pending.each { |entry| send_entry(entry) }
90
+ end
91
+
92
+ def unsubscribe
93
+ command("unsubscribe") if @subscribed
94
+ @subscribed = false
95
+ end
96
+
97
+ private
98
+
99
+ # The server says welcome once the socket is up, and takes commands
100
+ # from then on.
101
+ def welcome
102
+ command("subscribe")
103
+ :welcome
104
+ end
105
+
106
+ # The subscription is confirmed: answer the server's opening SyncStep1
107
+ # if it came first, send ours, say who we are, and replay what is
108
+ # still unacked. The server's SyncStep2 completes the handshake.
109
+ def confirmed
110
+ @subscribed = true
111
+ send_frame(@doc.handle_sync_message(@asked)[2]) if @asked
112
+ @asked = nil
113
+ send_frame(@doc.sync_step1)
114
+ send_frame(@presence) if @presence
115
+ resend
116
+ :confirmed
117
+ end
118
+
119
+ def receive_payload(payload)
120
+ return @outbox.ack(payload["ack"]) && :ack if payload.key?("ack")
121
+
122
+ # AnyCable whispers presence under its own key; the server relays
123
+ # document frames under "update".
124
+ encoded = payload["awareness"] || payload["update"]
125
+ frame = decode(encoded) if encoded.is_a?(String)
126
+ receive_frame(frame) if frame
127
+ end
128
+
129
+ def decode(encoded)
130
+ Base64.strict_decode64(encoded)
131
+ rescue ArgumentError
132
+ nil
133
+ end
134
+
135
+ # A malformed frame is dropped by Y.message_kind. The server's SyncStep1
136
+ # is answered with our full state, the way a browser answers it; one
137
+ # that arrives before the subscription is confirmed (Action Cable sends
138
+ # it from `subscribed`) waits for the confirmation.
139
+ def receive_frame(frame)
140
+ case Y.message_kind(frame)
141
+ when Sync::MSG_KIND_SYNC_STEP1
142
+ @subscribed ? send_frame(@doc.handle_sync_message(frame)[2]) : @asked = frame
143
+ nil
144
+ when Sync::MSG_KIND_UPDATE then receive_update(frame)
145
+ when Sync::MSG_KIND_AWARENESS
146
+ @on_awareness&.call(frame)
147
+ :awareness
148
+ end
149
+ end
150
+
151
+ # Apply what the doc does not already hold (its own updates come back
152
+ # from the server too) and report it. The first SyncStep2 is the load,
153
+ # not a change, so it does not fire on_update; a later one, after a
154
+ # reconnect, carries what was missed and does.
155
+ def receive_update(frame)
156
+ update = Y.update_from_message(frame)
157
+ apply(update) if update && @doc.update_advances?(update)
158
+ return :update unless frame.getbyte(1) == SYNC_STEP2 && !@synced
159
+
160
+ @synced = true
161
+ @loaded = true
162
+ :synced
163
+ end
164
+
165
+ def apply(update)
166
+ changed = @root ? @doc.apply_update_changes(update, @root) : (@doc.apply_update(update) && nil)
167
+ @on_update&.call(update, @doc, changed) if @loaded
168
+ end
169
+
170
+ def send_entry(entry)
171
+ message("update" => Base64.strict_encode64(Y.wrap_update(entry.update)), "id" => entry.id)
172
+ end
173
+
174
+ def send_frame(bytes) = message("update" => Base64.strict_encode64(bytes))
175
+
176
+ def message(data)
177
+ @transmit.call("command" => "message", "identifier" => @identifier, "data" => JSON.generate(data))
178
+ end
179
+
180
+ def command(name) = @transmit.call("command" => name, "identifier" => @identifier)
181
+
182
+ def mine?(message) = message["identifier"] == @identifier
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "async"
5
+ require "async/http/endpoint"
6
+ require "async/websocket/client"
7
+ rescue LoadError => e
8
+ raise LoadError, "Y::ActionCable::Client needs the async-websocket gem, which yrby-agent depends on. (#{e.message})"
9
+ end
10
+ require "json"
11
+ require "logger"
12
+ require "y/action_cable/client/session"
13
+
14
+ # y/collaborator and y/agent load this file too, so one warning covers all three.
15
+ warn "yrby-agent: Y::ActionCable::Client, Y::Collaborator, and Y::Agent are experimental " \
16
+ "and may change in any release. Warning[:experimental] = false turns this off.", category: :experimental
17
+
18
+ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
19
+ # Joins a document the way a browser does: over the
20
+ # cable's websocket, as a client of the document channel. It holds a
21
+ # Y::Doc, follows the document, and sends its own edits and presence
22
+ # through the channel, so the server records, acks, and distributes them
23
+ # like anyone else's. The process touches neither the pubsub nor the
24
+ # store. It needs the cable URL and the same way in a browser has: the
25
+ # channel's name and params, such as a grant the app minted with
26
+ # record.collaborative_document(name).grant.
27
+ #
28
+ # client = Y::ActionCable::Client.new("wss://example.com/cable",
29
+ # params: { grant: post.collaborative_document(:body).grant, name: "body" })
30
+ # client.on_update { |update, doc, changed| ... } # changed: block ordinals touched
31
+ # client.subscribe # connects and returns once the document has arrived
32
+ # client.presence = { "user" => { "name" => "Reviewer", "color" => "#7c3aed" } }
33
+ # client.edit { |doc| Y::Lexxy.append_paragraph(doc, "hello") }
34
+ # client.unsubscribe # waits for acks, clears presence, and closes
35
+ #
36
+ # The client sends the URL's own origin as its Origin, which Action Cable
37
+ # accepts. `headers:` go on the websocket request for anything more: an
38
+ # Origin when the cable is reached at another address than the site's, or
39
+ # a token for an ApplicationCable::Connection that requires a login, since
40
+ # the process has no cookie.
41
+ #
42
+ # The client runs as a task of the reactor `subscribe` is called from
43
+ # when there is one (Falcon, or an Async block), and in a thread of its
44
+ # own otherwise (Puma, a script). Callbacks run on that reactor; keep
45
+ # them short. edit and presence= may be called from any thread or fiber.
46
+ #
47
+ # Delivery is at least once, as in the JS provider: every update carries
48
+ # an id and is kept until the server acks it, resent on a timer and after
49
+ # a reconnect. A dropped socket reconnects with backoff. A rejected
50
+ # subscription, or a server asking not to reconnect, ends the client.
51
+ class Client
52
+ PROTOCOL = "actioncable-v1-json"
53
+ RESEND = 1 # seconds between retransmits of unacked updates
54
+ STALE = 15 # seconds without a message before the socket is given up; the server pings every 3
55
+ RENEW = 15 # seconds between presence renewals; peers drop a client they haven't heard from in 30
56
+ LEAVE_GRACE = 0.25 # seconds between clearing presence and unsubscribing
57
+ BACKOFF = [1, 2, 4, 8].freeze # seconds before each reconnect, then the last for good
58
+ UNACKED = "unsubscribed before the server acked every update"
59
+
60
+ # `channel:` and `params:` make the subscription identifier, the way
61
+ # yrby-client's provider does. `root:` is the root XmlText on_update
62
+ # reports changed blocks for; nil turns that off.
63
+ def initialize(url, params:, channel: "Y::DocumentChannel", headers: {}, doc: Y::Doc.new, root: "root", logger: nil) # rubocop:disable Metrics/ParameterLists -- the subscription's parts and the doc's
64
+ @endpoint = Async::HTTP::Endpoint.parse(url)
65
+ subscription = { "channel" => channel }.merge(params.transform_keys(&:to_s))
66
+ @identifier = JSON.generate(subscription)
67
+ # What logs and errors name the subscription by. The grant opens the
68
+ # document, so it stays out of them.
69
+ @label = JSON.generate(subscription.except("grant"))
70
+ @headers = headers.map { |name, value| [name.to_s.downcase, value.to_s] }
71
+ @doc = doc
72
+ @awareness = Y::Awareness.new(doc.client_id)
73
+ @presence = nil
74
+ @session = Session.new(doc, @identifier, root: root) do |message|
75
+ @outgoing << JSON.generate(message)
76
+ end
77
+ @logger = logger || Logger.new($stderr)
78
+ @commands = Thread::Queue.new # from any thread to the reactor
79
+ @outgoing = Thread::Queue.new # from the reactor to the socket
80
+ @events = Thread::Queue.new # from the reactor to whoever waits in subscribe
81
+ @queued = 0 # updates handed over but not yet taken up by the reactor
82
+ @lock = Mutex.new
83
+ @closed = false
84
+ @turned_away = false # the server closed the client, not the caller
85
+ end
86
+
87
+ def on_update(&block)
88
+ @session.on_update { |*args| callback(block, args) }
89
+ self
90
+ end
91
+
92
+ def on_awareness(&block)
93
+ @session.on_awareness { |*args| callback(block, args) }
94
+ self
95
+ end
96
+
97
+ def synced? = @session.synced?
98
+
99
+ # True while updates are in flight: handed over, sent, but not yet acked.
100
+ def pending? = @lock.synchronize { @queued.positive? } || @session.pending?
101
+
102
+ # True once the client has left, or the server ended its subscription
103
+ # (rejected it on a reconnect, or asked it not to reconnect).
104
+ def closed? = @closed
105
+
106
+ # Connect, subscribe, and wait up to `timeout` seconds for the document
107
+ # to arrive. Raises Y::Error when the subscription is rejected or the
108
+ # server does not answer in time.
109
+ def subscribe(timeout: 10)
110
+ raise Y::Error, "the client was closed" if @closed
111
+
112
+ @runner = Async::Task.current? ? Async { run } : Thread.new { Sync { run } }
113
+ event = @events.pop(timeout: timeout)
114
+ case event
115
+ when :synced then self
116
+ when :rejected then close_and_raise("subscription rejected for #{@label}")
117
+ when String then close_and_raise("the server closed the connection: #{event}")
118
+ else close_and_raise("no sync within #{timeout}s for #{@label}")
119
+ end
120
+ end
121
+
122
+ # Leave: wait up to `timeout` seconds for acks of what is still in
123
+ # flight, tell the channel, and close the socket. Nothing is delivered
124
+ # after this. Raises Y::Error when an update was still unacked, since
125
+ # it may never have reached the server.
126
+ def unsubscribe(timeout: 5)
127
+ # The server may have closed the client already, turning it away on a
128
+ # reconnect. What it never acked is lost all the same, and the first
129
+ # unsubscribe says so.
130
+ if @closed
131
+ lost = @turned_away && pending?
132
+ @turned_away = false
133
+ raise Y::Error, UNACKED if lost
134
+
135
+ return self
136
+ end
137
+
138
+ @closed = true
139
+ # From inside a callback the runner is the caller, so it can't be
140
+ # waited on. It finishes leaving once the callback returns, and logs
141
+ # an update still unacked instead.
142
+ inside = Thread.current[:yrby_client_callback].equal?(self)
143
+ @commands << [:close, [timeout, inside]]
144
+ return self if inside
145
+
146
+ @runner.is_a?(Thread) ? @runner.join(timeout + 5) : @runner&.wait
147
+ raise Y::Error, UNACKED if pending?
148
+
149
+ self
150
+ rescue Async::Stop
151
+ self
152
+ end
153
+
154
+ # Edit the document and send what the block changed. Returns the update,
155
+ # or nil when there's nothing to send. A remote update applied while the
156
+ # block runs stays out of it (see Y::Doc#edit). Raises Y::Error once the
157
+ # client is closed, since nothing it edits then would reach anyone.
158
+ def edit
159
+ raise Y::Error, "the client was closed" if @closed
160
+
161
+ # Only the block can be interrupted, so an update is never taken from
162
+ # the document and then dropped before it's handed over.
163
+ Thread.handle_interrupt(Object => :never) do
164
+ update = @doc.edit { Thread.handle_interrupt(Object => :immediate) { yield @doc } }
165
+ send_update(update)
166
+ update
167
+ end
168
+ end
169
+
170
+ # Show this client to the document's other participants, the way a
171
+ # browser's presence does. `state` is the hash editors render, such as
172
+ # { "user" => { "name" => "Reviewer", "color" => "#7c3aed" } }, plus a
173
+ # caret if you have one. The client renews it every RENEW seconds and
174
+ # clears it when it leaves.
175
+ def presence=(state)
176
+ @presence = JSON.generate(state)
177
+ send_awareness(@awareness.set_local_state(@presence))
178
+ end
179
+
180
+ private
181
+
182
+ attr_reader :doc
183
+
184
+ # Hand a document update to the reactor, which keeps it until the server
185
+ # acks it.
186
+ def send_update(update)
187
+ return unless update
188
+ raise Y::Error, "the client closed before this update was sent" if @closed
189
+
190
+ @lock.synchronize { @queued += 1 }
191
+ @commands << [:update, update]
192
+ end
193
+
194
+ def send_awareness(frame)
195
+ @commands << [:awareness, frame] if frame && !@closed
196
+ end
197
+
198
+ # Runs a caller's callback, marked so `unsubscribe` knows it is inside one.
199
+ def callback(block, args)
200
+ Thread.current[:yrby_client_callback] = self
201
+ block.call(*args)
202
+ ensure
203
+ Thread.current[:yrby_client_callback] = nil
204
+ end
205
+
206
+ def close_and_raise(reason)
207
+ @closed = true
208
+ @commands << [:close, [0, false]]
209
+ raise Y::Error, reason
210
+ end
211
+
212
+ # The whole life of the client, on its reactor: commands from callers
213
+ # are applied to the session as they arrive, and the socket is kept
214
+ # connected until the client is closed.
215
+ def run
216
+ @task = Async::Task.current
217
+ pump = @task.async { each_command }
218
+ renewal = @task.async { renew_presence }
219
+ attempts = 0
220
+ until @closed
221
+ if connection == :stop
222
+ @closed = @turned_away = true
223
+ break
224
+ end
225
+
226
+ attempts += 1
227
+ sleep BACKOFF[[attempts, BACKOFF.size].min - 1]
228
+ end
229
+ ensure
230
+ pump&.stop
231
+ renewal&.stop
232
+ end
233
+
234
+ # One socket: reader here, a writer task for the outgoing queue, and a
235
+ # timer that resends unacked updates. Returns :stop when the client
236
+ # should not reconnect, :dropped otherwise.
237
+ def connection
238
+ Async::WebSocket::Client.connect(@endpoint, headers: request_headers, protocols: [PROTOCOL]) do |socket|
239
+ writer = @task.async { each_outgoing(socket) }
240
+ timer = @task.async { retransmit }
241
+ read_loop(socket)
242
+ ensure
243
+ writer&.stop
244
+ timer&.stop
245
+ @session.dropped
246
+ @outgoing.clear
247
+ end
248
+ rescue Async::Stop
249
+ raise
250
+ rescue StandardError => e
251
+ @logger.warn("Y::ActionCable::Client #{@label}: #{e.class}: #{e.message}")
252
+ :dropped
253
+ end
254
+
255
+ def read_loop(socket)
256
+ loop do
257
+ message = @task.with_timeout(STALE) { socket.read } or return :dropped
258
+ data = JSON.parse(message.to_str)
259
+ case @session.receive(data)
260
+ when :synced then @events << :synced
261
+ when :rejected
262
+ @events << :rejected
263
+ return :stop
264
+ when :disconnect
265
+ return :dropped unless data["reconnect"] == false
266
+
267
+ # Such as "unauthorized" from a connection that failed to connect.
268
+ @events << data["reason"].to_s
269
+ return :stop
270
+ end
271
+ end
272
+ rescue Async::TimeoutError, JSON::ParserError => e
273
+ @logger.warn("Y::ActionCable::Client #{@label}: #{e.class}: #{e.message}")
274
+ :dropped
275
+ end
276
+
277
+ # y-protocols peers drop a client whose presence goes quiet for 30
278
+ # seconds, so a client with presence says it again on a timer.
279
+ def renew_presence
280
+ loop do
281
+ sleep RENEW
282
+ @session.send_awareness(@awareness.set_local_state(@presence)) if @presence
283
+ end
284
+ end
285
+
286
+ def retransmit
287
+ loop do
288
+ sleep RESEND
289
+ @session.resend
290
+ end
291
+ end
292
+
293
+ def each_outgoing(socket)
294
+ while (text = @outgoing.pop)
295
+ socket.write(text)
296
+ socket.flush
297
+ end
298
+ end
299
+
300
+ def each_command
301
+ while (command, argument = @commands.pop)
302
+ case command
303
+ when :update
304
+ @session.send_update(argument)
305
+ @lock.synchronize { @queued -= 1 }
306
+ when :awareness then @session.send_awareness(argument)
307
+ when :close then return leave(*argument)
308
+ end
309
+ end
310
+ end
311
+
312
+ # Give in-flight updates a moment to be acked, say goodbye, let the
313
+ # goodbye reach the socket, and stop the reactor task.
314
+ #
315
+ # Action Cable runs one connection's messages on a worker pool, so an
316
+ # unsubscribe sent right behind the presence removal can be handled first,
317
+ # and the server then drops the removal. The client lets the removal go
318
+ # out and waits LEAVE_GRACE before unsubscribing. If it is lost anyway,
319
+ # peers drop the presence after 30 seconds.
320
+ def leave(timeout, inside_callback)
321
+ deadline = now + timeout
322
+ sleep 0.05 while @session.pending? && now < deadline
323
+ @logger.error(UNACKED) if inside_callback && @session.pending?
324
+ if @presence
325
+ @session.send_awareness(@awareness.clear_local_state)
326
+ flush
327
+ sleep LEAVE_GRACE
328
+ end
329
+ @session.unsubscribe
330
+ flush
331
+ @task.stop
332
+ end
333
+
334
+ def flush
335
+ deadline = now + 1
336
+ sleep 0.05 until @outgoing.empty? || now > deadline
337
+ end
338
+
339
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
340
+
341
+ # The caller's headers, plus an Origin unless they set one. It's a new
342
+ # array each time, because async-websocket adds the handshake's key to
343
+ # the one it's given, and a reconnect has to send only its own key.
344
+ def request_headers
345
+ headers = @headers.dup
346
+ headers.unshift(["origin", origin]) unless headers.any? { |name, _| name == "origin" }
347
+ headers
348
+ end
349
+
350
+ # Action Cable checks the origin against the host unless forgery
351
+ # protection is off; the socket's own host is always allowed.
352
+ def origin
353
+ "#{@endpoint.secure? ? "https" : "http"}://#{@endpoint.authority}"
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ class Agent < Collaborator
5
+ # The RubyLLM tools an agent gives its model. The model names blocks by
6
+ # the ids read_document lists, and every write goes through the agent's
7
+ # `edit`, so it reaches people as the model makes it.
8
+ module Tools
9
+ # `blocks` as the model reads them: one line per block, "b3 (paragraph): text".
10
+ def self.listing(agent, blocks)
11
+ blocks.map do |block|
12
+ kind = block.attributes.values_at("__type", "__tag").compact.join(" ")
13
+ "#{agent.block_id(block)} (#{kind}): #{block.text}"
14
+ end.join("\n")
15
+ end
16
+
17
+ class Tool < RubyLLM::Tool
18
+ def self.tool_name = name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
19
+
20
+ def initialize(agent)
21
+ super()
22
+ @agent = agent
23
+ end
24
+
25
+ private
26
+
27
+ def missing(id) = { error: "There's no block #{id}. Call read_document for the blocks there are now." }
28
+ end
29
+
30
+ class ReadDocument < Tool
31
+ description "Lists the document's blocks in order, one per line, as `id (type): text`. " \
32
+ "Name a block by its id in the other tools. Ids stay on their blocks while people edit."
33
+
34
+ def execute
35
+ blocks = @agent.document_blocks
36
+ blocks.empty? ? "The document is empty." : Tools.listing(@agent, blocks)
37
+ end
38
+ end
39
+
40
+ class RewriteBlock < Tool
41
+ description "Replaces the text of one block with `text`, as plain text. " \
42
+ "The block's formatting, such as bold or links, is lost."
43
+ parameter :id, description: "The block's id, such as b3"
44
+ parameter :text, description: "The block's new text"
45
+
46
+ def execute(id:, text:)
47
+ block = @agent.block(id) or return missing(id)
48
+ @agent.edit { Lexical.replace_runs(block, text) }
49
+ "Rewrote #{id}."
50
+ end
51
+ end
52
+
53
+ class InsertParagraph < Tool
54
+ description "Adds a paragraph holding `text`, after the block `after`, or at the end of the document."
55
+ parameter :text, description: "The paragraph's text"
56
+ parameter :after, description: "The id of the block it goes after. Leave it out to add it at the end.",
57
+ required: false
58
+
59
+ def execute(text:, after: nil)
60
+ block = nil
61
+ @agent.edit do |doc|
62
+ at = after ? @agent.ordinal(after)&.succ : -1
63
+ block = Lexical.insert_paragraph(doc, at, text) if at
64
+ end
65
+ block ? "Added #{@agent.block_id(block)}." : missing(after)
66
+ end
67
+ end
68
+
69
+ class DeleteBlock < Tool
70
+ description "Deletes one block."
71
+ parameter :id, description: "The block's id, such as b3"
72
+
73
+ def execute(id:)
74
+ deleted = false
75
+ @agent.edit do |doc|
76
+ ordinal = @agent.ordinal(id)
77
+ deleted = Lexical.delete_block(doc, ordinal) if ordinal
78
+ end
79
+ deleted ? "Deleted #{id}." : missing(id)
80
+ end
81
+ end
82
+
83
+ ALL = [ReadDocument, RewriteBlock, InsertParagraph, DeleteBlock].freeze
84
+ end
85
+ end
86
+ end
data/lib/y/agent.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "y/collaborator"
5
+ require "yrby/agent/version"
6
+ require "y/agent/tools"
7
+
8
+ module Y
9
+ # A collaborator with a model. It joins a document the way Y::Collaborator
10
+ # does, and when people's typing settles it hands the blocks they changed to
11
+ # a model through RubyLLM, with tools that read and edit the document:
12
+ #
13
+ # class Proofreader < Y::Agent
14
+ # model "gpt-5.6-luna"
15
+ # instructions "Fix typos in the blocks people change. Change nothing else."
16
+ # end
17
+ #
18
+ # class ProofreadJob < ApplicationJob
19
+ # def perform(post) = Proofreader.run(post.collaborative_document(:body), url: "wss://example.com/cable")
20
+ # end
21
+ #
22
+ # `model`, `instructions`, `tools`, `thinking`, and `temperature` are
23
+ # RubyLLM::Agent's, and `llm` is the RubyLLM::Agent itself, for any other
24
+ # setting. Each ask is a fresh chat: the tools read the document as it is
25
+ # now, so the model needs no history, and a stay of hours never grows one.
26
+ class Agent < Collaborator
27
+ class << self
28
+ # The RubyLLM::Agent this agent's chats come from. A subclass starts
29
+ # with a copy of its parent's.
30
+ def llm
31
+ @llm ||= Class.new(superclass.respond_to?(:llm) ? superclass.llm : RubyLLM::Agent)
32
+ end
33
+
34
+ %i[model instructions tools thinking temperature].each do |setting|
35
+ define_method(setting) { |*args, **options, &block| llm.public_send(setting, *args, **options, &block) }
36
+ end
37
+ end
38
+
39
+ def initialize(document, **)
40
+ super
41
+ @anchors = {} # block id => anchor
42
+ @ids = {} # anchor => block id
43
+ end
44
+
45
+ # Asks the model `message` in a fresh chat with the document tools, and
46
+ # returns the reply, a RubyLLM::Message. A block gets each chunk as it
47
+ # streams in. The model's edits go through `edit`, so they reach people
48
+ # as it makes them.
49
+ def ask(message, &) = chat.ask(message, &)
50
+
51
+ # Asks the model about the blocks people changed. Override it to ask
52
+ # something else, or to call `ask` only sometimes.
53
+ def changed(blocks)
54
+ ask("People changed these blocks:\n\n#{Tools.listing(self, blocks)}") if blocks.any?
55
+ end
56
+
57
+ # The tools the model gets with each ask: Tools::ALL, which read the
58
+ # document and edit its blocks. Override it to give the model fewer,
59
+ # such as [Tools::ReadDocument.new(self)] for an agent that only answers.
60
+ def document_tools = Tools::ALL.map { |tool| tool.new(self) }
61
+
62
+ # Live handles to the document's top-level blocks, in order.
63
+ def document_blocks
64
+ root = doc.get_xml_text(ROOT)
65
+ (0...root.size).map { |ordinal| root.xml_text(ordinal) }
66
+ end
67
+
68
+ # A short id for `block`, such as "b3", that the model can name it by.
69
+ # The id stays on its block for the whole stay, wherever people move it.
70
+ def block_id(block)
71
+ anchor = block.anchor
72
+ @ids[anchor] ||= "b#{@ids.size + 1}".tap { |id| @anchors[id] = anchor }
73
+ end
74
+
75
+ # The live handle to the block `id` names, or nil once it's deleted.
76
+ def block(id) = (anchor = @anchors[id]) && doc.find(anchor)
77
+
78
+ # Where the block `id` names is now, or nil once it's deleted.
79
+ def ordinal(id) = (anchor = @anchors[id]) && doc.block_at(anchor)
80
+
81
+ # A fresh RubyLLM chat with the model, the instructions, and the
82
+ # document tools. `ask` asks one; take one to set more on it first.
83
+ def chat = self.class.llm.chat.with_tools(*document_tools)
84
+ end
85
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "securerandom"
5
+
6
+ module Y
7
+ class Collaborator
8
+ # The collaborator lock: a key in an ActiveSupport::Cache store holding
9
+ # this collaborator's token. It is written only if absent, and a task on
10
+ # the collaborator's reactor renews it every LOCK_TTL / 3 seconds while
11
+ # the collaborator stays, so a handler waiting on a model call doesn't
12
+ # let it lapse. It lapses LOCK_TTL seconds after the last renewal, so a
13
+ # collaborator whose process died doesn't hold the document for good.
14
+ #
15
+ # Cache stores have no atomic compare-and-set, so a renewal and a release
16
+ # read the key and then write or delete it. That leaves one gap: a process
17
+ # that stalls longer than LOCK_TTL loses the lock, and while it comes
18
+ # back, it can briefly overlap the collaborator that took over. Its next
19
+ # renewal sees the other token and it stops.
20
+ class Lock
21
+ # One key per document, or per document and `as` when it's given. A
22
+ # record is named by its GlobalID, so a Person and a Team with the same
23
+ # id don't share a key.
24
+ def self.key(document, as)
25
+ return "yrby:collaborator:#{document.key}" if as.nil?
26
+
27
+ "yrby:collaborator:#{document.key}:#{as.respond_to?(:to_global_id) ? as.to_global_id : as}"
28
+ end
29
+
30
+ def initialize(store, key)
31
+ @store = store
32
+ @key = key
33
+ @token = SecureRandom.uuid
34
+ @held = false
35
+ end
36
+
37
+ # Takes the lock if nobody holds it, and starts renewing it in a task
38
+ # of the current reactor. `release` stops the task.
39
+ def take
40
+ @held = @store.write(@key, @token, unless_exist: true, expires_in: LOCK_TTL) ? true : false
41
+ @renewer = Async::Task.current.async { renew_until_lost } if @held
42
+ @held
43
+ end
44
+
45
+ # False once another collaborator holds the lock.
46
+ def held? = @held
47
+
48
+ def release
49
+ @renewer&.stop
50
+ @store.delete(@key) if @held && @store.read(@key) == @token
51
+ @held = false
52
+ end
53
+
54
+ private
55
+
56
+ def renew_until_lost
57
+ while @held
58
+ sleep LOCK_TTL / 3.0
59
+ renew
60
+ end
61
+ end
62
+
63
+ def renew
64
+ holder = @store.read(@key)
65
+ return @held = false if holder && holder != @token
66
+
67
+ @store.write(@key, @token, expires_in: LOCK_TTL)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/duration"
4
+ require "logger"
5
+ require "y/action_cable/client"
6
+ require "y/collaborator/lock"
7
+
8
+ module Y
9
+ # Takes part in a document the way a person does. It joins over the cable
10
+ # with a grant, shows up among the people editing, hears what they write
11
+ # once their typing settles, and leaves when they do. It has no model of its
12
+ # own: an AI agent is a collaborator whose `changed` calls one. Subclass it,
13
+ # and run it from a job:
14
+ #
15
+ # class Reviewer < Y::Collaborator
16
+ # def joined
17
+ # self.presence = { "user" => { "name" => "Reviewer", "color" => "#7c3aed" } }
18
+ # edit { |doc| Y::Lexxy.append_paragraph(doc, "Reviewing.") }
19
+ # end
20
+ #
21
+ # def changed(blocks) # live handles to the top-level blocks people changed
22
+ # edit { |doc| Y::Lexxy.append_paragraph(doc, "Saw #{blocks.map(&:text).join(" / ")}.") }
23
+ # end
24
+ # end
25
+ #
26
+ # class ReviewJob < ApplicationJob
27
+ # def perform(post) = Reviewer.run(post.collaborative_document(:body), url: "wss://example.com/cable")
28
+ # end
29
+ #
30
+ # `run` follows the document until nobody else has been present for `idle`
31
+ # seconds, the collaborator has stayed `max_stay` seconds, or `leave` is
32
+ # called. It always clears its presence and unsubscribes on the way out. An
33
+ # error in `joined` or `changed` ends the collaborator and raises out of
34
+ # `run`; rescue it in the method to carry on.
35
+ #
36
+ # Only one collaborator works on a document at a time. Pass `as:` to allow
37
+ # one per person instead, such as `as: person` for the collaborator a person
38
+ # brought. The lock lives in `lock:`, Rails.cache by default, so it holds
39
+ # across processes when the cache is shared. `run` returns :busy without
40
+ # joining when another collaborator holds it.
41
+ class Collaborator
42
+ SETTLE = 1.5 # seconds of quiet before changed sees a burst of edits
43
+ SETTLE_AT_MOST = 5 # seconds of steady typing before changed sees it anyway
44
+ IDLE = 120 # seconds with nobody else present before it leaves
45
+ MAX_STAY = 2 * 60 * 60
46
+ GONE = 30 # a person whose presence hasn't renewed in this long has left
47
+ LOCK_TTL = 60 # the lock lapses this long after its last renewal
48
+ ROOT = "root" # the root XmlText Lexical and Lexxy edit, whose blocks changed reports
49
+
50
+ # Runs a collaborator until it leaves. Returns :left, or :busy when another
51
+ # collaborator holds the document.
52
+ def self.run(document, **) = new(document, **).run
53
+
54
+ attr_reader :document
55
+
56
+ # `document` is a record's collaborative document, such as
57
+ # post.collaborative_document(:body), which names its own grant and
58
+ # channel.
59
+ def initialize(document, url:, headers: {}, idle: IDLE, max_stay: MAX_STAY, as: nil, # rubocop:disable Metrics/ParameterLists -- the document, the connection, and how long to stay
60
+ lock: Collaborator.default_lock)
61
+ @document = document
62
+ @url = url
63
+ @headers = headers
64
+ @idle = idle
65
+ @max_stay = max_stay
66
+ @lock = lock && Lock.new(lock, Lock.key(document, as))
67
+ @changes = Thread::Queue.new
68
+ @people = Y::Awareness.new
69
+ @renewals = {} # client id => [clock, when it last moved]
70
+ @people_lock = Mutex.new
71
+ @leaving = false
72
+ end
73
+
74
+ # Runs as a task of the caller's reactor when there is one. Otherwise it
75
+ # starts one on the calling thread, such as a job's, and holds that
76
+ # thread until the collaborator leaves.
77
+ def run
78
+ Sync do
79
+ next :busy if @lock && !@lock.take
80
+
81
+ begin
82
+ join
83
+ joined
84
+ follow
85
+ @client.unsubscribe # raises when an update was never acked
86
+ :left
87
+ ensure
88
+ leave_after_error
89
+ @lock&.release
90
+ end
91
+ end
92
+ end
93
+
94
+ # How the collaborator appears to the people in the document: the hash
95
+ # editors render, such as { "user" => { "name" => "Reviewer" } }. nil, the
96
+ # default, keeps it out of their roster. Set it in `joined`, or in
97
+ # `initialize` to show up as the collaborator joins, and again to change
98
+ # it.
99
+ attr_reader :presence
100
+
101
+ def presence=(state)
102
+ @presence = state
103
+ @client&.presence = state
104
+ end
105
+
106
+ # Called once the collaborator has joined and the document has loaded.
107
+ def joined; end
108
+
109
+ # Called with live handles (Y::XmlText) to the top-level blocks people
110
+ # changed, in document order, once their typing has paused for SETTLE
111
+ # seconds, or after SETTLE_AT_MOST seconds of steady typing. A handle
112
+ # stays on its block while people edit around it, and writing to one
113
+ # whose block was deleted raises Y::Error. A change outside the root,
114
+ # such as to a map, comes right away with an empty list: it's a click,
115
+ # not typing. The collaborator's own edits never show up here.
116
+ def changed(blocks); end
117
+
118
+ # Edit the document. Returns the update, or nil when nothing changed.
119
+ # Raises Y::Error once another collaborator holds the document, so a
120
+ # collaborator that outlived the lock stops writing.
121
+ def edit(&)
122
+ raise Y::Error, "another collaborator holds #{@document.key}" unless holding?
123
+
124
+ @client.edit(&)
125
+ end
126
+
127
+ # Everyone else in the document, as { client_id => their presence state }.
128
+ def people
129
+ own = doc.client_id
130
+ @people_lock.synchronize do
131
+ @people.states.select do |id, state|
132
+ id != own && state && (renewed = @renewals[id]) && now - renewed.last < GONE
133
+ end
134
+ end
135
+ end
136
+
137
+ # Leave once `joined` or `changed` returns.
138
+ def leave = @leaving = true
139
+
140
+ def self.default_lock = (Rails.cache if defined?(Rails.cache))
141
+ def self.default_logger = (defined?(Rails.logger) && Rails.logger) || Logger.new($stderr)
142
+
143
+ private
144
+
145
+ # The collaborator's copy of the document, for its hooks to read.
146
+ attr_reader :doc
147
+
148
+ def join
149
+ grant = @document.grant(expires_in: ActiveSupport::Duration.build(@max_stay + LOCK_TTL))
150
+ params = { grant:, name: @document.name }
151
+ @doc = Y::Doc.new
152
+ @client = Y::ActionCable::Client.new(@url, params:, headers: @headers, channel: @document.channel, doc: @doc,
153
+ root: ROOT, logger: Collaborator.default_logger)
154
+ @client.on_update { |_update, doc, touched| @changes << anchors(doc, touched || []) }
155
+ @client.on_awareness { |frame| see(frame) }
156
+ @client.subscribe
157
+ @client.presence = @presence if @presence
158
+ end
159
+
160
+ def follow
161
+ started = now
162
+ alone_since = nil
163
+ until @leaving || @client.closed? || now - started > @max_stay || !holding?
164
+ alone_since = alone?(started) ? alone_since || now : nil
165
+ break if alone_since && now - alone_since > @idle
166
+
167
+ touched = @changes.pop(timeout: 1)
168
+ next unless touched
169
+
170
+ touched = settle(touched) unless touched.empty?
171
+ changed(blocks(touched)) if holding?
172
+ end
173
+ end
174
+
175
+ # Unsubscribe on the way out of an error, which says more than an unacked
176
+ # update would. After a normal leave this does nothing.
177
+ def leave_after_error
178
+ @client&.unsubscribe
179
+ rescue Y::Error => e
180
+ Collaborator.default_logger.error(e.message)
181
+ end
182
+
183
+ def holding? = @lock.nil? || @lock.held?
184
+
185
+ # People already in the document announce themselves only when they next
186
+ # renew their presence, so the collaborator can't know it is alone until one
187
+ # renewal window has passed since it joined.
188
+ def alone?(started) = now - started > Y::ActionCable::Client::RENEW && people.empty?
189
+
190
+ # Anchors to the blocks an update touched, taken as it arrives: a later
191
+ # update that adds or removes a block moves an ordinal, not an anchor.
192
+ def anchors(doc, ordinals)
193
+ root = doc.get_xml_text(ROOT) unless ordinals.empty?
194
+ ordinals.filter_map { |ordinal| root.xml_text(ordinal)&.anchor }
195
+ end
196
+
197
+ # Waits for a burst of edits to pause, or for SETTLE_AT_MOST seconds of
198
+ # steady typing, and returns the anchors of every block it touched.
199
+ def settle(anchors)
200
+ deadline = now + SETTLE_AT_MOST
201
+ while (wait = [SETTLE, deadline - now].min).positive? && (more = @changes.pop(timeout: wait))
202
+ anchors |= more
203
+ end
204
+ anchors
205
+ end
206
+
207
+ # Live handles to the blocks, in document order, leaving out any that
208
+ # were deleted while the edits settled.
209
+ def blocks(anchors)
210
+ anchors.filter_map { |anchor| (ordinal = doc.block_at(anchor)) && [ordinal, doc.find(anchor)] }
211
+ .sort_by(&:first).map(&:last)
212
+ end
213
+
214
+ # Presence frames from everyone, this collaborator's own included. A
215
+ # person's editor renews their presence every 15 seconds, so a clock that
216
+ # stops moving means they left without saying so.
217
+ def see(frame)
218
+ @people_lock.synchronize do
219
+ @people.apply_update(frame)
220
+ @people.clocks.each do |id, clock|
221
+ @renewals[id] = [clock, now] unless @renewals[id]&.first == clock
222
+ end
223
+ end
224
+ end
225
+
226
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
227
+ end
228
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yrby
4
+ module Agent
5
+ VERSION = "0.1.0.alpha1"
6
+ end
7
+ end
data/lib/yrby-agent.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the gem name, so `Bundler.require` loads it automatically.
4
+ require "y/agent"
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: yrby-agent
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.alpha1
5
+ platform: ruby
6
+ authors:
7
+ - JP Camara
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async-websocket
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.30'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.30'
26
+ - !ruby/object:Gem::Dependency
27
+ name: yrby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.9.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.9.1
40
+ - !ruby/object:Gem::Dependency
41
+ name: yrby-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0.8'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0.8'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ruby_llm
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: minitest
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '5.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '5.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '13.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '13.0'
96
+ description: Y::ActionCable::Client joins a yrby document over Action Cable the way
97
+ a browser does. Y::Collaborator takes part in a document the way a person does.
98
+ Y::Agent is a collaborator that hands what people changed to a model through RubyLLM,
99
+ with tools that read the document and edit its blocks live.
100
+ email:
101
+ - johnpcamara@gmail.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - CHANGELOG-agent.md
107
+ - LICENSE
108
+ - lib/y/action_cable/client.rb
109
+ - lib/y/action_cable/client/outbox.rb
110
+ - lib/y/action_cable/client/session.rb
111
+ - lib/y/agent.rb
112
+ - lib/y/agent/tools.rb
113
+ - lib/y/collaborator.rb
114
+ - lib/y/collaborator/lock.rb
115
+ - lib/yrby-agent.rb
116
+ - lib/yrby/agent/version.rb
117
+ homepage: https://github.com/jpcamara/yrby
118
+ licenses:
119
+ - MIT
120
+ metadata:
121
+ source_code_uri: https://github.com/jpcamara/yrby
122
+ changelog_uri: https://github.com/jpcamara/yrby/blob/main/CHANGELOG-agent.md
123
+ bug_tracker_uri: https://github.com/jpcamara/yrby/issues
124
+ rubygems_mfa_required: 'true'
125
+ rdoc_options: []
126
+ require_paths:
127
+ - lib
128
+ required_ruby_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: 3.4.0
133
+ required_rubygems_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ requirements: []
139
+ rubygems_version: 3.6.9
140
+ specification_version: 4
141
+ summary: Ruby processes and AI agents that edit yrby documents alongside people
142
+ test_files: []