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,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "wrap"
|
|
5
|
+
|
|
6
|
+
module Graphmind
|
|
7
|
+
# One debugger attachment point for a process.
|
|
8
|
+
#
|
|
9
|
+
# gm = Graphmind.configure(app: "support-agent")
|
|
10
|
+
#
|
|
11
|
+
# search = gm.tool("search_flights") { |origin:, destination:| ... }
|
|
12
|
+
#
|
|
13
|
+
# gm.run("handle-ticket") do
|
|
14
|
+
# search.call(origin: "LHR", destination: "JFK")
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Everything degrades to a no-op when no debugger is attached, and to
|
|
18
|
+
# literally nothing when GraphMind is disabled.
|
|
19
|
+
class Client
|
|
20
|
+
attr_reader :session
|
|
21
|
+
|
|
22
|
+
def initialize(app: nil, sdk: nil, **session_options)
|
|
23
|
+
@session = Session.new(
|
|
24
|
+
app_name: app || default_app_name,
|
|
25
|
+
sdk: sdk || { "name" => "ruby", "version" => Graphmind::VERSION },
|
|
26
|
+
**session_options
|
|
27
|
+
)
|
|
28
|
+
@session_of = -> { @session }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# -- attach --------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def enabled? = @session.enabled?
|
|
34
|
+
def attached? = @session.attached?
|
|
35
|
+
def url = @session.url
|
|
36
|
+
|
|
37
|
+
# Block until the debugger handshake completes (breakpoints armed).
|
|
38
|
+
# `false` means "carry on detached" — it is never an error.
|
|
39
|
+
def ready(timeout = Session::DEFAULT_READY_TIMEOUT) = @session.ready(timeout)
|
|
40
|
+
|
|
41
|
+
# -- runs & spans --------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# Run boundary: `gm.run("name") { |ctx| ... }`.
|
|
44
|
+
def run(name, meta: nil, &block)
|
|
45
|
+
@session.run(name, meta: meta, &block)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def current_run = @session.current_run
|
|
49
|
+
def with_run_context(ctx, &block) = @session.with_run_context(ctx, &block)
|
|
50
|
+
def clear_run_context = @session.clear_run_context
|
|
51
|
+
|
|
52
|
+
# An arbitrary node on the canvas: gated, timed, named. Use it for the
|
|
53
|
+
# parts of a graph GraphMind cannot see by itself — a retrieval step, a
|
|
54
|
+
# hand-rolled planner loop, a Sidekiq job body.
|
|
55
|
+
#
|
|
56
|
+
# The block's value becomes the node's output and the span's value, unless
|
|
57
|
+
# `span.output = ...` sets one explicitly.
|
|
58
|
+
def span(name, kind: "custom", input: nil, parent_id: nil)
|
|
59
|
+
kind = "custom" unless Protocol::NODE_KINDS.include?(kind)
|
|
60
|
+
holder = Span.new
|
|
61
|
+
Wrap.invoke(@session,
|
|
62
|
+
node_id: "#{kind}:#{name}", kind: kind, name: name.to_s,
|
|
63
|
+
parent_id: parent_id, input: input) do
|
|
64
|
+
value = yield holder
|
|
65
|
+
holder.output_set? ? holder.output : value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# -- tools ---------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
# Wrap a block as a gated tool:
|
|
72
|
+
#
|
|
73
|
+
# search = gm.tool("search") { |query:| index.query(query) }
|
|
74
|
+
# search.call(query: "flights")
|
|
75
|
+
def tool(name = nil, kind: "tool", parent_id: nil, &block)
|
|
76
|
+
raise ArgumentError, "Graphmind#tool needs a block" if block.nil?
|
|
77
|
+
|
|
78
|
+
Wrap.gate_callable(block, @session_of, name: name, kind: kind, parent_id: parent_id)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Wrap any callable (Proc, Method, or an object responding to #call).
|
|
82
|
+
def wrap(callable, name: nil, kind: "tool", node_id: nil, parent_id: nil)
|
|
83
|
+
Wrap.gate_callable(callable, @session_of, name: name, kind: kind, node_id: node_id,
|
|
84
|
+
parent_id: parent_id)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Wrap a {name => callable} Hash, an Array of callables, or one callable.
|
|
88
|
+
def wrap_tools(tools) = Wrap.wrap_tools(tools, @session_of)
|
|
89
|
+
|
|
90
|
+
# Gate one method on one object, in place.
|
|
91
|
+
def wrap_method(target, method_name, name: nil, kind: "tool", parent_id: nil)
|
|
92
|
+
Wrap.wrap_method(target, method_name, @session_of, name: name, kind: kind,
|
|
93
|
+
parent_id: parent_id)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# -- integrations --------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
# Instrument a ruby-openai `OpenAI::Client` in place and return it.
|
|
99
|
+
def instrument_openai(client, **options)
|
|
100
|
+
require_relative "integrations/ruby_openai"
|
|
101
|
+
Integrations::RubyOpenAI.instrument(client, @session, **options)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Instrument a `RubyLLM::Chat` (and its tools) in place and return it.
|
|
105
|
+
def instrument_ruby_llm(chat, **options)
|
|
106
|
+
require_relative "integrations/ruby_llm"
|
|
107
|
+
Integrations::RubyLLM.instrument(chat, @session, **options)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# -- low level -----------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def emit(type, payload) = @session.emit(type, payload)
|
|
113
|
+
def gate(point, node) = @session.gate(point, node)
|
|
114
|
+
def graph_hint(nodes) = @session.graph_hint(nodes)
|
|
115
|
+
def push_token(node_id, channel, value) = @session.push_token(node_id, channel, value)
|
|
116
|
+
def stats = @session.stats
|
|
117
|
+
def dispose = @session.dispose
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def default_app_name
|
|
122
|
+
if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
123
|
+
::Rails.application.class.name.to_s.split("::").first.to_s.downcase
|
|
124
|
+
else
|
|
125
|
+
File.basename(Dir.pwd)
|
|
126
|
+
end
|
|
127
|
+
rescue StandardError
|
|
128
|
+
"ruby"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Yielded by Client#span so the block can name what the node produced.
|
|
133
|
+
class Span
|
|
134
|
+
attr_reader :output
|
|
135
|
+
|
|
136
|
+
def initialize
|
|
137
|
+
@output = nil
|
|
138
|
+
@set = false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def output=(value)
|
|
142
|
+
@output = value
|
|
143
|
+
@set = true
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def output_set? = @set
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# -- module-level default instance -----------------------------------------
|
|
150
|
+
|
|
151
|
+
@default = nil
|
|
152
|
+
@default_mutex = Mutex.new
|
|
153
|
+
|
|
154
|
+
class << self
|
|
155
|
+
# Create (or replace) the process-wide default instance. Accepts
|
|
156
|
+
# everything Session accepts plus `app:` and `sdk:`.
|
|
157
|
+
def configure(**options)
|
|
158
|
+
previous = nil
|
|
159
|
+
client = nil
|
|
160
|
+
@default_mutex.synchronize do
|
|
161
|
+
previous = @default
|
|
162
|
+
client = Client.new(**options)
|
|
163
|
+
@default = client
|
|
164
|
+
end
|
|
165
|
+
previous&.dispose
|
|
166
|
+
client
|
|
167
|
+
end
|
|
168
|
+
alias init configure
|
|
169
|
+
|
|
170
|
+
# The default instance, created with defaults on first use.
|
|
171
|
+
def instance
|
|
172
|
+
current = @default
|
|
173
|
+
return current if current
|
|
174
|
+
|
|
175
|
+
@default_mutex.synchronize do
|
|
176
|
+
@default ||= Client.new
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def configured? = !@default.nil?
|
|
181
|
+
|
|
182
|
+
# Dispose and forget the default instance (mainly for tests).
|
|
183
|
+
def reset
|
|
184
|
+
current = @default_mutex.synchronize do
|
|
185
|
+
previous = @default
|
|
186
|
+
@default = nil
|
|
187
|
+
previous
|
|
188
|
+
end
|
|
189
|
+
current&.dispose
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def session = instance.session
|
|
194
|
+
def enabled? = instance.enabled?
|
|
195
|
+
def attached? = instance.attached?
|
|
196
|
+
def ready(timeout = Session::DEFAULT_READY_TIMEOUT) = instance.ready(timeout)
|
|
197
|
+
def run(name, meta: nil, &block) = instance.run(name, meta: meta, &block)
|
|
198
|
+
def span(name, **kwargs, &block) = instance.span(name, **kwargs, &block)
|
|
199
|
+
def tool(name = nil, **kwargs, &block) = instance.tool(name, **kwargs, &block)
|
|
200
|
+
def wrap(callable, **kwargs) = instance.wrap(callable, **kwargs)
|
|
201
|
+
def wrap_tools(tools) = instance.wrap_tools(tools)
|
|
202
|
+
def wrap_method(target, method_name, **kwargs) = instance.wrap_method(target, method_name, **kwargs)
|
|
203
|
+
def instrument_openai(client, **kwargs) = instance.instrument_openai(client, **kwargs)
|
|
204
|
+
def instrument_ruby_llm(chat, **kwargs) = instance.instrument_ruby_llm(chat, **kwargs)
|
|
205
|
+
def emit(type, payload) = instance.emit(type, payload)
|
|
206
|
+
def graph_hint(nodes) = instance.graph_hint(nodes)
|
|
207
|
+
def current_run = instance.current_run
|
|
208
|
+
def with_run_context(ctx, &block) = instance.with_run_context(ctx, &block)
|
|
209
|
+
def clear_run_context = instance.clear_run_context
|
|
210
|
+
def stats = instance.stats
|
|
211
|
+
def dispose = instance.dispose
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Class-level macro for gating instance methods:
|
|
215
|
+
#
|
|
216
|
+
# class Tools
|
|
217
|
+
# include Graphmind::Instrument
|
|
218
|
+
# graphmind_tool :search, :book
|
|
219
|
+
#
|
|
220
|
+
# def search(query) = ...
|
|
221
|
+
# end
|
|
222
|
+
#
|
|
223
|
+
# Every instance of the class now pauses at `tool:search` / `tool:book`.
|
|
224
|
+
module Instrument
|
|
225
|
+
def self.included(base)
|
|
226
|
+
base.extend(ClassMethods)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
module ClassMethods
|
|
230
|
+
def graphmind_tool(*method_names, kind: "tool", session: nil)
|
|
231
|
+
gates = (@graphmind_gate_module ||= begin
|
|
232
|
+
mod = Module.new
|
|
233
|
+
prepend(mod)
|
|
234
|
+
mod
|
|
235
|
+
end)
|
|
236
|
+
method_names.each do |method_name|
|
|
237
|
+
node_name = method_name.to_s
|
|
238
|
+
gates.define_method(method_name) do |*args, **kwargs, &block|
|
|
239
|
+
active = session ? session.call : Graphmind.instance.session
|
|
240
|
+
body = lambda do
|
|
241
|
+
kwargs.empty? ? super(*args, &block) : super(*args, **kwargs, &block)
|
|
242
|
+
end
|
|
243
|
+
if active.nil? || !active.enabled? || active.disposed?
|
|
244
|
+
body.call
|
|
245
|
+
else
|
|
246
|
+
parameters =
|
|
247
|
+
begin
|
|
248
|
+
method(method_name).super_method&.parameters || []
|
|
249
|
+
rescue StandardError
|
|
250
|
+
[]
|
|
251
|
+
end
|
|
252
|
+
Graphmind::Wrap.invoke(
|
|
253
|
+
active,
|
|
254
|
+
node_id: Graphmind::Ids.tool_node_id(node_name),
|
|
255
|
+
kind: kind,
|
|
256
|
+
name: node_name,
|
|
257
|
+
input: Graphmind::Wrap.describe(parameters, args, kwargs),
|
|
258
|
+
&body
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
method_names
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Graphmind
|
|
4
|
+
# Duration clock.
|
|
5
|
+
#
|
|
6
|
+
# Every GraphMind duration (`durationMs`, `heldMs`) is measured on
|
|
7
|
+
# `Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)` —
|
|
8
|
+
# monotonic and sub-microsecond — never on `Time.now`, which steps under NTP
|
|
9
|
+
# adjustment and reads a 80µs handler as `0ms`. Wall-clock fields on the wire
|
|
10
|
+
# (envelope `ts`) stay integer epoch milliseconds; only elapsed-time
|
|
11
|
+
# measurement lives here.
|
|
12
|
+
#
|
|
13
|
+
# Port of packages/client/src/clock.ts. The wire contract for a duration:
|
|
14
|
+
# * rounded to 0.01 ms (two decimals),
|
|
15
|
+
# * clamped to >= 0,
|
|
16
|
+
# * never NaN / infinite (those become 0.0).
|
|
17
|
+
#
|
|
18
|
+
# The clock is read through `Clock.now_ms` at call time so tests can
|
|
19
|
+
# substitute it with `Clock.override=`.
|
|
20
|
+
module Clock
|
|
21
|
+
class << self
|
|
22
|
+
# Installed by tests; nil means the real monotonic clock.
|
|
23
|
+
attr_accessor :override
|
|
24
|
+
|
|
25
|
+
# Milliseconds on the monotonic clock. Fractional; only differences mean anything.
|
|
26
|
+
def now_ms
|
|
27
|
+
o = @override
|
|
28
|
+
return o.call.to_f if o
|
|
29
|
+
|
|
30
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Coerce a raw millisecond measurement into the wire contract.
|
|
34
|
+
def normalize_duration_ms(raw)
|
|
35
|
+
return 0.0 unless raw.is_a?(Numeric)
|
|
36
|
+
|
|
37
|
+
value = raw.to_f
|
|
38
|
+
return 0.0 if value.nan? || value.infinite? || value <= 0.0
|
|
39
|
+
|
|
40
|
+
value.round(2)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Elapsed since a `now_ms` reading, normalised for the wire.
|
|
44
|
+
def elapsed_ms(started_at, now = nil)
|
|
45
|
+
normalize_duration_ms((now || now_ms) - started_at)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "redaction"
|
|
5
|
+
require_relative "shrink"
|
|
6
|
+
|
|
7
|
+
module Graphmind
|
|
8
|
+
# The client-side inject guard (0.6.0, contract C2; refute-security C2.3 /
|
|
9
|
+
# S5): a value that still carries the redaction placeholder or a truncation
|
|
10
|
+
# marker is a pre-filled copy of a RECORDING its sender never saw in full —
|
|
11
|
+
# not a result anyone meant to substitute. Refused (the gate stays held) under
|
|
12
|
+
# every debugger, a 0.5 hub included, whose own guard knows only the
|
|
13
|
+
# placeholder.
|
|
14
|
+
#
|
|
15
|
+
# Port of `proposedValueRefusal` in packages/client/src/edit-input.ts — one
|
|
16
|
+
# marker list for every SDK and the hub, pinned by the shared fixture
|
|
17
|
+
# packages/client/test/fixtures/edit-input.json (its `proposedValue`
|
|
18
|
+
# section). This gem does not announce `edit-input`, so it never runs an
|
|
19
|
+
# edited input; only injected values need the guard.
|
|
20
|
+
#
|
|
21
|
+
# The markers, over the value's compact JSON text:
|
|
22
|
+
# * "__REDACTED__" anywhere -> placeholder
|
|
23
|
+
# * the shrink's `__graphmindTruncated` key and "…[graphmind: truncated]"
|
|
24
|
+
# suffix, LangGraph's `"__graphmind":"truncated"` / `"unserializable"`,
|
|
25
|
+
# the MCP get_node preview's `"note":"payload truncated: showing first `
|
|
26
|
+
# * the Python SDK's recording bounds (`…[truncated]"`, `"<N bytes>"`,
|
|
27
|
+
# `"…[depth limit]"`, a `"…"` key holding `"[N more keys]"`, `"…[N more]"`)
|
|
28
|
+
# -> truncated
|
|
29
|
+
module EditGuard
|
|
30
|
+
# A refusal the client decided (codes and messages never quote values).
|
|
31
|
+
Refusal = Struct.new(:code, :message)
|
|
32
|
+
|
|
33
|
+
ELLIPSIS = "\xE2\x80\xA6".dup.force_encoding(Encoding::UTF_8).freeze
|
|
34
|
+
MCP_PREVIEW_NOTE_PREFIX = "payload truncated: showing first "
|
|
35
|
+
|
|
36
|
+
TRUNCATION_MARKERS = [
|
|
37
|
+
"__graphmindTruncated",
|
|
38
|
+
Shrink::TRUNCATION_SUFFIX,
|
|
39
|
+
'"__graphmind":"truncated"',
|
|
40
|
+
'"__graphmind":"unserializable"',
|
|
41
|
+
%("note":"#{MCP_PREVIEW_NOTE_PREFIX})
|
|
42
|
+
].map { |marker| marker.dup.force_encoding(Encoding::UTF_8).freeze }.freeze
|
|
43
|
+
|
|
44
|
+
# `PYTHON_PREVIEW_MARKERS` in edit-input.ts, verbatim (`\A` / `\z` for its
|
|
45
|
+
# `^` / `$`, which in Ruby would match at any line; ASCII digits only).
|
|
46
|
+
E = Regexp.escape(ELLIPSIS)
|
|
47
|
+
PYTHON_PREVIEW_MARKERS = [
|
|
48
|
+
Regexp.new(%(#{E}\\[truncated\\]")),
|
|
49
|
+
Regexp.new(%((?:\\A|[\\[:,])"<[0-9]+ bytes>"(?=[,\\]}]|\\z))),
|
|
50
|
+
Regexp.new(%((?:\\A|[\\[:,])"#{E}\\[depth limit\\]"(?=[,\\]}]|\\z))),
|
|
51
|
+
Regexp.new(%([{,]"#{E}":"\\[[0-9]+ more keys\\]"(?=[,}]))),
|
|
52
|
+
Regexp.new(%((?:\\A|[\\[:,])"#{E}\\[[0-9]+ more\\]"(?=[,\\]}]|\\z)))
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
PLACEHOLDER_MESSAGE = 'the value contains redacted content ("__REDACTED__"); replace it before running'
|
|
56
|
+
TRUNCATED_MESSAGE = "the value contains a truncated preview, not the full value; replace it before running"
|
|
57
|
+
UNREADABLE_MESSAGE = "the value could not be read as JSON"
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
# How deep a value this guard serialises to check (json's default, 100,
|
|
62
|
+
# refused values the TypeScript client and the Python SDK inject). Finite
|
|
63
|
+
# on purpose: json's generator recurses on the machine stack, which on a
|
|
64
|
+
# thread (the transport's, where this runs) runs out between 1,000 and
|
|
65
|
+
# 2,000 levels, and running out is not always a catchable
|
|
66
|
+
# SystemStackError. Deeper, or cyclic, is a `shape` refusal — answered,
|
|
67
|
+
# never dropped.
|
|
68
|
+
MAX_NESTING = 512
|
|
69
|
+
|
|
70
|
+
# Refusal for a proposed inject output that must never run: `placeholder`,
|
|
71
|
+
# `truncated`, or `shape` when it cannot be serialised to be checked
|
|
72
|
+
# (nested past MAX_NESTING included). nil when it is clean. Never raises.
|
|
73
|
+
def proposed_value_refusal(value)
|
|
74
|
+
text = JSON.generate(value, max_nesting: MAX_NESTING)
|
|
75
|
+
text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
|
|
76
|
+
return Refusal.new("placeholder", PLACEHOLDER_MESSAGE) if text.include?(Redaction::REDACTED)
|
|
77
|
+
if TRUNCATION_MARKERS.any? { |marker| text.include?(marker) } ||
|
|
78
|
+
PYTHON_PREVIEW_MARKERS.any? { |pattern| pattern.match?(text) }
|
|
79
|
+
return Refusal.new("truncated", TRUNCATED_MESSAGE)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
nil
|
|
83
|
+
rescue StandardError, SystemStackError
|
|
84
|
+
Refusal.new("shape", UNREADABLE_MESSAGE)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Graphmind
|
|
4
|
+
# Kill switches and environment-derived defaults.
|
|
5
|
+
#
|
|
6
|
+
# Precedence for "is GraphMind enabled" (mirrors packages/client/src/env.ts,
|
|
7
|
+
# with a Ruby-flavoured production check because there is no NODE_ENV):
|
|
8
|
+
#
|
|
9
|
+
# 1. GRAPHMIND_DISABLED=1 -> disabled, always. Ops-level kill switch; it
|
|
10
|
+
# beats an explicit `enabled: true` passed in code.
|
|
11
|
+
# 2. explicit `enabled:` -> as given.
|
|
12
|
+
# 3. looks like production -> disabled unless GRAPHMIND=1.
|
|
13
|
+
# 4. otherwise -> enabled.
|
|
14
|
+
#
|
|
15
|
+
# "Looks like production" is deliberately a boring, documented rule: the
|
|
16
|
+
# first variable that is *set* out of ENV_VARS decides, and it counts as
|
|
17
|
+
# production when its value is production/prod (case-insensitive). No
|
|
18
|
+
# heuristics over hostnames, cloud metadata or TTYs — a debugger that
|
|
19
|
+
# silently turns itself off for surprising reasons is worse than one you
|
|
20
|
+
# have to switch on.
|
|
21
|
+
module Env
|
|
22
|
+
DEFAULT_URL = "ws://127.0.0.1:4747/ingest"
|
|
23
|
+
|
|
24
|
+
# Checked in order; the first one that is set decides the environment.
|
|
25
|
+
ENV_VARS = %w[
|
|
26
|
+
GRAPHMIND_ENV
|
|
27
|
+
ENVIRONMENT
|
|
28
|
+
APP_ENV
|
|
29
|
+
RAILS_ENV
|
|
30
|
+
RACK_ENV
|
|
31
|
+
ENV
|
|
32
|
+
NODE_ENV
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
PRODUCTION_VALUES = %w[production prod].freeze
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
def source(env)
|
|
40
|
+
env.nil? ? ENV : env
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# True when the first *set* variable in ENV_VARS names a production env.
|
|
44
|
+
def production?(env = nil)
|
|
45
|
+
src = source(env)
|
|
46
|
+
ENV_VARS.each do |key|
|
|
47
|
+
value = src[key]
|
|
48
|
+
next if value.nil? || value.to_s.empty?
|
|
49
|
+
|
|
50
|
+
return PRODUCTION_VALUES.include?(value.to_s.strip.downcase)
|
|
51
|
+
end
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
KILL_SWITCH_OFF = ["", "0", "false", "off", "no"].freeze
|
|
56
|
+
|
|
57
|
+
# A kill switch or privacy switch read from the environment: on for any
|
|
58
|
+
# String except empty, `0`, `false`, `off` and `no` (case-insensitive,
|
|
59
|
+
# surrounding whitespace ignored); nil is off. An unexpected spelling
|
|
60
|
+
# (`yes`, `on`) must err towards the switch being on. Mirrors
|
|
61
|
+
# `killSwitchOn` in the TS client.
|
|
62
|
+
def kill_switch_on?(value)
|
|
63
|
+
return false unless value.is_a?(String)
|
|
64
|
+
|
|
65
|
+
!KILL_SWITCH_OFF.include?(value.strip.downcase)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resolve_enabled(explicit = nil, env = nil)
|
|
69
|
+
src = source(env)
|
|
70
|
+
return false if kill_switch_on?(src["GRAPHMIND_DISABLED"])
|
|
71
|
+
return explicit unless explicit.nil?
|
|
72
|
+
|
|
73
|
+
!(production?(src) && src["GRAPHMIND"] != "1")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_url(explicit = nil, env = nil)
|
|
77
|
+
return explicit unless explicit.nil?
|
|
78
|
+
|
|
79
|
+
value = source(env)["GRAPHMIND_URL"]
|
|
80
|
+
value.nil? || value.empty? ? DEFAULT_URL : value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Graphmind
|
|
4
|
+
# Raised into the host **only** when the debugger explicitly aborts a run.
|
|
5
|
+
# Everything else in this gem degrades to a no-op.
|
|
6
|
+
class AbortError < StandardError
|
|
7
|
+
def initialize(message = "Run aborted by the GraphMind debugger")
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module Errors
|
|
13
|
+
MAX_STACK_FRAMES = 40
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def abort_error?(error)
|
|
18
|
+
return false if error.nil?
|
|
19
|
+
return true if error.is_a?(Graphmind::AbortError)
|
|
20
|
+
|
|
21
|
+
name = error.class.name.to_s
|
|
22
|
+
name.end_with?("AbortError") || name.end_with?("Abort")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Serialize an exception into the wire contract's ErrorInfo shape.
|
|
26
|
+
def to_error_info(error)
|
|
27
|
+
return { "name" => "Error", "message" => error.to_s } unless error.is_a?(Exception)
|
|
28
|
+
|
|
29
|
+
info = {
|
|
30
|
+
"name" => error.class.name.to_s,
|
|
31
|
+
"message" => error.message.to_s
|
|
32
|
+
}
|
|
33
|
+
backtrace = error.backtrace
|
|
34
|
+
if backtrace.is_a?(Array) && !backtrace.empty?
|
|
35
|
+
info["stack"] = ([error.message.to_s] + backtrace.first(MAX_STACK_FRAMES)).join("\n")
|
|
36
|
+
end
|
|
37
|
+
info
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|