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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 65f8a0b777a314a5844f7f7d236c93026246a2c365ae227a1453f720d72a1f2c
4
+ data.tar.gz: 21f074893c16ac7e57a21285497393820a4317ec0d016f348fdb0722e4b05bf8
5
+ SHA512:
6
+ metadata.gz: 1aeb1bac00a67068549d9195b7b1dcbbe7d9dfb028aff2c08ab1e8290832c60cec61c403ed7238437a4ba2874a4a51247206638f8a910d54a6044adc6f03f51c
7
+ data.tar.gz: 49016c19497543ea0f44d216708879f497a7ceec2608d466b5a5f369f0bbcc739afd4bdff96b467cd6aba134794100f6b423b5d4ac651f00664017765f8ff2c4
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog — graphmind (Ruby)
2
+
3
+ All notable changes to the Ruby SDK. The wire protocol version is tracked
4
+ separately (`gm: 1`) and is shared with the TypeScript and Python SDKs.
5
+
6
+ ## 0.1.0 — unreleased
7
+
8
+ First release of the Ruby SDK.
9
+
10
+ ### Core
11
+
12
+ - WebSocket transport to `ws://127.0.0.1:4747/ingest` (`GRAPHMIND_URL` to
13
+ override): lazy connect, `hello` / `hello.ack` handshake carrying breakpoints
14
+ and mode, background reconnect with a fast burst after an established
15
+ attachment drops.
16
+ - Hand-rolled RFC 6455 client over `TCPSocket` — the gem has **no runtime
17
+ dependencies**.
18
+ - Gate engine that genuinely holds the calling thread, and resumes with
19
+ `continue` / `retry` / `inject` / `abort`.
20
+ - Bounded ring buffer (count + bytes, drop-oldest) with replay-on-attach.
21
+ - Fail-open everywhere: disconnect, `dispose`, and interpreter exit release
22
+ every held gate. Optional `pause_timeout:`.
23
+ - Kill switches: `GRAPHMIND_DISABLED=1`, production-environment detection,
24
+ explicit `enabled:`.
25
+
26
+ ### Instrumentation
27
+
28
+ - `Graphmind.run`, `Graphmind.span`, `Graphmind.tool`, `Graphmind.wrap_tools`,
29
+ `Graphmind.wrap_method`, and the `Graphmind::Instrument` class macro.
30
+ - `ruby-openai`: `chat` and `responses.create` gated as `llm:step`, with token
31
+ usage and streamed deltas.
32
+ - `ruby_llm`: one `llm:step` per provider round-trip, plus gated
33
+ `RubyLLM::Tool#call` with working `inject`.
34
+ - Injected values are coerced into the type each call site expects.
35
+
36
+ ### Rails
37
+
38
+ - A Railtie that names the app, clears the per-thread run context on
39
+ `executor.to_complete`, and disposes at exit.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GraphMind (Mohamed Hegazy)
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.
data/README.md ADDED
@@ -0,0 +1,464 @@
1
+ # graphmind (Ruby)
2
+
3
+ **A live debugger for AI agents.** Phoenix and Langfuse show you what your agent
4
+ *did*. GraphMind attaches while it is happening: your instrumented app streams
5
+ execution events to a local viewer, which renders the run as a live graph and
6
+ can **hold execution** — before an LLM step, before/after a tool call, or on
7
+ error — then resume with **continue / retry / inject / abort**.
8
+
9
+ Local-first (`127.0.0.1`), **zero runtime dependencies**, and it fails open: with
10
+ no debugger attached everything is a no-op, and a debugger that disconnects
11
+ mid-hold releases every gate.
12
+
13
+ ---
14
+
15
+ ## Install
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ group :development, :test do
20
+ gem "graphmind"
21
+ end
22
+ ```
23
+
24
+ Then start the debugger (Node, from npm — the viewer and server are shared by
25
+ every language SDK):
26
+
27
+ ```sh
28
+ npx graphmind-ai # serves http://127.0.0.1:4747 and opens the viewer
29
+ ```
30
+
31
+ ## Quickstart
32
+
33
+ ```ruby
34
+ require "graphmind"
35
+
36
+ gm = Graphmind.configure(app: "support-agent")
37
+
38
+ search = gm.tool("search_flights") do |origin:, destination:|
39
+ Flights.search(origin, destination)
40
+ end
41
+
42
+ client = gm.instrument_openai(OpenAI::Client.new(access_token: ENV.fetch("OPENAI_API_KEY")))
43
+
44
+ gm.run("handle-ticket") do
45
+ client.chat(parameters: {
46
+ model: "gpt-4o-mini",
47
+ messages: [{ role: "user", content: "cheapest LHR to JFK next Tuesday?" }]
48
+ })
49
+ search.call(origin: "LHR", destination: "JFK")
50
+ end
51
+ ```
52
+
53
+ Open the viewer and you get a live graph: an `agent:handle-ticket` node, an
54
+ `llm:step` node with the model, prompt and token usage, and a
55
+ `tool:search_flights` node with its arguments and result. Click a node to set a
56
+ breakpoint; the next run **stops there**, and you can edit the tool's result
57
+ before letting it continue.
58
+
59
+ With nothing attached, every line above is a plain method call.
60
+
61
+ ### Waiting for the debugger
62
+
63
+ ```ruby
64
+ Graphmind.ready(5) # true once attached, false on timeout — never raises
65
+ ```
66
+
67
+ `false` means "carry on detached". It is not an error.
68
+
69
+ ---
70
+
71
+ ## What you can instrument
72
+
73
+ ### Tools and plain methods
74
+
75
+ Blocks:
76
+
77
+ ```ruby
78
+ search = Graphmind.tool("search") { |query:| Index.query(query) }
79
+ search.call(query: "flights") # gated: before / error / after
80
+ ```
81
+
82
+ Any callable, keeping its shape:
83
+
84
+ ```ruby
85
+ tools = Graphmind.wrap_tools(
86
+ search: ->(q) { Index.query(q) },
87
+ book: method(:book_flight)
88
+ )
89
+ tools[:search].call("flights")
90
+ ```
91
+
92
+ One method on one object, in place:
93
+
94
+ ```ruby
95
+ Graphmind.wrap_method(flight_service, :search)
96
+ flight_service.search("LHR", "JFK") # now pauses
97
+ ```
98
+
99
+ Every instance of a class:
100
+
101
+ ```ruby
102
+ class FlightTools
103
+ include Graphmind::Instrument
104
+ graphmind_tool :search, :book
105
+
106
+ def search(origin, destination) = ...
107
+ end
108
+ ```
109
+
110
+ ### Anything else: spans
111
+
112
+ ```ruby
113
+ Graphmind.span("plan", kind: "chain", input: { goal: goal }) do |span|
114
+ steps = planner.plan(goal)
115
+ span.output = steps # what the node shows; also the span's return value
116
+ steps
117
+ end
118
+ ```
119
+
120
+ `kind` is any node kind in the wire contract: `agent`, `llm`, `tool`, `chain`,
121
+ `retriever`, `server`, `resource`, `prompt`, `custom`.
122
+
123
+ ### ruby-openai
124
+
125
+ ```ruby
126
+ client = Graphmind.instrument_openai(OpenAI::Client.new(access_token: ...))
127
+ client.chat(parameters: { model: "gpt-4o-mini", messages: [...] })
128
+ client.responses.create(parameters: { model: "gpt-4o-mini", input: "hi" })
129
+ ```
130
+
131
+ One `llm:step` node per call, with the model, every message in full, the
132
+ sampling parameters, the tools by schema hash, the reply text, the requested tool
133
+ calls, the normalized finish reason and token usage. Streaming (`stream:`) forwards deltas to the canvas and still
134
+ calls your own handler with the arity it declared.
135
+
136
+ The module is prepended to **that client's singleton class**: nothing global is
137
+ monkey-patched, and another client in the same process is untouched.
138
+
139
+ ### ruby_llm
140
+
141
+ ```ruby
142
+ chat = Graphmind.instrument_ruby_llm(RubyLLM.chat.with_tools(Weather))
143
+ chat.ask("what's the weather in Cairo?")
144
+ ```
145
+
146
+ One `llm:step` node per provider round-trip (not per `ask`), plus a
147
+ `tool:<name>` node per `RubyLLM::Tool#call` — where `inject` genuinely replaces
148
+ the tool result the model sees next.
149
+
150
+ **Token usage is inclusive** (0.6+) in both integrations: `inputTokens` is the
151
+ whole prompt, cached tokens included, and the usage carries `inclusive: true`.
152
+ ruby_llm counts input WITHOUT cached tokens (2.0's `tokens.input` is "standard,
153
+ non-cached"), so the cache reads and writes are added back; `cacheReadTokens`,
154
+ `cacheWriteTokens` and `reasoningTokens` appear only when reported.
155
+
156
+ ### Injecting values from the viewer
157
+
158
+ Whatever you type in the viewer arrives as JSON, so the integrations coerce it
159
+ into what the call site expects:
160
+
161
+ | Gate | Type a string and you get |
162
+ | --- | --- |
163
+ | `tool:*` (blocks, methods, `RubyLLM::Tool`) | the string, as the tool's return value |
164
+ | ruby-openai `llm:step` | a `chat.completion`-shaped Hash whose message content is your string |
165
+ | ruby_llm `llm:step` | a `RubyLLM::Message` with `role: :assistant` |
166
+
167
+ An object works too and is passed through / built as-is.
168
+
169
+ **Refused, whatever the debugger (0.6+):** a value that still holds
170
+ `"__REDACTED__"` or a truncated preview — a hidden or shortened *recording*
171
+ pasted back, not a result anyone meant to substitute — is never injected. The
172
+ call stays paused and the debugger shows why (`exec.refused`; an older debugger
173
+ that cannot show it gets a line in your log instead). Inject the full value, or
174
+ continue, retry or abort. This gem does not run calls with **edited
175
+ arguments** (the TypeScript and Python SDKs do): an edit sent to a Ruby app is
176
+ refused the same way and the call stays paused. Every answer echoes the
177
+ debugger's `requestId`, and the gem says so in its handshake (`request-id`),
178
+ so the debugger never credits a release the app made on its own (a pause
179
+ timeout) to someone's resume.
180
+
181
+ ---
182
+
183
+ ## Capability matrix
184
+
185
+ | Surface | Node | `before` | `after` | `error` | `inject` | `retry` | `abort` | Streaming |
186
+ | --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
187
+ | `Graphmind.tool` / `wrap_tools` / `wrap_method` / `Graphmind::Instrument` | `tool:<name>` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
188
+ | `Graphmind.span` | `<kind>:<name>` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
189
+ | `Graphmind.run` | `agent:<name>` | — | — | — | — | — | ✅¹ | — |
190
+ | ruby-openai `chat` / `responses.create` | `llm:step` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ observe |
191
+ | ruby_llm provider round-trip | `llm:step` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️² |
192
+ | ruby_llm `Tool#call` | `tool:<name>` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
193
+
194
+ ¹ A run has no gate of its own; `abort` at any gate inside it marks the run
195
+ aborted and raises `Graphmind::AbortError`.
196
+ ² A streaming `ask` is gated like any other round-trip, but individual deltas
197
+ are not forwarded to the canvas yet (ruby-openai's are).
198
+
199
+ `after` gates fire only in step mode or with an explicit `after` breakpoint
200
+ (this matches the TypeScript and Python SDKs).
201
+
202
+ ---
203
+
204
+ ## Rails
205
+
206
+ Add the gem and you get a Railtie. It does three things:
207
+
208
+ 1. names the app after your Rails application;
209
+ 2. clears the per-thread run context via `Rails.application.executor.to_complete`
210
+ — Puma reuses threads, so a run left behind by a request that died mid-flight
211
+ would otherwise adopt the *next* request's events;
212
+ 3. releases held gates and closes the socket at exit.
213
+
214
+ Configure it from `config/application.rb` or an initializer:
215
+
216
+ ```ruby
217
+ # config/initializers/graphmind.rb
218
+ Rails.application.configure do
219
+ config.graphmind.enabled = Rails.env.development?
220
+ config.graphmind.app = "support-agent"
221
+ config.graphmind.url = "ws://127.0.0.1:4747/ingest" # or GRAPHMIND_URL
222
+ end
223
+ ```
224
+
225
+ Every key is passed to `Graphmind.configure`. A key it does not understand is a
226
+ warning at boot, never a failed deploy. `config.graphmind.autoconfigure = false`
227
+ leaves configuration entirely to you.
228
+
229
+ Without Rails, do the same thing in one line at boot:
230
+
231
+ ```ruby
232
+ Graphmind.configure(app: "my-agent", enabled: ENV["RACK_ENV"] != "production")
233
+ ```
234
+
235
+ ### Threads: Puma, Sidekiq, and friends
236
+
237
+ The run context lives in **fiber-local storage** (`Thread.current[:graphmind_run_context]`).
238
+ Concretely:
239
+
240
+ * **Puma / Falcon / any threaded server** — each request runs on its own thread
241
+ (its root fiber), so `Graphmind.run` inside a controller action is scoped to
242
+ exactly that request. Two concurrent requests get two runs and never see each
243
+ other's context. Verified in `test/test_threading.rb`.
244
+ * **Sidekiq** — a job is a thread. Wrap `perform` in `Graphmind.run("MyJob")`
245
+ and the whole job is one run.
246
+ * **A gate holds only the thread that hit it.** Other Puma threads keep serving
247
+ while one request sits at a breakpoint. This is the single most important
248
+ property to understand: pausing a request in a threaded server does *not*
249
+ freeze the server.
250
+ * **Threads you spawn yourself do not inherit the run context** (this is how
251
+ Ruby's fiber-locals work). Carry it explicitly:
252
+
253
+ ```ruby
254
+ Graphmind.run("batch") do |ctx|
255
+ items.map { |i| Thread.new { Graphmind.with_run_context(ctx) { process(i) } } }.each(&:join)
256
+ end
257
+ ```
258
+
259
+ The ruby_llm integration does this for you when ruby_llm runs tool calls on a
260
+ thread pool.
261
+ * **Forking servers (Puma clustered, Unicorn, Resque)** — GraphMind's ids are
262
+ re-seeded after a fork, and each worker opens its own WebSocket. Every worker
263
+ shows up as its own client in the viewer.
264
+ * **The transport never blocks your threads.** One background thread owns the
265
+ socket and one drains a bounded outbox; `emit` is a serialize plus an enqueue
266
+ (~2 µs measured, see below).
267
+
268
+ ---
269
+
270
+ ## Recording less, and the loop hold (0.5)
271
+
272
+ **Recording less.** Four kill switches replace values with `"__REDACTED__"` inside the gem, before the
273
+ event is buffered or sent, so a debugger that attaches late, the database and every export only ever
274
+ see the placeholder. Set them in the environment of the instrumented app, or as options:
275
+
276
+ ```ruby
277
+ Graphmind.configure(hide_inputs: true, hide_outputs: true, hide_tool_args: true, hide_tool_results: true)
278
+ ```
279
+
280
+ | Switch | Replaces |
281
+ | --- | --- |
282
+ | `GRAPHMIND_HIDE_INPUTS` / `hide_inputs:` | every node's `input`; streamed tool-argument deltas are emptied |
283
+ | `GRAPHMIND_HIDE_OUTPUTS` / `hide_outputs:` | every node's `output`; every streamed delta is emptied (`v: ""`, `chars` kept) |
284
+ | `GRAPHMIND_HIDE_TOOL_ARGS` / `hide_tool_args:` | tool nodes' `input` only |
285
+ | `GRAPHMIND_HIDE_TOOL_RESULTS` / `hide_tool_results:` | tool nodes' `output` only |
286
+
287
+ Any env value except empty, `0`, `false`, `off` and `no` (any case) turns a switch on — an unexpected spelling such as `yes` errs towards hiding; options accept `true`, `1` and the same strings. Either source turning a switch
288
+ on turns it on. Hashes with Symbol keys are redacted too. The same keywords work on
289
+ `Graphmind::Session.new`; `session.redaction_switches` shows what is in effect.
290
+
291
+ **The loop hold.** While a debugger is attached, the third call of the same tool with identical
292
+ arguments made **back-to-back** — no other tool call in between — is held before it runs (`exec.paused` with `reason: "loop"`). It applies to anything
293
+ that goes through `Graphmind::Wrap` — `gm.tool`, `gm.wrap`, `wrap_tools`, `wrap_method`, spans, and the
294
+ ruby_openai / ruby_llm tool paths — and holds threads individually (per Puma worker). Detached, or with
295
+ `GRAPHMIND_ON_LOOP=warn`, nothing is held and one line per streak is logged, naming the tool but never
296
+ its arguments.
297
+
298
+ ```ruby
299
+ Graphmind.configure(app: "shop", loop_guard: { threshold: 5, mode: "warn", allow_nodes: [:poll_job] }) # or loop_guard: false
300
+ ```
301
+
302
+ Back-to-back is per run: calling any other tool, or the same tool with other arguments, starts the
303
+ count again, while LLM steps in between do not (model → tool → model → tool with identical calls is
304
+ still a loop) and tools in `allow_nodes` / `GRAPHMIND_LOOP_ALLOW` are invisible. So a constant-argument
305
+ tool called now and again across a long session is never held. Accepted limit: an agent alternating
306
+ between two tools (search, read, search, read) is not held.
307
+
308
+ Env: `GRAPHMIND_LOOP_THRESHOLD` (default 3, `0` off), `GRAPHMIND_ON_LOOP=pause|warn|off`,
309
+ `GRAPHMIND_LOOP_ALLOW=poll_job,tool:heartbeat`. Symbol, String and camelCase option keys are accepted.
310
+ "Identical" means canonical JSON: Symbol and String keys and values count as the same, structs and other
311
+ `#to_h` objects compare by `to_h`, other objects by `#inspect`, MCP's `_meta` is ignored and pagination
312
+ keys are not; the fingerprint uses the full arguments even though the recorded input is truncated at 200
313
+ items. Under `hide_inputs` (or `hide_tool_args` on a tool) the fingerprint is sent as `"__REDACTED__"`.
314
+ `session.loop_guard_config` shows the resolved settings.
315
+
316
+ **Limits.**
317
+ - `node.error` (exception messages and tracebacks) is never redacted — error text can echo data.
318
+ - Redaction fails closed: if a payload cannot be inspected safely while a switch is on (a mapping whose
319
+ reads raise, a malformed payload), the event is sent with input/output/token text hidden and
320
+ `redaction.failed: true`, or dropped with one warning if even its identity fields are unreadable. The
321
+ raw value is never sent and nothing is raised into your app.
322
+ - The tool-only switches hide the tool node's own input/output. In an agent loop the same values also
323
+ travel through the model's messages; set `GRAPHMIND_HIDE_INPUTS` and `GRAPHMIND_HIDE_OUTPUTS` to keep
324
+ them out entirely.
325
+ - Hidden streamed text keeps its length (`chars`, counted in UTF-16 units like JavaScript).
326
+ - Tools that legitimately poll with byte-identical arguments need `allow_nodes` or `GRAPHMIND_LOOP_ALLOW`.
327
+ - LLM steps are not watched unless `kinds` includes `"llm"` (each kind keeps its own streak); identical
328
+ calls made in parallel are back-to-back too, so they count.
329
+ - Cross-language equivalence is guaranteed for JSON-shaped arguments and checked against the TypeScript
330
+ client by two shared fixtures and a generated differential file.
331
+ - Ruby strings cannot hold unpaired UTF-16 surrogates; such values are treated as unreadable (never a repeat).
332
+
333
+ ## Kill switches and safety
334
+
335
+ | Setting | Effect |
336
+ | --- | --- |
337
+ | `GRAPHMIND_DISABLED=1` | Off, always. Beats an explicit `enabled: true` in code. |
338
+ | production-looking env | Off unless `GRAPHMIND=1`. |
339
+ | `enabled: false` | Off. |
340
+ | `GRAPHMIND_URL` | Where to dial (default `ws://127.0.0.1:4747/ingest`). |
341
+
342
+ "Production-looking" is a boring, documented rule: the first variable that is
343
+ *set* out of `GRAPHMIND_ENV`, `ENVIRONMENT`, `APP_ENV`, `RAILS_ENV`, `RACK_ENV`,
344
+ `ENV`, `NODE_ENV` decides, and it counts as production when its value is
345
+ `production` or `prod`. No hostname or cloud-metadata guessing.
346
+
347
+ The invariants, all covered by tests:
348
+
349
+ * **Never raises into your app.** Internal failures degrade to a no-op plus one
350
+ rate-limited warning per problem per minute. Your own exceptions propagate
351
+ untouched; the only exception GraphMind adds is `Graphmind::AbortError`, and
352
+ only when you asked for it in the viewer.
353
+ * **Fails open.** Disconnect, dispose, or interpreter exit releases every held
354
+ gate with `continue`. There is also an optional `pause_timeout:`.
355
+ * **Bounded memory.** Events buffer in a ring (2,000 frames / 8 MiB by default,
356
+ drop-oldest) for replay when the debugger attaches. Anything dropped while
357
+ detached is counted and warned about.
358
+ * **Local only.** It dials `127.0.0.1`; it never listens, and it opens no port.
359
+
360
+ ---
361
+
362
+ ## Measured overhead
363
+
364
+ From `test/test_overhead.rb` on the machine this gem was developed on (Apple
365
+ Silicon, Ruby 3.3.12) — run it yourself with
366
+ `GRAPHMIND_REPORT_OVERHEAD=1 rake test`:
367
+
368
+ | Path | Cost |
369
+ | --- | --- |
370
+ | Gate, GraphMind disabled | **0.07 µs** per call |
371
+ | Gate, enabled but detached | **0.12 µs** per call |
372
+ | Wrapped tool call, disabled (added over the raw call) | **1.0 µs** |
373
+ | `emit` while detached (serialize + ring buffer, no socket) | **2.0 µs** |
374
+
375
+ The tests assert loose ceilings (5 µs / 10 µs / 100 µs) so they catch a
376
+ regression in kind — a lock or an allocation creeping onto the fast path —
377
+ without failing on a noisy CI box.
378
+
379
+ ---
380
+
381
+ ## The WebSocket client
382
+
383
+ The gem speaks RFC 6455 itself, over `TCPSocket`, in ~350 lines
384
+ (`lib/graphmind/websocket.rb`). That is a deliberate trade:
385
+
386
+ * **Zero runtime dependencies.** A debugger that you add to someone else's
387
+ Gemfile must not force a resolution on `faraday`, `websocket-driver` or
388
+ `eventmachine`. Dependency conflicts are the number one reason a dev tool
389
+ never gets installed.
390
+ * **The client half of RFC 6455 for one local link is small**: one masked text
391
+ opcode, ping/pong, close, and fragment reassembly.
392
+ * **Scope, stated plainly**: client role, text frames, `ws://` and `wss://`, no
393
+ `permessage-deflate`, no subprotocol negotiation, no HTTP proxy support.
394
+
395
+ Because a fake viewer built on the same codec could hide a symmetric bug,
396
+ `test/live_server_check.rb` (`rake live`) boots the real Node server
397
+ (`graphmind serve`, which uses `ws`) and drives it with this gem end to end —
398
+ handshake, ingest, a genuine hold at the server's default pause-on-error
399
+ breakpoint, and fail-open when the server is killed.
400
+
401
+ ---
402
+
403
+ ## Verified on
404
+
405
+ * **Ruby 3.3.12** (arm64-darwin) — the full suite (290 tests), the ruby-openai
406
+ and ruby_llm integrations, the Rails 8.1 Railtie, and the live cross-check
407
+ against the real `graphmind serve`.
408
+ * **The built gem**, installed into a clean `GEM_HOME` containing nothing but
409
+ Ruby's default gems, driven against the real server.
410
+ * **One real `gpt-4o-mini` call** through the instrumented `ruby-openai` client:
411
+ the `llm:step` node recorded the reply and the provider's own token usage.
412
+ * **ruby-openai 8.3.0**, and **ruby_llm 1.16.0 and 2.0.0** — CI runs the suite
413
+ against the newest ruby_llm and, in its own cell, the last 1.x.
414
+ * **railties 8.1.3.1**.
415
+
416
+ The gemspec allows **Ruby >= 3.1** because that is the oldest release whose
417
+ syntax and stdlib this gem uses (endless methods, argument forwarding,
418
+ `io/wait`). CI runs the full suite on **3.1** (the floor) and **3.3** on every
419
+ push; **3.2 is not executed**. If you run it there and something breaks, that
420
+ is a bug worth filing.
421
+
422
+ ---
423
+
424
+ ## Limitations
425
+
426
+ * **`after`-gate injection replaces the return value, not side effects.** If
427
+ your tool already wrote to the database, `inject` changes what the caller
428
+ sees, not what happened.
429
+ * **ruby_llm streaming deltas are not forwarded** to the canvas yet; the call is
430
+ still gated and recorded. ruby-openai streaming *is* forwarded.
431
+ * **ruby_llm's LLM gate hooks a private method** (`provider_completion`) to get
432
+ one node per HTTP round-trip. If a future ruby_llm renames it, the gem falls
433
+ back to `complete_once` and then to the public `complete` (one coarser node
434
+ per turn). The hook actually used is shown on the node as `hook`. Every patch
435
+ forwards whatever arguments it is given — 2.0 changed these signatures, and a
436
+ patch that pinned the 1.x one raised inside the user's chat.
437
+ * **No `run.gap` marker.** Events dropped while the debugger was unreachable are
438
+ counted and warned about, but the viewer is not told where the hole was (the
439
+ TypeScript client does send a marker; this gem does not yet).
440
+ * **No Anthropic or LangChain.rb integration** — use `Graphmind.tool` /
441
+ `Graphmind.span`, which gate anything.
442
+ * **No `Graphmind.ready` equivalent for "wait for a breakpoint"** — `ready`
443
+ waits for the handshake, not for a pause.
444
+ * **Fiber-scheduler-based servers** (`async`, Falcon) are correct by
445
+ construction because the context is fiber-local, but were not load-tested.
446
+
447
+ ---
448
+
449
+ ## Development
450
+
451
+ ```sh
452
+ bundle install
453
+ rake test # 95 tests, no network beyond loopback
454
+ rake live # cross-check against a real `graphmind serve` (needs Node)
455
+ gem build graphmind.gemspec
456
+ ```
457
+
458
+ The test suite validates **every emitted envelope** against
459
+ `packages/schema/schema.json`, the same wire contract the TypeScript and Python
460
+ SDKs are checked against.
461
+
462
+ ## License
463
+
464
+ MIT. See [LICENSE](LICENSE).