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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Graphmind
|
|
4
|
+
# Bounded FIFO with drop-oldest semantics, holding the most recent frames
|
|
5
|
+
# while no viewer is attached so they can be replayed on attach.
|
|
6
|
+
#
|
|
7
|
+
# Two independent bounds, both drop-oldest:
|
|
8
|
+
# * capacity — a hard item count;
|
|
9
|
+
# * max_bytes — an approximate memory ceiling, in UTF-8 bytes of the
|
|
10
|
+
# frames. Without it, N slots cost N x the largest payload the host ever
|
|
11
|
+
# emits.
|
|
12
|
+
#
|
|
13
|
+
# An item bigger than the whole byte budget is REFUSED (push returns false,
|
|
14
|
+
# `rejected` counts it) and nothing is evicted for it: one oversized frame
|
|
15
|
+
# must never cost the replay buffer every older frame — while the debugger
|
|
16
|
+
# was unreachable that turned one 17 MB event into the loss of every event
|
|
17
|
+
# before it. The caller decides the refused item's fate (the session sends
|
|
18
|
+
# it live when attached, else counts it lost). `reject_oversize: false`
|
|
19
|
+
# restores the old behaviour: the item is kept alone and every other item is
|
|
20
|
+
# evicted. Port of packages/client/src/ring-buffer.ts (`rejectOversize`,
|
|
21
|
+
# which the TypeScript session always turns on).
|
|
22
|
+
#
|
|
23
|
+
# Not thread-safe on its own — the session owns it under its mutex.
|
|
24
|
+
class RingBuffer
|
|
25
|
+
attr_reader :capacity, :dropped, :byte_size, :rejected, :max_bytes
|
|
26
|
+
|
|
27
|
+
def initialize(capacity, max_bytes = nil, reject_oversize: true)
|
|
28
|
+
raise ArgumentError, "capacity must be a positive integer" unless capacity.is_a?(Integer) && capacity >= 1
|
|
29
|
+
raise ArgumentError, "max_bytes must be > 0" if max_bytes && !(max_bytes.is_a?(Numeric) && max_bytes.positive?)
|
|
30
|
+
|
|
31
|
+
@capacity = capacity
|
|
32
|
+
@max_bytes = max_bytes
|
|
33
|
+
@reject_oversize = reject_oversize ? true : false
|
|
34
|
+
@items = []
|
|
35
|
+
@dropped = 0
|
|
36
|
+
@rejected = 0
|
|
37
|
+
@byte_size = 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Append one serialized frame; drops the oldest when either bound trips.
|
|
41
|
+
# Returns false only when the frame alone is larger than max_bytes and was
|
|
42
|
+
# refused (the buffer is then exactly as it was); true otherwise.
|
|
43
|
+
def push(frame)
|
|
44
|
+
size = frame.bytesize
|
|
45
|
+
if @max_bytes && @reject_oversize && size > @max_bytes
|
|
46
|
+
@rejected += 1
|
|
47
|
+
return false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@items << frame
|
|
51
|
+
@byte_size += size
|
|
52
|
+
while @items.size > @capacity
|
|
53
|
+
@byte_size -= @items.shift.bytesize
|
|
54
|
+
@dropped += 1
|
|
55
|
+
end
|
|
56
|
+
if @max_bytes
|
|
57
|
+
# Never down to empty: the frame just pushed is the newest and stays.
|
|
58
|
+
while @byte_size > @max_bytes && @items.size > 1
|
|
59
|
+
@byte_size -= @items.shift.bytesize
|
|
60
|
+
@dropped += 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_a = @items.dup
|
|
67
|
+
def size = @items.size
|
|
68
|
+
|
|
69
|
+
def clear
|
|
70
|
+
@items.clear
|
|
71
|
+
@byte_size = 0
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Graphmind
|
|
4
|
+
# Process-wide shutdown plumbing.
|
|
5
|
+
#
|
|
6
|
+
# A gate held on the main thread would otherwise block interpreter exit
|
|
7
|
+
# forever, so every live session registers a fail-open hook here and a single
|
|
8
|
+
# at_exit handler releases them all. One handler, not one per session, so a
|
|
9
|
+
# test suite that builds hundreds of sessions does not build hundreds of
|
|
10
|
+
# at_exit callbacks.
|
|
11
|
+
module Runtime
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@hooks = []
|
|
14
|
+
@installed = false
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def register(hook)
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
@hooks << hook
|
|
20
|
+
install!
|
|
21
|
+
end
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def unregister(hook)
|
|
26
|
+
@mutex.synchronize { @hooks.delete(hook) }
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fail_open_all
|
|
31
|
+
hooks = @mutex.synchronize { @hooks.dup }
|
|
32
|
+
hooks.each do |hook|
|
|
33
|
+
hook.call
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def install!
|
|
43
|
+
return if @installed
|
|
44
|
+
|
|
45
|
+
@installed = true
|
|
46
|
+
at_exit { Graphmind::Runtime.fail_open_all }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Graphmind
|
|
4
|
+
# "Never raise into the host app" plumbing.
|
|
5
|
+
#
|
|
6
|
+
# All internal failures degrade to a no-op plus a rate-limited warning: at
|
|
7
|
+
# most one warning per key per interval, so a permanently broken transport
|
|
8
|
+
# cannot spam a Rails log.
|
|
9
|
+
class Warner
|
|
10
|
+
# `Kernel.warn`, explicitly: a bare `warn(message)` inside this class binds
|
|
11
|
+
# to Warner#warn (arity 2+) and raises ArgumentError, which the rescue at
|
|
12
|
+
# the bottom of #warn would then swallow — leaving the default sink
|
|
13
|
+
# silently printing nothing.
|
|
14
|
+
def initialize(interval = 60.0, sink = nil)
|
|
15
|
+
@interval = interval
|
|
16
|
+
@sink = sink || ->(message) { Kernel.warn(message) }
|
|
17
|
+
@last = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def warn(key, message, cause = nil)
|
|
22
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
23
|
+
emit = @mutex.synchronize do
|
|
24
|
+
previous = @last[key]
|
|
25
|
+
if previous && (now - previous) < @interval
|
|
26
|
+
false
|
|
27
|
+
else
|
|
28
|
+
@last[key] = now
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
return unless emit
|
|
33
|
+
|
|
34
|
+
suffix =
|
|
35
|
+
if cause.is_a?(Exception)
|
|
36
|
+
" (#{cause.class}: #{cause.message})"
|
|
37
|
+
elsif cause.nil?
|
|
38
|
+
""
|
|
39
|
+
else
|
|
40
|
+
" (#{cause})"
|
|
41
|
+
end
|
|
42
|
+
@sink.call("[graphmind] #{message}#{suffix}")
|
|
43
|
+
rescue StandardError
|
|
44
|
+
# Even a raising sink must not propagate into the host.
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|