graphmind 0.6.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 +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE +21 -0
- data/README.md +464 -0
- data/lib/graphmind/api.rb +267 -0
- data/lib/graphmind/clock.rb +49 -0
- data/lib/graphmind/edit_guard.rb +87 -0
- data/lib/graphmind/env.rb +83 -0
- data/lib/graphmind/errors.rb +40 -0
- data/lib/graphmind/gate_engine.rb +282 -0
- data/lib/graphmind/held_ledger.rb +209 -0
- data/lib/graphmind/ids.rb +52 -0
- data/lib/graphmind/integrations/ruby_llm.rb +449 -0
- data/lib/graphmind/integrations/ruby_openai.rb +388 -0
- data/lib/graphmind/integrations/support.rb +459 -0
- data/lib/graphmind/jack.rb +201 -0
- data/lib/graphmind/loop_guard.rb +516 -0
- data/lib/graphmind/protocol.rb +123 -0
- data/lib/graphmind/railtie.rb +66 -0
- data/lib/graphmind/redaction.rb +781 -0
- data/lib/graphmind/ring_buffer.rb +75 -0
- data/lib/graphmind/runtime.rb +50 -0
- data/lib/graphmind/safe.rb +48 -0
- data/lib/graphmind/session.rb +1180 -0
- data/lib/graphmind/shrink.rb +787 -0
- data/lib/graphmind/token_batcher.rb +84 -0
- data/lib/graphmind/transport.rb +331 -0
- data/lib/graphmind/version.rb +5 -0
- data/lib/graphmind/websocket.rb +448 -0
- data/lib/graphmind/wrap.rb +295 -0
- data/lib/graphmind.rb +54 -0
- metadata +83 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Graphmind
|
|
4
|
+
# Batches streamed deltas into `node.token` events.
|
|
5
|
+
#
|
|
6
|
+
# A streaming LLM produces tokens far faster than a canvas can paint, and one
|
|
7
|
+
# WebSocket frame per token would swamp both the socket and the viewer. Every
|
|
8
|
+
# delta lands in a per-node list, and a single background thread flushes them
|
|
9
|
+
# on a fixed interval (~30 Hz by default) or when the node finishes.
|
|
10
|
+
class TokenBatcher
|
|
11
|
+
def initialize(sink, interval = 0.034)
|
|
12
|
+
@sink = sink
|
|
13
|
+
@interval = interval
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@pending = {}
|
|
16
|
+
@thread = nil
|
|
17
|
+
@disposed = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def push(node_id, channel, value)
|
|
21
|
+
return if @disposed || value.nil? || value.empty?
|
|
22
|
+
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
(@pending[node_id] ||= []) << { "t" => channel, "v" => value.to_s }
|
|
25
|
+
ensure_thread
|
|
26
|
+
end
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Flush one node's pending deltas immediately (called before node.finished
|
|
31
|
+
# so a finished node never has tokens arriving after its result).
|
|
32
|
+
def flush_node(node_id)
|
|
33
|
+
deltas = @mutex.synchronize { @pending.delete(node_id) }
|
|
34
|
+
emit(node_id, deltas)
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def flush_all
|
|
39
|
+
pending = @mutex.synchronize do
|
|
40
|
+
snapshot = @pending
|
|
41
|
+
@pending = {}
|
|
42
|
+
snapshot
|
|
43
|
+
end
|
|
44
|
+
pending.each { |node_id, deltas| emit(node_id, deltas) }
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dispose
|
|
49
|
+
@disposed = true
|
|
50
|
+
thread = @mutex.synchronize do
|
|
51
|
+
t = @thread
|
|
52
|
+
@thread = nil
|
|
53
|
+
t
|
|
54
|
+
end
|
|
55
|
+
thread&.kill
|
|
56
|
+
flush_all
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Called with the mutex held.
|
|
63
|
+
def ensure_thread
|
|
64
|
+
return if @thread || @disposed
|
|
65
|
+
|
|
66
|
+
@thread = Thread.new do
|
|
67
|
+
until @disposed
|
|
68
|
+
sleep(@interval)
|
|
69
|
+
flush_all
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
@thread.name = "graphmind-tokens" if @thread.respond_to?(:name=)
|
|
73
|
+
@thread.report_on_exception = false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def emit(node_id, deltas)
|
|
77
|
+
return if deltas.nil? || deltas.empty?
|
|
78
|
+
|
|
79
|
+
@sink.call(node_id, deltas)
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protocol"
|
|
4
|
+
require_relative "websocket"
|
|
5
|
+
|
|
6
|
+
module Graphmind
|
|
7
|
+
# WebSocket transport: lazy connect with a hard connect timeout, the
|
|
8
|
+
# hello/hello.ack handshake, background reconnect, and fail-open detach.
|
|
9
|
+
#
|
|
10
|
+
# Port of packages/client/src/transport.ts. It never raises into callers:
|
|
11
|
+
# every failure degrades to "detached" plus a rate-limited warning, and a
|
|
12
|
+
# background retry keeps trying.
|
|
13
|
+
#
|
|
14
|
+
# Threading: one supervisor thread owns the socket and the read loop; while
|
|
15
|
+
# attached it spawns one writer thread that drains the outbox, so a host
|
|
16
|
+
# thread calling #enqueue never blocks on the network. Both are ordinary
|
|
17
|
+
# (non-main) threads, so they cannot keep a Ruby process alive at exit.
|
|
18
|
+
#
|
|
19
|
+
# Reconnect timing has two regimes, because they are not the same problem:
|
|
20
|
+
# * never attached (no debugger running) — retry every retry_interval;
|
|
21
|
+
# * lost an established attachment (a blip) — the debugger existed
|
|
22
|
+
# milliseconds ago and every millisecond dark eats the ring buffer, so
|
|
23
|
+
# burst first (FAST_RETRY_STEPS) and then settle.
|
|
24
|
+
class Transport
|
|
25
|
+
FAST_RETRY_STEPS = [0.2, 0.4, 0.8].freeze
|
|
26
|
+
|
|
27
|
+
# Hard cap on frames waiting for a slow socket. Oldest are dropped first;
|
|
28
|
+
# the ring buffer, not this queue, is what guarantees replay-on-attach.
|
|
29
|
+
MAX_OUTBOX = 10_000
|
|
30
|
+
|
|
31
|
+
Hooks = Struct.new(:build_hello, :on_attached, :on_detached, :on_control, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
attr_reader :url
|
|
34
|
+
|
|
35
|
+
def initialize(url:, hooks:, warner:, connect_timeout: 0.3, handshake_timeout: 1.0,
|
|
36
|
+
retry_interval: 10.0)
|
|
37
|
+
@url = url
|
|
38
|
+
@hooks = hooks
|
|
39
|
+
@warner = warner
|
|
40
|
+
@connect_timeout = connect_timeout
|
|
41
|
+
@handshake_timeout = handshake_timeout
|
|
42
|
+
@retry_interval = retry_interval
|
|
43
|
+
|
|
44
|
+
@state = :idle # :idle | :connecting | :attached | :disposed
|
|
45
|
+
@started = false
|
|
46
|
+
@mutex = Mutex.new
|
|
47
|
+
@wake = ConditionVariable.new
|
|
48
|
+
@kicked = false
|
|
49
|
+
@supervisor = nil
|
|
50
|
+
@connection = nil
|
|
51
|
+
@outbox = nil
|
|
52
|
+
@writer = nil
|
|
53
|
+
@fast_retry_step = FAST_RETRY_STEPS.length
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def attached? = @state == :attached
|
|
57
|
+
def state = @state
|
|
58
|
+
|
|
59
|
+
# Begin connecting (idempotent). Called lazily on first session use.
|
|
60
|
+
def start
|
|
61
|
+
return if @started || @state == :disposed
|
|
62
|
+
|
|
63
|
+
@started = true
|
|
64
|
+
@supervisor = Thread.new { supervise }
|
|
65
|
+
@supervisor.name = "graphmind-transport" if @supervisor.respond_to?(:name=)
|
|
66
|
+
@supervisor.report_on_exception = false
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Force an attempt now instead of waiting out the retry interval.
|
|
71
|
+
def kick
|
|
72
|
+
return if @state == :disposed
|
|
73
|
+
return start unless @started
|
|
74
|
+
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
@kicked = true
|
|
77
|
+
@wake.broadcast
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Queue one serialized frame. Non-blocking, safe from any thread.
|
|
83
|
+
def enqueue(frame)
|
|
84
|
+
return false unless @state == :attached
|
|
85
|
+
|
|
86
|
+
outbox = @outbox
|
|
87
|
+
return false if outbox.nil?
|
|
88
|
+
|
|
89
|
+
# Drop-oldest so a stalled socket cannot grow without bound.
|
|
90
|
+
begin
|
|
91
|
+
outbox.pop(true) while outbox.size >= MAX_OUTBOX
|
|
92
|
+
rescue ThreadError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
outbox << frame
|
|
96
|
+
true
|
|
97
|
+
rescue StandardError
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Approximate number of frames still waiting to be written.
|
|
102
|
+
def pending = (@outbox&.size || 0)
|
|
103
|
+
|
|
104
|
+
def dispose
|
|
105
|
+
return if @state == :disposed
|
|
106
|
+
|
|
107
|
+
@state = :disposed
|
|
108
|
+
teardown_connection
|
|
109
|
+
@mutex.synchronize { @wake.broadcast }
|
|
110
|
+
supervisor = @supervisor
|
|
111
|
+
@supervisor = nil
|
|
112
|
+
supervisor&.join(0.5)
|
|
113
|
+
supervisor&.kill if supervisor&.alive?
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def supervise
|
|
120
|
+
until @state == :disposed
|
|
121
|
+
@mutex.synchronize { @kicked = false }
|
|
122
|
+
begin
|
|
123
|
+
attempt
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
@warner.warn("transport-loop", "transport loop error", e)
|
|
126
|
+
end
|
|
127
|
+
break if @state == :disposed
|
|
128
|
+
|
|
129
|
+
wait_for_retry
|
|
130
|
+
end
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
@warner.warn("transport-loop", "transport supervisor stopped", e)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def wait_for_retry
|
|
136
|
+
delay = next_retry_delay
|
|
137
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay
|
|
138
|
+
@mutex.synchronize do
|
|
139
|
+
loop do
|
|
140
|
+
break if @kicked || @state == :disposed
|
|
141
|
+
|
|
142
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
143
|
+
break if remaining <= 0
|
|
144
|
+
|
|
145
|
+
@wake.wait(@mutex, remaining)
|
|
146
|
+
end
|
|
147
|
+
@kicked = false
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def next_retry_delay
|
|
152
|
+
step = FAST_RETRY_STEPS[@fast_retry_step]
|
|
153
|
+
return @retry_interval if step.nil?
|
|
154
|
+
|
|
155
|
+
@fast_retry_step += 1
|
|
156
|
+
step < @retry_interval ? step : @retry_interval
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def attempt
|
|
160
|
+
return if @state == :disposed
|
|
161
|
+
|
|
162
|
+
@state = :connecting
|
|
163
|
+
connection =
|
|
164
|
+
begin
|
|
165
|
+
WebSocket.connect(@url, connect_timeout: @connect_timeout,
|
|
166
|
+
handshake_timeout: @handshake_timeout)
|
|
167
|
+
rescue WebSocket::Error => e
|
|
168
|
+
@state = :idle
|
|
169
|
+
@warner.warn("transport-connect", "could not reach the GraphMind viewer; staying detached", e)
|
|
170
|
+
return
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
@state = :idle
|
|
173
|
+
@warner.warn("transport-connect", "failed to open the viewer WebSocket", e)
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
was_attached = false
|
|
178
|
+
begin
|
|
179
|
+
connection.send_text(@hooks.build_hello.call)
|
|
180
|
+
ack = await_ack(connection)
|
|
181
|
+
return if ack.nil?
|
|
182
|
+
|
|
183
|
+
@connection = connection
|
|
184
|
+
@outbox = Thread::Queue.new
|
|
185
|
+
@state = :attached
|
|
186
|
+
was_attached = true
|
|
187
|
+
start_writer(connection, @outbox)
|
|
188
|
+
safely { @hooks.on_attached.call(ack) }
|
|
189
|
+
read_loop(connection)
|
|
190
|
+
rescue WebSocket::Error => e
|
|
191
|
+
@warner.warn("transport-io", "viewer connection lost", e)
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
@warner.warn("transport-io", "viewer connection error", e)
|
|
194
|
+
ensure
|
|
195
|
+
@state = :idle unless @state == :disposed
|
|
196
|
+
stop_writer
|
|
197
|
+
@connection = nil
|
|
198
|
+
@outbox = nil
|
|
199
|
+
begin
|
|
200
|
+
connection.abort!
|
|
201
|
+
rescue StandardError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
if was_attached
|
|
205
|
+
# Losing a live attachment arms the fast-reconnect burst.
|
|
206
|
+
@fast_retry_step = 0
|
|
207
|
+
# FAIL-OPEN: no debugger, no holds.
|
|
208
|
+
safely { @hooks.on_detached.call }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Read frames until hello.ack, honouring the handshake budget. Returns the
|
|
214
|
+
# ack payload, or nil when the connection must be abandoned.
|
|
215
|
+
def await_ack(connection)
|
|
216
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @handshake_timeout
|
|
217
|
+
loop do
|
|
218
|
+
message = connection.read_message(deadline: deadline)
|
|
219
|
+
if message.nil?
|
|
220
|
+
@warner.warn(
|
|
221
|
+
"transport-ack-timeout",
|
|
222
|
+
"viewer did not complete the handshake within #{(@handshake_timeout * 1000).round}ms; staying detached"
|
|
223
|
+
)
|
|
224
|
+
return nil
|
|
225
|
+
end
|
|
226
|
+
return nil if message == :closed
|
|
227
|
+
|
|
228
|
+
result = Protocol.parse_envelope_json(message)
|
|
229
|
+
case result.kind
|
|
230
|
+
when :version_mismatch
|
|
231
|
+
@warner.warn(
|
|
232
|
+
"transport-version",
|
|
233
|
+
"viewer speaks protocol v#{result.received}, this client speaks " \
|
|
234
|
+
"v#{Protocol::PROTOCOL_VERSION}; staying detached"
|
|
235
|
+
)
|
|
236
|
+
return nil
|
|
237
|
+
when :ok
|
|
238
|
+
envelope = result.envelope
|
|
239
|
+
next unless envelope["type"] == "hello.ack"
|
|
240
|
+
|
|
241
|
+
payload = envelope["payload"] || {}
|
|
242
|
+
versions = payload["versions"] || {}
|
|
243
|
+
if versions["protocol"] != Protocol::PROTOCOL_VERSION
|
|
244
|
+
@warner.warn(
|
|
245
|
+
"transport-version",
|
|
246
|
+
"viewer acked protocol v#{versions['protocol']}, this client speaks " \
|
|
247
|
+
"v#{Protocol::PROTOCOL_VERSION}; staying detached"
|
|
248
|
+
)
|
|
249
|
+
return nil
|
|
250
|
+
end
|
|
251
|
+
return payload
|
|
252
|
+
end
|
|
253
|
+
# invalid / unknown-type before the ack: ignore and keep reading.
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def read_loop(connection)
|
|
258
|
+
while @state == :attached
|
|
259
|
+
message = connection.read_message
|
|
260
|
+
break if message.nil? || message == :closed
|
|
261
|
+
|
|
262
|
+
result = Protocol.parse_envelope_json(message)
|
|
263
|
+
case result.kind
|
|
264
|
+
when :ok
|
|
265
|
+
safely { @hooks.on_control.call(result.envelope) }
|
|
266
|
+
when :invalid
|
|
267
|
+
@warner.warn("transport-invalid", "ignoring invalid frame: #{result.reason}")
|
|
268
|
+
end
|
|
269
|
+
# unknown-type / version-mismatch mid-stream: tolerate silently.
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def start_writer(connection, outbox)
|
|
274
|
+
@writer = Thread.new do
|
|
275
|
+
loop do
|
|
276
|
+
frame = outbox.pop
|
|
277
|
+
break if frame.nil? || frame.equal?(STOP)
|
|
278
|
+
|
|
279
|
+
begin
|
|
280
|
+
connection.send_text(frame)
|
|
281
|
+
rescue StandardError
|
|
282
|
+
# The socket is gone; the read loop will notice and reconnect.
|
|
283
|
+
begin
|
|
284
|
+
connection.abort!
|
|
285
|
+
rescue StandardError
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
break
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
@writer.name = "graphmind-writer" if @writer.respond_to?(:name=)
|
|
293
|
+
@writer.report_on_exception = false
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def stop_writer
|
|
297
|
+
writer = @writer
|
|
298
|
+
@writer = nil
|
|
299
|
+
return if writer.nil?
|
|
300
|
+
|
|
301
|
+
begin
|
|
302
|
+
@outbox&.<<(STOP)
|
|
303
|
+
rescue StandardError
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
writer.join(0.2)
|
|
307
|
+
writer.kill if writer.alive?
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def teardown_connection
|
|
311
|
+
connection = @connection
|
|
312
|
+
@connection = nil
|
|
313
|
+
return if connection.nil?
|
|
314
|
+
|
|
315
|
+
begin
|
|
316
|
+
connection.abort!
|
|
317
|
+
rescue StandardError
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def safely
|
|
323
|
+
yield
|
|
324
|
+
rescue StandardError => e
|
|
325
|
+
@warner.warn("transport-hook", "internal error in a transport callback", e)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
STOP = Object.new
|
|
329
|
+
private_constant :STOP
|
|
330
|
+
end
|
|
331
|
+
end
|