robot_lab 0.0.1
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/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.github/workflows/deploy-yard-docs.yml +52 -0
- data/CHANGELOG.md +55 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +332 -0
- data/Rakefile +67 -0
- data/docs/api/adapters/anthropic.md +121 -0
- data/docs/api/adapters/gemini.md +133 -0
- data/docs/api/adapters/index.md +104 -0
- data/docs/api/adapters/openai.md +134 -0
- data/docs/api/core/index.md +113 -0
- data/docs/api/core/memory.md +314 -0
- data/docs/api/core/network.md +291 -0
- data/docs/api/core/robot.md +273 -0
- data/docs/api/core/state.md +273 -0
- data/docs/api/core/tool.md +353 -0
- data/docs/api/history/active-record-adapter.md +195 -0
- data/docs/api/history/config.md +191 -0
- data/docs/api/history/index.md +132 -0
- data/docs/api/history/thread-manager.md +144 -0
- data/docs/api/index.md +82 -0
- data/docs/api/mcp/client.md +221 -0
- data/docs/api/mcp/index.md +111 -0
- data/docs/api/mcp/server.md +225 -0
- data/docs/api/mcp/transports.md +264 -0
- data/docs/api/messages/index.md +67 -0
- data/docs/api/messages/text-message.md +102 -0
- data/docs/api/messages/tool-call-message.md +144 -0
- data/docs/api/messages/tool-result-message.md +154 -0
- data/docs/api/messages/user-message.md +171 -0
- data/docs/api/streaming/context.md +174 -0
- data/docs/api/streaming/events.md +237 -0
- data/docs/api/streaming/index.md +108 -0
- data/docs/architecture/core-concepts.md +243 -0
- data/docs/architecture/index.md +138 -0
- data/docs/architecture/message-flow.md +320 -0
- data/docs/architecture/network-orchestration.md +216 -0
- data/docs/architecture/robot-execution.md +243 -0
- data/docs/architecture/state-management.md +323 -0
- data/docs/assets/css/custom.css +56 -0
- data/docs/assets/images/robot_lab.jpg +0 -0
- data/docs/concepts.md +216 -0
- data/docs/examples/basic-chat.md +193 -0
- data/docs/examples/index.md +129 -0
- data/docs/examples/mcp-server.md +290 -0
- data/docs/examples/multi-robot-network.md +312 -0
- data/docs/examples/rails-application.md +420 -0
- data/docs/examples/tool-usage.md +310 -0
- data/docs/getting-started/configuration.md +230 -0
- data/docs/getting-started/index.md +56 -0
- data/docs/getting-started/installation.md +179 -0
- data/docs/getting-started/quick-start.md +203 -0
- data/docs/guides/building-robots.md +376 -0
- data/docs/guides/creating-networks.md +366 -0
- data/docs/guides/history.md +359 -0
- data/docs/guides/index.md +68 -0
- data/docs/guides/mcp-integration.md +356 -0
- data/docs/guides/memory.md +309 -0
- data/docs/guides/rails-integration.md +432 -0
- data/docs/guides/streaming.md +314 -0
- data/docs/guides/using-tools.md +394 -0
- data/docs/index.md +160 -0
- data/examples/01_simple_robot.rb +38 -0
- data/examples/02_tools.rb +106 -0
- data/examples/03_network.rb +103 -0
- data/examples/04_mcp.rb +219 -0
- data/examples/05_streaming.rb +124 -0
- data/examples/06_prompt_templates.rb +324 -0
- data/examples/07_network_memory.rb +329 -0
- data/examples/prompts/assistant/system.txt.erb +2 -0
- data/examples/prompts/assistant/user.txt.erb +1 -0
- data/examples/prompts/billing/system.txt.erb +7 -0
- data/examples/prompts/billing/user.txt.erb +1 -0
- data/examples/prompts/classifier/system.txt.erb +4 -0
- data/examples/prompts/classifier/user.txt.erb +1 -0
- data/examples/prompts/entity_extractor/system.txt.erb +11 -0
- data/examples/prompts/entity_extractor/user.txt.erb +3 -0
- data/examples/prompts/escalation/system.txt.erb +35 -0
- data/examples/prompts/escalation/user.txt.erb +34 -0
- data/examples/prompts/general/system.txt.erb +4 -0
- data/examples/prompts/general/user.txt.erb +1 -0
- data/examples/prompts/github_assistant/system.txt.erb +6 -0
- data/examples/prompts/github_assistant/user.txt.erb +1 -0
- data/examples/prompts/helper/system.txt.erb +1 -0
- data/examples/prompts/helper/user.txt.erb +1 -0
- data/examples/prompts/keyword_extractor/system.txt.erb +8 -0
- data/examples/prompts/keyword_extractor/user.txt.erb +3 -0
- data/examples/prompts/order_support/system.txt.erb +27 -0
- data/examples/prompts/order_support/user.txt.erb +22 -0
- data/examples/prompts/product_support/system.txt.erb +30 -0
- data/examples/prompts/product_support/user.txt.erb +32 -0
- data/examples/prompts/sentiment_analyzer/system.txt.erb +9 -0
- data/examples/prompts/sentiment_analyzer/user.txt.erb +3 -0
- data/examples/prompts/synthesizer/system.txt.erb +14 -0
- data/examples/prompts/synthesizer/user.txt.erb +15 -0
- data/examples/prompts/technical/system.txt.erb +7 -0
- data/examples/prompts/technical/user.txt.erb +1 -0
- data/examples/prompts/triage/system.txt.erb +16 -0
- data/examples/prompts/triage/user.txt.erb +17 -0
- data/lib/generators/robot_lab/install_generator.rb +78 -0
- data/lib/generators/robot_lab/robot_generator.rb +55 -0
- data/lib/generators/robot_lab/templates/initializer.rb.tt +41 -0
- data/lib/generators/robot_lab/templates/migration.rb.tt +32 -0
- data/lib/generators/robot_lab/templates/result_model.rb.tt +52 -0
- data/lib/generators/robot_lab/templates/robot.rb.tt +46 -0
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +32 -0
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +53 -0
- data/lib/generators/robot_lab/templates/thread_model.rb.tt +40 -0
- data/lib/robot_lab/adapters/anthropic.rb +163 -0
- data/lib/robot_lab/adapters/base.rb +85 -0
- data/lib/robot_lab/adapters/gemini.rb +193 -0
- data/lib/robot_lab/adapters/openai.rb +159 -0
- data/lib/robot_lab/adapters/registry.rb +81 -0
- data/lib/robot_lab/configuration.rb +143 -0
- data/lib/robot_lab/error.rb +32 -0
- data/lib/robot_lab/errors.rb +70 -0
- data/lib/robot_lab/history/active_record_adapter.rb +146 -0
- data/lib/robot_lab/history/config.rb +115 -0
- data/lib/robot_lab/history/thread_manager.rb +93 -0
- data/lib/robot_lab/mcp/client.rb +210 -0
- data/lib/robot_lab/mcp/server.rb +84 -0
- data/lib/robot_lab/mcp/transports/base.rb +56 -0
- data/lib/robot_lab/mcp/transports/sse.rb +117 -0
- data/lib/robot_lab/mcp/transports/stdio.rb +133 -0
- data/lib/robot_lab/mcp/transports/streamable_http.rb +139 -0
- data/lib/robot_lab/mcp/transports/websocket.rb +108 -0
- data/lib/robot_lab/memory.rb +882 -0
- data/lib/robot_lab/memory_change.rb +123 -0
- data/lib/robot_lab/message.rb +357 -0
- data/lib/robot_lab/network.rb +350 -0
- data/lib/robot_lab/rails/engine.rb +29 -0
- data/lib/robot_lab/rails/railtie.rb +42 -0
- data/lib/robot_lab/robot.rb +560 -0
- data/lib/robot_lab/robot_result.rb +205 -0
- data/lib/robot_lab/robotic_model.rb +324 -0
- data/lib/robot_lab/state_proxy.rb +188 -0
- data/lib/robot_lab/streaming/context.rb +144 -0
- data/lib/robot_lab/streaming/events.rb +95 -0
- data/lib/robot_lab/streaming/sequence_counter.rb +48 -0
- data/lib/robot_lab/task.rb +117 -0
- data/lib/robot_lab/tool.rb +223 -0
- data/lib/robot_lab/tool_config.rb +112 -0
- data/lib/robot_lab/tool_manifest.rb +234 -0
- data/lib/robot_lab/user_message.rb +118 -0
- data/lib/robot_lab/version.rb +5 -0
- data/lib/robot_lab/waiter.rb +73 -0
- data/lib/robot_lab.rb +195 -0
- data/mkdocs.yml +214 -0
- data/sig/robot_lab.rbs +4 -0
- metadata +442 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/semantic_cache"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
# Raised when a blocking get times out
|
|
7
|
+
class AwaitTimeout < Error; end
|
|
8
|
+
|
|
9
|
+
# Unified memory system for Robot and Network execution
|
|
10
|
+
#
|
|
11
|
+
# Memory is a reactive key-value store backed by Redis (if available) or an
|
|
12
|
+
# internal Hash object. It provides persistent storage for runtime data,
|
|
13
|
+
# conversation history, and arbitrary user-defined values.
|
|
14
|
+
#
|
|
15
|
+
# == Reactive Features
|
|
16
|
+
#
|
|
17
|
+
# Memory supports pub/sub semantics where robots can subscribe to key changes
|
|
18
|
+
# and optionally block until values become available:
|
|
19
|
+
#
|
|
20
|
+
# - `set(key, value)` - Write a value and notify subscribers asynchronously
|
|
21
|
+
# - `get(key, wait: true)` - Read a value, blocking until it exists if needed
|
|
22
|
+
# - `subscribe(*keys)` - Register a callback for key changes
|
|
23
|
+
#
|
|
24
|
+
# Reserved keys with special accessors:
|
|
25
|
+
# - :data - runtime data (StateProxy for method-style access)
|
|
26
|
+
# - :results - accumulated robot results
|
|
27
|
+
# - :messages - conversation history
|
|
28
|
+
# - :session_id - conversation session identifier for history persistence
|
|
29
|
+
# - :cache - semantic cache instance (RubyLLM::SemanticCache)
|
|
30
|
+
#
|
|
31
|
+
# @example Basic usage
|
|
32
|
+
# memory = Memory.new
|
|
33
|
+
# memory.set(:user_id, 123)
|
|
34
|
+
# memory.get(:user_id) # => 123
|
|
35
|
+
#
|
|
36
|
+
# @example Blocking read
|
|
37
|
+
# # In robot A (writer)
|
|
38
|
+
# memory.set(:sentiment, { score: 0.8 })
|
|
39
|
+
#
|
|
40
|
+
# # In robot B (reader, may run concurrently)
|
|
41
|
+
# result = memory.get(:sentiment, wait: true) # Blocks until available
|
|
42
|
+
# result = memory.get(:sentiment, wait: 30) # Blocks up to 30 seconds
|
|
43
|
+
#
|
|
44
|
+
# @example Multiple keys
|
|
45
|
+
# results = memory.get(:sentiment, :entities, :keywords, wait: 60)
|
|
46
|
+
# # => { sentiment: {...}, entities: [...], keywords: [...] }
|
|
47
|
+
#
|
|
48
|
+
# @example Subscriptions (async callbacks)
|
|
49
|
+
# memory.subscribe(:raw_data) do |change|
|
|
50
|
+
# puts "#{change.key} changed by #{change.writer}"
|
|
51
|
+
# enriched = enrich(change.value)
|
|
52
|
+
# memory.set(:enriched, enriched)
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# @example Using reserved keys
|
|
56
|
+
# memory.data[:category] = "billing"
|
|
57
|
+
# memory.data.category # => "billing"
|
|
58
|
+
# memory.results # => []
|
|
59
|
+
# memory.cache # => RubyLLM::SemanticCache instance
|
|
60
|
+
#
|
|
61
|
+
class Memory
|
|
62
|
+
# Reserved keys that have special behavior
|
|
63
|
+
RESERVED_KEYS = %i[data results messages session_id cache].freeze
|
|
64
|
+
|
|
65
|
+
# @!attribute [r] network_name
|
|
66
|
+
# @return [String, nil] the network this memory belongs to
|
|
67
|
+
# @!attribute [rw] current_writer
|
|
68
|
+
# @return [String, nil] the name of the robot currently writing
|
|
69
|
+
attr_reader :network_name
|
|
70
|
+
attr_accessor :current_writer
|
|
71
|
+
|
|
72
|
+
# Creates a new Memory instance.
|
|
73
|
+
#
|
|
74
|
+
# @param data [Hash] initial runtime data
|
|
75
|
+
# @param results [Array<RobotResult>] pre-loaded robot results
|
|
76
|
+
# @param messages [Array<Message, Hash>] pre-loaded conversation messages
|
|
77
|
+
# @param session_id [String, nil] conversation session identifier
|
|
78
|
+
# @param backend [Symbol] storage backend (:auto, :redis, :hash)
|
|
79
|
+
# @param enable_cache [Boolean] whether to enable semantic caching (default: true)
|
|
80
|
+
# @param network_name [String, nil] the network this memory belongs to
|
|
81
|
+
#
|
|
82
|
+
# @example Basic memory with caching enabled
|
|
83
|
+
# Memory.new(data: { category: nil, resolved: false })
|
|
84
|
+
#
|
|
85
|
+
# @example Memory with caching disabled
|
|
86
|
+
# Memory.new(enable_cache: false)
|
|
87
|
+
#
|
|
88
|
+
# @example Network-owned memory
|
|
89
|
+
# Memory.new(network_name: "support_pipeline")
|
|
90
|
+
def initialize(data: {}, results: [], messages: [], session_id: nil, backend: :auto, enable_cache: true, network_name: nil)
|
|
91
|
+
@backend = select_backend(backend)
|
|
92
|
+
@mutex = Mutex.new
|
|
93
|
+
@enable_cache = enable_cache
|
|
94
|
+
@network_name = network_name
|
|
95
|
+
@current_writer = nil
|
|
96
|
+
|
|
97
|
+
# Initialize reserved keys
|
|
98
|
+
set_internal(:data, data.is_a?(Hash) ? data.transform_keys(&:to_sym) : data)
|
|
99
|
+
set_internal(:results, Array(results))
|
|
100
|
+
set_internal(:messages, Array(messages).map { |m| normalize_message(m) })
|
|
101
|
+
set_internal(:session_id, session_id)
|
|
102
|
+
set_internal(:cache, @enable_cache ? RubyLLM::SemanticCache : nil)
|
|
103
|
+
|
|
104
|
+
# Data proxy for method-style access
|
|
105
|
+
@data_proxy = nil
|
|
106
|
+
|
|
107
|
+
# Reactive infrastructure
|
|
108
|
+
@subscriptions = Hash.new { |h, k| h[k] = [] }
|
|
109
|
+
@pattern_subscriptions = []
|
|
110
|
+
@waiters = Hash.new { |h, k| h[k] = [] }
|
|
111
|
+
@subscription_mutex = Mutex.new
|
|
112
|
+
@waiter_mutex = Mutex.new
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get value by key
|
|
116
|
+
#
|
|
117
|
+
# @param key [Symbol, String] the key to retrieve
|
|
118
|
+
# @return [Object] the stored value
|
|
119
|
+
#
|
|
120
|
+
def [](key)
|
|
121
|
+
key = key.to_sym
|
|
122
|
+
return send(key) if RESERVED_KEYS.include?(key) && key != :cache
|
|
123
|
+
|
|
124
|
+
get_internal(key)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Set value by key
|
|
128
|
+
#
|
|
129
|
+
# For non-reserved keys, this delegates to {#set} which provides
|
|
130
|
+
# reactive notifications. For reserved keys, it bypasses notifications.
|
|
131
|
+
#
|
|
132
|
+
# @param key [Symbol, String] the key to set
|
|
133
|
+
# @param value [Object] the value to store
|
|
134
|
+
# @return [Object] the stored value
|
|
135
|
+
#
|
|
136
|
+
# @see #set
|
|
137
|
+
#
|
|
138
|
+
def []=(key, value)
|
|
139
|
+
key = key.to_sym
|
|
140
|
+
|
|
141
|
+
# Reserved keys have special handling (no notifications)
|
|
142
|
+
case key
|
|
143
|
+
when :data
|
|
144
|
+
@data_proxy = nil # Reset proxy
|
|
145
|
+
set_internal(:data, value.is_a?(Hash) ? value.transform_keys(&:to_sym) : value)
|
|
146
|
+
when :results
|
|
147
|
+
set_internal(:results, Array(value))
|
|
148
|
+
when :messages
|
|
149
|
+
set_internal(:messages, Array(value).map { |m| normalize_message(m) })
|
|
150
|
+
when :session_id
|
|
151
|
+
set_internal(:session_id, value)
|
|
152
|
+
when :cache
|
|
153
|
+
# Cache is read-only after initialization
|
|
154
|
+
raise ArgumentError, "Cannot reassign cache - it is initialized automatically"
|
|
155
|
+
else
|
|
156
|
+
# Non-reserved keys use reactive set
|
|
157
|
+
set(key, value)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
value
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Access runtime data through StateProxy
|
|
164
|
+
#
|
|
165
|
+
# @return [StateProxy] proxy for method-style data access
|
|
166
|
+
#
|
|
167
|
+
def data
|
|
168
|
+
@data_proxy ||= StateProxy.new(get_internal(:data) || {})
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Get copy of results (immutable access)
|
|
172
|
+
#
|
|
173
|
+
# @return [Array<RobotResult>]
|
|
174
|
+
#
|
|
175
|
+
def results
|
|
176
|
+
(get_internal(:results) || []).dup
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Get copy of messages (immutable access)
|
|
180
|
+
#
|
|
181
|
+
# @return [Array<Message>]
|
|
182
|
+
#
|
|
183
|
+
def messages
|
|
184
|
+
(get_internal(:messages) || []).dup
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Get session identifier
|
|
188
|
+
#
|
|
189
|
+
# @return [String, nil]
|
|
190
|
+
#
|
|
191
|
+
def session_id
|
|
192
|
+
get_internal(:session_id)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Set session identifier
|
|
196
|
+
#
|
|
197
|
+
# @param id [String, nil]
|
|
198
|
+
# @return [self]
|
|
199
|
+
#
|
|
200
|
+
def session_id=(id)
|
|
201
|
+
set_internal(:session_id, id)
|
|
202
|
+
self
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Get the semantic cache module
|
|
206
|
+
#
|
|
207
|
+
# The cache is always active and provides semantic similarity matching
|
|
208
|
+
# for LLM responses, reducing costs and latency by returning cached
|
|
209
|
+
# responses for semantically equivalent queries.
|
|
210
|
+
#
|
|
211
|
+
# @example Using the cache with fetch
|
|
212
|
+
# response = memory.cache.fetch("What is Ruby?") do
|
|
213
|
+
# RubyLLM.chat.ask("What is Ruby?")
|
|
214
|
+
# end
|
|
215
|
+
#
|
|
216
|
+
# @example Wrapping a chat instance
|
|
217
|
+
# chat = memory.cache.wrap(RubyLLM.chat(model: "gpt-4"))
|
|
218
|
+
# chat.ask("What is Ruby?") # Cached on semantic similarity
|
|
219
|
+
#
|
|
220
|
+
# @return [RubyLLM::SemanticCache] the semantic cache module
|
|
221
|
+
#
|
|
222
|
+
def cache
|
|
223
|
+
get_internal(:cache)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# =========================================================================
|
|
227
|
+
# Reactive Memory API
|
|
228
|
+
# =========================================================================
|
|
229
|
+
|
|
230
|
+
# Set a value and notify subscribers asynchronously.
|
|
231
|
+
#
|
|
232
|
+
# This is the primary write method for reactive memory. It stores the value,
|
|
233
|
+
# wakes any threads waiting for this key, and asynchronously notifies
|
|
234
|
+
# subscribers.
|
|
235
|
+
#
|
|
236
|
+
# @param key [Symbol, String] the key to set
|
|
237
|
+
# @param value [Object] the value to store
|
|
238
|
+
# @return [Object] the stored value
|
|
239
|
+
#
|
|
240
|
+
# @example Basic set
|
|
241
|
+
# memory.set(:sentiment, { score: 0.8, confidence: 0.95 })
|
|
242
|
+
#
|
|
243
|
+
# @example Set triggers notifications
|
|
244
|
+
# memory.subscribe(:status) { |change| puts "Status: #{change.value}" }
|
|
245
|
+
# memory.set(:status, "complete") # Subscriber callback fires async
|
|
246
|
+
#
|
|
247
|
+
def set(key, value)
|
|
248
|
+
key = key.to_sym
|
|
249
|
+
old_value = nil
|
|
250
|
+
|
|
251
|
+
# Store the value
|
|
252
|
+
@mutex.synchronize do
|
|
253
|
+
old_value = @backend[key]
|
|
254
|
+
@backend[key] = value
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Wake any threads waiting for this key (synchronous - they need the value)
|
|
258
|
+
wake_waiters(key, value)
|
|
259
|
+
|
|
260
|
+
# Notify subscribers asynchronously
|
|
261
|
+
notify_subscribers_async(key, value, old_value)
|
|
262
|
+
|
|
263
|
+
value
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Get one or more values, optionally waiting until they exist.
|
|
267
|
+
#
|
|
268
|
+
# @param keys [Array<Symbol, String>] one or more keys to retrieve
|
|
269
|
+
# @param wait [Boolean, Numeric] wait behavior:
|
|
270
|
+
# - `false` (default): return immediately, nil if missing
|
|
271
|
+
# - `true`: block indefinitely until value(s) exist
|
|
272
|
+
# - `Numeric`: block up to that many seconds, raise AwaitTimeout if exceeded
|
|
273
|
+
# @return [Object, Hash] single value for one key, hash for multiple keys
|
|
274
|
+
# @raise [AwaitTimeout] if timeout expires before value is available
|
|
275
|
+
#
|
|
276
|
+
# @example Immediate read
|
|
277
|
+
# memory.get(:sentiment) # => value or nil
|
|
278
|
+
#
|
|
279
|
+
# @example Blocking read
|
|
280
|
+
# memory.get(:sentiment, wait: true) # Blocks until available
|
|
281
|
+
#
|
|
282
|
+
# @example Blocking with timeout
|
|
283
|
+
# memory.get(:sentiment, wait: 30) # Blocks up to 30 seconds
|
|
284
|
+
#
|
|
285
|
+
# @example Multiple keys
|
|
286
|
+
# memory.get(:sentiment, :entities, :keywords, wait: 60)
|
|
287
|
+
# # => { sentiment: {...}, entities: [...], keywords: [...] }
|
|
288
|
+
#
|
|
289
|
+
def get(*keys, wait: false)
|
|
290
|
+
keys = keys.flatten.map(&:to_sym)
|
|
291
|
+
|
|
292
|
+
if keys.one?
|
|
293
|
+
get_single(keys.first, wait: wait)
|
|
294
|
+
else
|
|
295
|
+
get_multiple(keys, wait: wait)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Subscribe to changes on one or more keys.
|
|
300
|
+
#
|
|
301
|
+
# The callback is invoked asynchronously whenever a subscribed key changes.
|
|
302
|
+
# The callback receives a MemoryChange object with details about the change.
|
|
303
|
+
#
|
|
304
|
+
# @param keys [Array<Symbol, String>] keys to subscribe to
|
|
305
|
+
# @yield [MemoryChange] callback invoked when a subscribed key changes
|
|
306
|
+
# @return [Object] subscription identifier (for unsubscribe)
|
|
307
|
+
#
|
|
308
|
+
# @example Subscribe to a single key
|
|
309
|
+
# memory.subscribe(:raw_data) do |change|
|
|
310
|
+
# puts "#{change.key} changed from #{change.previous} to #{change.value}"
|
|
311
|
+
# puts "Written by: #{change.writer}"
|
|
312
|
+
# end
|
|
313
|
+
#
|
|
314
|
+
# @example Subscribe to multiple keys
|
|
315
|
+
# memory.subscribe(:sentiment, :entities) do |change|
|
|
316
|
+
# update_dashboard(change.key, change.value)
|
|
317
|
+
# end
|
|
318
|
+
#
|
|
319
|
+
def subscribe(*keys, &block)
|
|
320
|
+
raise ArgumentError, "Block required for subscribe" unless block_given?
|
|
321
|
+
|
|
322
|
+
keys = keys.flatten.map(&:to_sym)
|
|
323
|
+
subscription_id = generate_subscription_id
|
|
324
|
+
|
|
325
|
+
@subscription_mutex.synchronize do
|
|
326
|
+
keys.each do |key|
|
|
327
|
+
@subscriptions[key] << { id: subscription_id, callback: block }
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
subscription_id
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Subscribe to keys matching a pattern.
|
|
335
|
+
#
|
|
336
|
+
# Pattern uses glob-style matching:
|
|
337
|
+
# - `*` matches any characters
|
|
338
|
+
# - `?` matches a single character
|
|
339
|
+
#
|
|
340
|
+
# @param pattern [String] glob pattern to match keys
|
|
341
|
+
# @yield [MemoryChange] callback invoked when a matching key changes
|
|
342
|
+
# @return [Object] subscription identifier (for unsubscribe)
|
|
343
|
+
#
|
|
344
|
+
# @example Subscribe to namespace
|
|
345
|
+
# memory.subscribe_pattern("analysis:*") do |change|
|
|
346
|
+
# puts "Analysis key #{change.key} updated"
|
|
347
|
+
# end
|
|
348
|
+
#
|
|
349
|
+
def subscribe_pattern(pattern, &block)
|
|
350
|
+
raise ArgumentError, "Block required for subscribe_pattern" unless block_given?
|
|
351
|
+
|
|
352
|
+
subscription_id = generate_subscription_id
|
|
353
|
+
regex = pattern_to_regex(pattern)
|
|
354
|
+
|
|
355
|
+
@subscription_mutex.synchronize do
|
|
356
|
+
@pattern_subscriptions << { id: subscription_id, pattern: regex, callback: block }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
subscription_id
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Remove a subscription.
|
|
363
|
+
#
|
|
364
|
+
# @param subscription_id [Object] the subscription identifier from subscribe
|
|
365
|
+
# @return [Boolean] true if subscription was found and removed
|
|
366
|
+
#
|
|
367
|
+
def unsubscribe(subscription_id)
|
|
368
|
+
removed = false
|
|
369
|
+
|
|
370
|
+
@subscription_mutex.synchronize do
|
|
371
|
+
@subscriptions.each_value do |subs|
|
|
372
|
+
removed = true if subs.reject! { |s| s[:id] == subscription_id }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
removed = true if @pattern_subscriptions.reject! { |s| s[:id] == subscription_id }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
removed
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Remove all subscriptions for specific keys.
|
|
382
|
+
#
|
|
383
|
+
# @param keys [Array<Symbol, String>] keys to unsubscribe from
|
|
384
|
+
# @return [self]
|
|
385
|
+
#
|
|
386
|
+
def unsubscribe_keys(*keys)
|
|
387
|
+
keys = keys.flatten.map(&:to_sym)
|
|
388
|
+
|
|
389
|
+
@subscription_mutex.synchronize do
|
|
390
|
+
keys.each { |key| @subscriptions.delete(key) }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
self
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Check if there are any subscribers for a key.
|
|
397
|
+
#
|
|
398
|
+
# @param key [Symbol, String] the key to check
|
|
399
|
+
# @return [Boolean]
|
|
400
|
+
#
|
|
401
|
+
def subscribed?(key)
|
|
402
|
+
key = key.to_sym
|
|
403
|
+
|
|
404
|
+
@subscription_mutex.synchronize do
|
|
405
|
+
return true if @subscriptions[key].any?
|
|
406
|
+
|
|
407
|
+
@pattern_subscriptions.any? { |s| s[:pattern].match?(key.to_s) }
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Append a robot result to history
|
|
412
|
+
#
|
|
413
|
+
# @param result [RobotResult]
|
|
414
|
+
# @return [self]
|
|
415
|
+
#
|
|
416
|
+
def append_result(result)
|
|
417
|
+
@mutex.synchronize do
|
|
418
|
+
results_array = @backend[:results] || []
|
|
419
|
+
results_array << result
|
|
420
|
+
@backend[:results] = results_array
|
|
421
|
+
end
|
|
422
|
+
self
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Set results (used when loading from persistence)
|
|
426
|
+
#
|
|
427
|
+
# @param results [Array<RobotResult>]
|
|
428
|
+
# @return [self]
|
|
429
|
+
#
|
|
430
|
+
def set_results(results)
|
|
431
|
+
set_internal(:results, Array(results))
|
|
432
|
+
self
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Get results from a specific index (for incremental save)
|
|
436
|
+
#
|
|
437
|
+
# @param start_index [Integer]
|
|
438
|
+
# @return [Array<RobotResult>]
|
|
439
|
+
#
|
|
440
|
+
def results_from(start_index)
|
|
441
|
+
(get_internal(:results) || [])[start_index..] || []
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Merge additional values into memory
|
|
445
|
+
#
|
|
446
|
+
# @param values [Hash] key-value pairs to merge
|
|
447
|
+
# @return [self]
|
|
448
|
+
#
|
|
449
|
+
def merge!(values)
|
|
450
|
+
values.each { |k, v| self[k] = v }
|
|
451
|
+
self
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# Check if key exists
|
|
455
|
+
#
|
|
456
|
+
# @param key [Symbol, String]
|
|
457
|
+
# @return [Boolean]
|
|
458
|
+
#
|
|
459
|
+
def key?(key)
|
|
460
|
+
key = key.to_sym
|
|
461
|
+
return true if RESERVED_KEYS.include?(key)
|
|
462
|
+
|
|
463
|
+
@mutex.synchronize do
|
|
464
|
+
@backend.key?(key)
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
alias has_key? key?
|
|
468
|
+
alias include? key?
|
|
469
|
+
|
|
470
|
+
# Get all keys (excluding reserved keys)
|
|
471
|
+
#
|
|
472
|
+
# @return [Array<Symbol>]
|
|
473
|
+
#
|
|
474
|
+
def keys
|
|
475
|
+
@mutex.synchronize do
|
|
476
|
+
@backend.keys.map(&:to_sym) - RESERVED_KEYS
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Get all keys including reserved
|
|
481
|
+
#
|
|
482
|
+
# @return [Array<Symbol>]
|
|
483
|
+
#
|
|
484
|
+
def all_keys
|
|
485
|
+
@mutex.synchronize do
|
|
486
|
+
@backend.keys.map(&:to_sym)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Delete a key
|
|
491
|
+
#
|
|
492
|
+
# @param key [Symbol, String]
|
|
493
|
+
# @return [Object] the deleted value
|
|
494
|
+
#
|
|
495
|
+
def delete(key)
|
|
496
|
+
key = key.to_sym
|
|
497
|
+
raise ArgumentError, "Cannot delete reserved key: #{key}" if RESERVED_KEYS.include?(key)
|
|
498
|
+
|
|
499
|
+
@mutex.synchronize do
|
|
500
|
+
@backend.delete(key)
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Clear all non-reserved keys
|
|
505
|
+
#
|
|
506
|
+
# @return [self]
|
|
507
|
+
#
|
|
508
|
+
def clear
|
|
509
|
+
@mutex.synchronize do
|
|
510
|
+
keys_to_delete = @backend.keys.map(&:to_sym) - RESERVED_KEYS
|
|
511
|
+
keys_to_delete.each { |k| @backend.delete(k) }
|
|
512
|
+
end
|
|
513
|
+
self
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Reset memory to initial state
|
|
517
|
+
#
|
|
518
|
+
# @return [self]
|
|
519
|
+
#
|
|
520
|
+
def reset
|
|
521
|
+
cached = get_internal(:cache) # Preserve cache instance
|
|
522
|
+
@mutex.synchronize do
|
|
523
|
+
@backend.clear
|
|
524
|
+
@backend[:data] = {}
|
|
525
|
+
@backend[:results] = []
|
|
526
|
+
@backend[:messages] = []
|
|
527
|
+
@backend[:session_id] = nil
|
|
528
|
+
@backend[:cache] = cached # Restore cache instance
|
|
529
|
+
end
|
|
530
|
+
@data_proxy = nil
|
|
531
|
+
self
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Format history for robot prompts
|
|
535
|
+
#
|
|
536
|
+
# Combines pre-loaded messages with formatted results.
|
|
537
|
+
#
|
|
538
|
+
# @param formatter [Proc, nil] custom result formatter
|
|
539
|
+
# @return [Array<Message>]
|
|
540
|
+
#
|
|
541
|
+
def format_history(formatter: nil)
|
|
542
|
+
formatter ||= default_formatter
|
|
543
|
+
messages + results.flat_map { |r| formatter.call(r) }
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Clone memory for isolated execution
|
|
547
|
+
#
|
|
548
|
+
# The semantic cache setting and network name are preserved in clones.
|
|
549
|
+
# Subscriptions are NOT cloned - the new memory starts with fresh subscriptions.
|
|
550
|
+
#
|
|
551
|
+
# @return [Memory]
|
|
552
|
+
#
|
|
553
|
+
def clone
|
|
554
|
+
cloned = Memory.new(
|
|
555
|
+
data: deep_dup(data.to_h),
|
|
556
|
+
results: results.dup,
|
|
557
|
+
messages: messages.dup,
|
|
558
|
+
session_id: session_id,
|
|
559
|
+
backend: @backend.is_a?(Hash) ? :hash : :auto,
|
|
560
|
+
enable_cache: @enable_cache,
|
|
561
|
+
network_name: @network_name
|
|
562
|
+
)
|
|
563
|
+
# Copy non-reserved keys (without triggering notifications)
|
|
564
|
+
keys.each { |k| cloned.send(:set_internal, k, deep_dup(get_internal(k))) }
|
|
565
|
+
cloned
|
|
566
|
+
end
|
|
567
|
+
alias dup clone
|
|
568
|
+
|
|
569
|
+
# Export memory to hash for serialization
|
|
570
|
+
#
|
|
571
|
+
# Note: The cache is not serialized as it is recreated on initialization.
|
|
572
|
+
#
|
|
573
|
+
# @return [Hash]
|
|
574
|
+
#
|
|
575
|
+
def to_h
|
|
576
|
+
{
|
|
577
|
+
data: data.to_h,
|
|
578
|
+
results: results.map(&:export),
|
|
579
|
+
messages: messages.map(&:to_h),
|
|
580
|
+
session_id: session_id,
|
|
581
|
+
custom: keys.each_with_object({}) { |k, h| h[k] = self[k] }
|
|
582
|
+
}.compact
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Convert to JSON
|
|
586
|
+
#
|
|
587
|
+
# @param args [Array] arguments passed to to_json
|
|
588
|
+
# @return [String]
|
|
589
|
+
#
|
|
590
|
+
def to_json(*args)
|
|
591
|
+
to_h.to_json(*args)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Reconstruct memory from hash
|
|
595
|
+
#
|
|
596
|
+
# A new semantic cache instance is created automatically.
|
|
597
|
+
#
|
|
598
|
+
# @param hash [Hash]
|
|
599
|
+
# @return [Memory]
|
|
600
|
+
#
|
|
601
|
+
def self.from_hash(hash)
|
|
602
|
+
hash = hash.transform_keys(&:to_sym)
|
|
603
|
+
memory = new(
|
|
604
|
+
data: hash[:data] || {},
|
|
605
|
+
results: (hash[:results] || []).map { |r| RobotResult.from_hash(r) },
|
|
606
|
+
messages: (hash[:messages] || []).map { |m| Message.from_hash(m) },
|
|
607
|
+
session_id: hash[:session_id]
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Restore custom keys
|
|
611
|
+
(hash[:custom] || {}).each { |k, v| memory[k] = v }
|
|
612
|
+
|
|
613
|
+
memory
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Check if using Redis backend
|
|
617
|
+
#
|
|
618
|
+
# @return [Boolean]
|
|
619
|
+
#
|
|
620
|
+
def redis?
|
|
621
|
+
@backend.is_a?(RedisBackend)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
private
|
|
625
|
+
|
|
626
|
+
def create_semantic_cache
|
|
627
|
+
RubyLLM::SemanticCache
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def select_backend(preference)
|
|
631
|
+
case preference
|
|
632
|
+
when :redis
|
|
633
|
+
create_redis_backend || create_hash_backend
|
|
634
|
+
when :hash
|
|
635
|
+
create_hash_backend
|
|
636
|
+
else # :auto
|
|
637
|
+
create_redis_backend || create_hash_backend
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def create_redis_backend
|
|
642
|
+
return nil unless redis_available?
|
|
643
|
+
|
|
644
|
+
RedisBackend.new
|
|
645
|
+
rescue StandardError
|
|
646
|
+
nil
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def create_hash_backend
|
|
650
|
+
{}
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def redis_available?
|
|
654
|
+
return false unless defined?(Redis)
|
|
655
|
+
|
|
656
|
+
# Check if Redis is configured in RobotLab
|
|
657
|
+
redis_config = RobotLab.configuration.respond_to?(:redis) ? RobotLab.configuration.redis : nil
|
|
658
|
+
redis_config || ENV["REDIS_URL"]
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def get_internal(key)
|
|
662
|
+
@mutex.synchronize do
|
|
663
|
+
@backend[key.to_sym]
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def set_internal(key, value)
|
|
668
|
+
@mutex.synchronize do
|
|
669
|
+
@backend[key.to_sym] = value
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def normalize_message(msg)
|
|
674
|
+
case msg
|
|
675
|
+
when Message
|
|
676
|
+
msg
|
|
677
|
+
when Hash
|
|
678
|
+
Message.from_hash(msg)
|
|
679
|
+
else
|
|
680
|
+
raise ArgumentError, "Invalid message: must be Message or Hash"
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def default_formatter
|
|
685
|
+
->(result) { result.output + result.tool_calls }
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def deep_dup(obj)
|
|
689
|
+
case obj
|
|
690
|
+
when Hash
|
|
691
|
+
obj.transform_values { |v| deep_dup(v) }
|
|
692
|
+
when Array
|
|
693
|
+
obj.map { |v| deep_dup(v) }
|
|
694
|
+
else
|
|
695
|
+
obj.dup rescue obj
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# =========================================================================
|
|
700
|
+
# Reactive Memory Helpers
|
|
701
|
+
# =========================================================================
|
|
702
|
+
|
|
703
|
+
def get_single(key, wait:)
|
|
704
|
+
# Try immediate read
|
|
705
|
+
value = @mutex.synchronize { @backend[key] }
|
|
706
|
+
return value unless value.nil? && wait
|
|
707
|
+
|
|
708
|
+
# Need to wait
|
|
709
|
+
timeout = wait == true ? nil : wait
|
|
710
|
+
wait_for_key(key, timeout: timeout)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def get_multiple(keys, wait:)
|
|
714
|
+
results = {}
|
|
715
|
+
missing = []
|
|
716
|
+
|
|
717
|
+
@mutex.synchronize do
|
|
718
|
+
keys.each do |key|
|
|
719
|
+
if @backend.key?(key)
|
|
720
|
+
results[key] = @backend[key]
|
|
721
|
+
else
|
|
722
|
+
missing << key
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
return results if missing.empty? || !wait
|
|
728
|
+
|
|
729
|
+
# Wait for missing keys
|
|
730
|
+
timeout = wait == true ? nil : wait
|
|
731
|
+
missing.each do |key|
|
|
732
|
+
results[key] = wait_for_key(key, timeout: timeout)
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
results
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def wait_for_key(key, timeout:)
|
|
739
|
+
waiter = Waiter.new
|
|
740
|
+
|
|
741
|
+
@waiter_mutex.synchronize do
|
|
742
|
+
# Double-check - value might have arrived while setting up
|
|
743
|
+
value = @mutex.synchronize { @backend[key] }
|
|
744
|
+
return value unless value.nil?
|
|
745
|
+
|
|
746
|
+
@waiters[key] << waiter
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
result = waiter.wait(timeout: timeout)
|
|
750
|
+
|
|
751
|
+
if result == :timeout
|
|
752
|
+
# Clean up the waiter
|
|
753
|
+
@waiter_mutex.synchronize { @waiters[key].delete(waiter) }
|
|
754
|
+
raise AwaitTimeout, "Timeout waiting for :#{key} after #{timeout} seconds"
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
result
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def wake_waiters(key, value)
|
|
761
|
+
waiters = @waiter_mutex.synchronize { @waiters.delete(key) || [] }
|
|
762
|
+
waiters.each { |w| w.signal(value) }
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def notify_subscribers_async(key, value, old_value)
|
|
766
|
+
# Collect all matching subscribers
|
|
767
|
+
callbacks = []
|
|
768
|
+
|
|
769
|
+
@subscription_mutex.synchronize do
|
|
770
|
+
# Exact key matches
|
|
771
|
+
callbacks.concat(@subscriptions[key].map { |s| s[:callback] })
|
|
772
|
+
|
|
773
|
+
# Pattern matches
|
|
774
|
+
key_str = key.to_s
|
|
775
|
+
@pattern_subscriptions.each do |sub|
|
|
776
|
+
callbacks << sub[:callback] if sub[:pattern].match?(key_str)
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
return if callbacks.empty?
|
|
781
|
+
|
|
782
|
+
# Build the change object
|
|
783
|
+
change = MemoryChange.new(
|
|
784
|
+
key: key,
|
|
785
|
+
value: value,
|
|
786
|
+
previous: old_value,
|
|
787
|
+
writer: @current_writer,
|
|
788
|
+
network_name: @network_name,
|
|
789
|
+
timestamp: Time.now
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
# Dispatch callbacks asynchronously
|
|
793
|
+
callbacks.each do |callback|
|
|
794
|
+
dispatch_async { callback.call(change) }
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def dispatch_async(&block)
|
|
799
|
+
# Use Async if available (preferred for fiber-based concurrency)
|
|
800
|
+
if defined?(Async) && Async::Task.current?
|
|
801
|
+
Async { block.call }
|
|
802
|
+
else
|
|
803
|
+
# Fall back to Thread for basic async dispatch
|
|
804
|
+
Thread.new do
|
|
805
|
+
block.call
|
|
806
|
+
rescue StandardError => e
|
|
807
|
+
# Log but don't crash the notification system
|
|
808
|
+
warn "Memory subscription callback error: #{e.message}"
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def generate_subscription_id
|
|
814
|
+
SecureRandom.uuid
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def pattern_to_regex(pattern)
|
|
818
|
+
# Convert glob pattern to regex
|
|
819
|
+
regex_str = pattern
|
|
820
|
+
.gsub(".", "\\.")
|
|
821
|
+
.gsub("*", ".*")
|
|
822
|
+
.gsub("?", ".")
|
|
823
|
+
|
|
824
|
+
Regexp.new("\\A#{regex_str}\\z")
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Redis backend for Memory (optional, loaded when Redis is available)
|
|
829
|
+
#
|
|
830
|
+
# @api private
|
|
831
|
+
class RedisBackend
|
|
832
|
+
def initialize
|
|
833
|
+
@redis = create_redis_connection
|
|
834
|
+
@namespace = "robot_lab:memory:#{SecureRandom.uuid}"
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
def [](key)
|
|
838
|
+
value = @redis.get("#{@namespace}:#{key}")
|
|
839
|
+
value ? JSON.parse(value, symbolize_names: true) : nil
|
|
840
|
+
rescue JSON::ParserError
|
|
841
|
+
value
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def []=(key, value)
|
|
845
|
+
serialized = value.is_a?(String) ? value : value.to_json
|
|
846
|
+
@redis.set("#{@namespace}:#{key}", serialized)
|
|
847
|
+
value
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def key?(key)
|
|
851
|
+
@redis.exists?("#{@namespace}:#{key}")
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def keys
|
|
855
|
+
@redis.keys("#{@namespace}:*").map { |k| k.sub("#{@namespace}:", "").to_sym }
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def delete(key)
|
|
859
|
+
value = self[key]
|
|
860
|
+
@redis.del("#{@namespace}:#{key}")
|
|
861
|
+
value
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def clear
|
|
865
|
+
keys.each { |k| delete(k) }
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
private
|
|
869
|
+
|
|
870
|
+
def create_redis_connection
|
|
871
|
+
redis_config = RobotLab.configuration.respond_to?(:redis) ? RobotLab.configuration.redis : nil
|
|
872
|
+
|
|
873
|
+
if redis_config.is_a?(Hash)
|
|
874
|
+
Redis.new(**redis_config)
|
|
875
|
+
elsif ENV["REDIS_URL"]
|
|
876
|
+
Redis.new(url: ENV["REDIS_URL"])
|
|
877
|
+
else
|
|
878
|
+
Redis.new
|
|
879
|
+
end
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
end
|