robot_lab-durable 0.1.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.
@@ -0,0 +1,182 @@
1
+ # Durable Learning for RobotLab
2
+
3
+ **Date**: 2026-05-06
4
+ **Status**: Draft
5
+
6
+ ## Problem
7
+
8
+ Robots built with RobotLab have no memory between sessions and no mechanism for improving their judgment over time. Each run starts from zero. The newsletter reader robot is the motivating case: it should get better at deciding what content belongs in the Obsidian PKM vault without requiring the user to explain their preferences every time.
9
+
10
+ ## Goals
11
+
12
+ - Robots accumulate knowledge within a session (explicit during operation, reflection at end)
13
+ - Knowledge persists across sessions and across projects
14
+ - Conservative bias: when uncertain and no relevant past learning exists, skip rather than guess
15
+ - Human-readable, auditable, directly editable storage
16
+ - General capability — not newsletter-specific
17
+
18
+ ## Non-goals
19
+
20
+ - Automated curator/consolidation (YAGNI for now)
21
+ - Confidence decay over time (YAGNI for now)
22
+ - Fine-tuning or model-level learning (out of scope)
23
+
24
+ ---
25
+
26
+ ## Design
27
+
28
+ ### `RobotLab::Durable::Entry`
29
+
30
+ A single knowledge record. Immutable value object (Ruby `Data.define`).
31
+
32
+ | Field | Type | Description |
33
+ |--------------|---------|----------------------------------------------------------------|
34
+ | `content` | String | The fact, pattern, or preference learned, in plain language |
35
+ | `reasoning` | String | Why — captured from discussion or observation |
36
+ | `category` | Symbol | `:fact`, `:preference`, `:pattern`, `:correction` |
37
+ | `domain` | String | Topic area ("Ruby tooling", "newsletter curation") |
38
+ | `confidence` | Float | 0.1 initial, increments by 0.1 each confirmed application, max 1.0 |
39
+ | `use_count` | Integer | Times recalled and applied |
40
+ | `created_at` | String | ISO8601 timestamp |
41
+ | `updated_at` | String | ISO8601 timestamp |
42
+
43
+ ### `RobotLab::Durable::Store`
44
+
45
+ Reads and writes `Entry` records. Storage: Markdown files with YAML frontmatter in `lib/robot_lab/durable/`, one file per domain (e.g., `newsletter_curation.md`, `ruby_tooling.md`).
46
+
47
+ File format mirrors the Obsidian PKM convention — intentionally familiar:
48
+
49
+ ```markdown
50
+ ---
51
+ domain: newsletter curation
52
+ entries:
53
+ - content: Skip LangChain content
54
+ reasoning: User is Ruby-only and considers Python tooling irrelevant
55
+ category: preference
56
+ confidence: 0.4
57
+ use_count: 3
58
+ created_at: "2026-05-06T12:00:00Z"
59
+ updated_at: "2026-05-06T14:30:00Z"
60
+ ---
61
+ ```
62
+
63
+ Key methods:
64
+ - `recall(query:, domain: nil, min_confidence: 0.0)` — returns relevant entries, sorted by confidence
65
+ - `record(entry)` — appends or updates an entry in the appropriate domain file
66
+ - `confirm(entry)` — increments `confidence` by 0.1 and `use_count` by 1, updates `updated_at`
67
+
68
+ Matching strategy: keyword overlap on `content` + `domain` fields. Semantic search via fastembed is a future enhancement.
69
+
70
+ ### `RobotLab::Durable::Reflector`
71
+
72
+ A lightweight robot that runs at session end. Receives the session's `Memory` (messages, results, data) and the existing `Store`. Its job: identify observations from the session worth promoting to durable knowledge and call `store.record` for each.
73
+
74
+ Implemented as a `RobotLab.build` robot with a focused system prompt. Uses `RecordKnowledge` tool internally.
75
+
76
+ ### `RobotLab::Durable::Learning` (mixin)
77
+
78
+ Included in a `Robot` to activate learning capability.
79
+
80
+ ```ruby
81
+ robot = RobotLab.build(
82
+ name: "newsletter_analyst",
83
+ system_prompt: "...",
84
+ local_tools: [FetchLatestNewsletter],
85
+ learn: true, # activates Durable::Learning
86
+ learn_domain: "newsletter curation"
87
+ )
88
+ ```
89
+
90
+ What the mixin does:
91
+ 1. Adds `RecallKnowledge` and `RecordKnowledge` to the robot's tool list
92
+ 2. Runs a `recall` pass on session start, injecting relevant entries as context
93
+ 3. Registers an `on_session_end` hook that triggers `Durable::Reflector`
94
+
95
+ ### `RecallKnowledge` tool
96
+
97
+ The robot calls this before acting on anything uncertain.
98
+
99
+ ```
100
+ Input: query (String), domain (String, optional)
101
+ Output: Array of matching Entry records as formatted context
102
+ ```
103
+
104
+ ### `RecordKnowledge` tool
105
+
106
+ The robot calls this during the session when it judges something worth keeping.
107
+
108
+ ```
109
+ Input: content, reasoning, category, domain
110
+ Output: confirmation the entry was written
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Learning Loop
116
+
117
+ ```
118
+ Session start
119
+ └─ Store.recall → inject relevant entries as context
120
+
121
+ During session
122
+ └─ Robot discusses/decides
123
+ └─ Robot calls RecordKnowledge (explicit, in-moment captures)
124
+ └─ Robot calls RecallKnowledge before uncertain decisions
125
+ └─ On confirmed application → Store.confirm (confidence++)
126
+
127
+ Session end
128
+ └─ Reflector reviews session Memory
129
+ └─ Promotes observations not yet explicitly recorded
130
+ └─ Writes to Store
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Conservative Bias
136
+
137
+ When `recall_knowledge` returns no relevant entries AND the robot's confidence on a candidate decision is below 0.5, the robot skips the action and logs the reason. This is enforced through system prompt guidance, not framework code.
138
+
139
+ ---
140
+
141
+ ## Storage Location
142
+
143
+ `lib/robot_lab/durable/` — already exists as a placeholder. Domain files live here:
144
+
145
+ ```
146
+ lib/robot_lab/durable/
147
+ newsletter_curation.md
148
+ ruby_tooling.md
149
+ ...
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Newsletter Reader Integration
155
+
156
+ The newsletter robot uses `learn: true, learn_domain: "newsletter curation"`. Its workflow:
157
+
158
+ 1. Fetch newsletter RSS
159
+ 2. Follow links one level deep, extract article content
160
+ 3. For each article: `RecallKnowledge` to check against past decisions
161
+ 4. High-confidence match → act autonomously (include or skip)
162
+ 5. No match or low confidence → skip and log
163
+ 6. Discussion with user → `RecordKnowledge` captures reasoning
164
+ 7. Session end → `Reflector` promotes any missed observations
165
+
166
+ Over time, the confidence on frequently-confirmed patterns rises, and the robot handles more autonomously with fewer discussions required.
167
+
168
+ ---
169
+
170
+ ## Files to Create
171
+
172
+ | File | Purpose |
173
+ |------|---------|
174
+ | `lib/robot_lab/durable/entry.rb` | `Entry` data class |
175
+ | `lib/robot_lab/durable/store.rb` | Read/write/confirm operations |
176
+ | `lib/robot_lab/durable/reflector.rb` | End-of-session reflection robot |
177
+ | `lib/robot_lab/durable/learning.rb` | `Learning` mixin for Robot |
178
+ | `lib/robot_lab/tools/recall_knowledge.rb` | `RecallKnowledge` tool |
179
+ | `lib/robot_lab/tools/record_knowledge.rb` | `RecordKnowledge` tool |
180
+ | `test/robot_lab/durable/entry_test.rb` | Unit tests |
181
+ | `test/robot_lab/durable/store_test.rb` | Unit tests |
182
+ | `examples/32_newsletter_reader.rb` | Updated with `learn: true` |
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 33: XYZZY Stock Price Generator
5
+ #
6
+ # Publishes fake streaming prices for ticker XYZZY to a Redis channel
7
+ # using Geometric Brownian Motion with occasional volatility regime shifts.
8
+ #
9
+ # Prerequisites:
10
+ # gem install redis
11
+ # Redis server running on localhost:6379
12
+ #
13
+ # Usage:
14
+ # ruby examples/33_stock_generator.rb
15
+
16
+ require "redis"
17
+ require "json"
18
+ require "time"
19
+
20
+ CHANNEL = "stock:xyzzy"
21
+ START_PRICE = 100.0
22
+ BASE_VOL = 0.008 # baseline volatility per tick (~0.8%)
23
+ DRIFT = 0.0001 # slight upward drift per tick
24
+
25
+ # Box-Muller transform — standard normal sample
26
+ def randn
27
+ Math.sqrt(-2.0 * Math.log(rand)) * Math.cos(2.0 * Math::PI * rand)
28
+ end
29
+
30
+ # Occasionally shift the volatility regime to create interesting price dynamics
31
+ def current_volatility(tick)
32
+ case tick % 60
33
+ when 0..10 then BASE_VOL * 2.0 # high volatility burst
34
+ when 30..35 then BASE_VOL * 0.4 # low volatility squeeze
35
+ else BASE_VOL
36
+ end
37
+ end
38
+
39
+ # ── Main ──────────────────────────────────────────────────────────────────────
40
+
41
+ redis = Redis.new
42
+ price = START_PRICE
43
+ tick = 0
44
+
45
+ trap("INT") { puts "\nGenerator stopped."; exit }
46
+
47
+ puts "=" * 50
48
+ puts "XYZZY Stock Generator"
49
+ puts "=" * 50
50
+ puts "Channel : #{CHANNEL}"
51
+ puts "Interval: 5 seconds per tick"
52
+ puts "Model : Geometric Brownian Motion"
53
+ puts "Press Ctrl-C to stop."
54
+ puts "-" * 50
55
+
56
+ loop do
57
+ tick += 1
58
+
59
+ vol = current_volatility(tick)
60
+ price = (price * Math.exp((DRIFT - 0.5 * vol**2) + vol * randn)).round(2)
61
+ price = [price, 1.0].max # floor at $1.00
62
+
63
+ regime = case vol
64
+ when BASE_VOL * 2.0 then " [HIGH VOL]"
65
+ when BASE_VOL * 0.4 then " [low vol]"
66
+ else ""
67
+ end
68
+
69
+ payload = JSON.generate(
70
+ ticker: "XYZZY",
71
+ price: price,
72
+ tick: tick,
73
+ timestamp: Time.now.iso8601
74
+ )
75
+
76
+ redis.publish(CHANNEL, payload)
77
+ puts "Tick %5d $%8.2f vol=%.3f%s" % [tick, price, vol, regime]
78
+
79
+ sleep 5
80
+ end
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 33: XYZZY Stock Price Predictor
5
+ #
6
+ # Consumes fake streaming prices for ticker XYZZY from a Redis channel,
7
+ # predicts the high and low over the next price window using an SMA + EMA
8
+ # ensemble, and uses a RobotLab learning robot to tune predictor parameters
9
+ # after each window closes.
10
+ #
11
+ # Run alongside:
12
+ # ruby examples/33_stock_generator.rb (in a separate terminal)
13
+ #
14
+ # Prerequisites:
15
+ # gem install redis
16
+ # Redis server running on localhost:6379
17
+ #
18
+ # Usage:
19
+ # ruby examples/33_stock_predictor.rb
20
+
21
+ require "robot_lab"
22
+ require "robot_lab/durable"
23
+ require "redis"
24
+ require "json"
25
+
26
+ CHANNEL = "stock:xyzzy"
27
+ WINDOW_SIZE = 12 # ticks per prediction window
28
+
29
+ # ── Mutable predictor parameters ──────────────────────────────────────────────
30
+
31
+ module PredictorConfig
32
+ @sma_window = 10
33
+ @sma_std_multiplier = 1.5
34
+ @ema_alpha = 0.2
35
+ @ema_vol_multiplier = 2.0
36
+ @sma_weight = 0.5
37
+
38
+ class << self
39
+ attr_accessor :sma_window, :sma_std_multiplier, :ema_alpha,
40
+ :ema_vol_multiplier, :sma_weight
41
+
42
+ def summary
43
+ format(
44
+ "sma_window=%d sma_std=%.2f ema_alpha=%.2f ema_vol=%.2f sma_weight=%.2f",
45
+ sma_window, sma_std_multiplier, ema_alpha, ema_vol_multiplier, sma_weight
46
+ )
47
+ end
48
+ end
49
+ end
50
+
51
+ # ── SMA predictor ──────────────────────────────────────────────────────────────
52
+
53
+ module SMAPredictor
54
+ def self.predict(prices)
55
+ window = prices.last(PredictorConfig.sma_window)
56
+ mean = window.sum / window.size.to_f
57
+ var = window.sum { |p| (p - mean)**2 } / window.size.to_f
58
+ std = Math.sqrt(var)
59
+ mult = PredictorConfig.sma_std_multiplier
60
+
61
+ {
62
+ high: (mean + mult * std).round(2),
63
+ low: [mean - mult * std, 1.0].max.round(2)
64
+ }
65
+ end
66
+ end
67
+
68
+ # ── EMA predictor (stateful — updated every tick) ──────────────────────────────
69
+
70
+ module EMAPredictor
71
+ @ema = nil
72
+ @var_ema = nil
73
+
74
+ class << self
75
+ def update(price)
76
+ alpha = PredictorConfig.ema_alpha
77
+ if @ema.nil?
78
+ @ema = price
79
+ @var_ema = 0.0
80
+ else
81
+ delta = price - @ema
82
+ @ema = alpha * price + (1 - alpha) * @ema
83
+ @var_ema = alpha * delta**2 + (1 - alpha) * @var_ema
84
+ end
85
+ end
86
+
87
+ def predict
88
+ return nil if @ema.nil?
89
+
90
+ vol = Math.sqrt(@var_ema) * PredictorConfig.ema_vol_multiplier
91
+ {
92
+ high: (@ema + vol).round(2),
93
+ low: [@ema - vol, 1.0].max.round(2)
94
+ }
95
+ end
96
+ end
97
+ end
98
+
99
+ # ── Ensemble predictor ─────────────────────────────────────────────────────────
100
+
101
+ module EnsemblePredictor
102
+ def self.predict(prices)
103
+ sma = SMAPredictor.predict(prices)
104
+ ema = EMAPredictor.predict
105
+ return sma unless ema
106
+
107
+ w = PredictorConfig.sma_weight
108
+ {
109
+ high: (w * sma[:high] + (1 - w) * ema[:high]).round(2),
110
+ low: (w * sma[:low] + (1 - w) * ema[:low]).round(2)
111
+ }
112
+ end
113
+ end
114
+
115
+ # ── AdjustParameters tool ──────────────────────────────────────────────────────
116
+
117
+ class AdjustParameters < RobotLab::Tool
118
+ description "Adjust one predictor parameter to improve future prediction accuracy. " \
119
+ "Make at most one or two targeted changes per window."
120
+
121
+ param :parameter, type: "string",
122
+ desc: "Parameter to adjust: sma_window, sma_std_multiplier, ema_alpha, ema_vol_multiplier, sma_weight"
123
+ param :value, type: "number",
124
+ desc: "New value (sma_window: 3-30 int; std/vol multipliers: 0.5-4.0; ema_alpha: 0.05-0.5; sma_weight: 0.0-1.0)"
125
+ param :reasoning, type: "string",
126
+ desc: "Why this change should reduce prediction error"
127
+
128
+ LIMITS = {
129
+ "sma_window" => { min: 3, max: 30, integer: true },
130
+ "sma_std_multiplier" => { min: 0.5, max: 4.0, integer: false },
131
+ "ema_alpha" => { min: 0.05, max: 0.5, integer: false },
132
+ "ema_vol_multiplier" => { min: 0.5, max: 4.0, integer: false },
133
+ "sma_weight" => { min: 0.0, max: 1.0, integer: false }
134
+ }.freeze
135
+
136
+ def execute(parameter:, value:, reasoning:)
137
+ spec = LIMITS[parameter]
138
+ return "Unknown parameter '#{parameter}'. Valid: #{LIMITS.keys.join(", ")}" unless spec
139
+
140
+ clamped = value.to_f.clamp(spec[:min], spec[:max])
141
+ clamped = clamped.round if spec[:integer]
142
+
143
+ PredictorConfig.send(:"#{parameter}=", clamped)
144
+
145
+ "Set #{parameter} = #{clamped}. #{reasoning}"
146
+ end
147
+ end
148
+
149
+ # ── Error metrics ──────────────────────────────────────────────────────────────
150
+
151
+ WindowResult = Data.define(
152
+ :window_num,
153
+ :predicted_high, :predicted_low,
154
+ :actual_high, :actual_low,
155
+ :high_err, :low_err, :mean_err
156
+ )
157
+
158
+ def evaluate_window(window_num, predicted, actuals)
159
+ actual_high = actuals.max.round(2)
160
+ actual_low = actuals.min.round(2)
161
+ high_err = (predicted[:high] - actual_high).abs.round(2)
162
+ low_err = (predicted[:low] - actual_low).abs.round(2)
163
+ mean_err = ((high_err + low_err) / 2.0).round(2)
164
+
165
+ WindowResult.new(
166
+ window_num:,
167
+ predicted_high: predicted[:high], predicted_low: predicted[:low],
168
+ actual_high:, actual_low:,
169
+ high_err:, low_err:, mean_err:
170
+ )
171
+ end
172
+
173
+ def tuner_prompt(result)
174
+ <<~PROMPT
175
+ Window #{result.window_num} just closed.
176
+
177
+ Prediction vs Actual:
178
+ Predicted: high=$#{result.predicted_high} low=$#{result.predicted_low}
179
+ Actual: high=$#{result.actual_high} low=$#{result.actual_low}
180
+ Error: high_err=$#{result.high_err} low_err=$#{result.low_err} mean_err=$#{result.mean_err}
181
+
182
+ Current parameters:
183
+ #{PredictorConfig.summary}
184
+
185
+ Window size: #{WINDOW_SIZE} ticks.
186
+
187
+ First call RecallKnowledge to check what has worked before.
188
+ Then decide whether to adjust a parameter via AdjustParameters.
189
+ If the error is acceptable or you are uncertain, do nothing.
190
+ If you notice a clear pattern worth preserving, call RecordKnowledge.
191
+ PROMPT
192
+ end
193
+
194
+ # ── Main ──────────────────────────────────────────────────────────────────────
195
+
196
+ puts "=" * 60
197
+ puts "XYZZY Stock Predictor"
198
+ puts "=" * 60
199
+ puts "Channel : #{CHANNEL}"
200
+ puts "Window : #{WINDOW_SIZE} ticks"
201
+ puts "Model : SMA + EMA Ensemble with Durable Learning"
202
+ puts "Warmup : #{PredictorConfig.sma_window} ticks"
203
+ puts "Press Ctrl-C to stop."
204
+ puts "-" * 60
205
+
206
+ redis = Redis.new
207
+ prices = []
208
+ robot = RobotLab.build(
209
+ name: "predictor_tuner",
210
+ system_prompt: <<~PROMPT,
211
+ You are a quantitative analyst tuning an ensemble stock price range
212
+ predictor for ticker XYZZY. Each prediction covers the high and low
213
+ price over the next #{WINDOW_SIZE} ticks.
214
+
215
+ The ensemble combines a Simple Moving Average (SMA) band and an
216
+ Exponential Moving Average (EMA) band. Adjustable parameters:
217
+
218
+ sma_window (3-30 int) — lookback period for SMA
219
+ sma_std_multiplier (0.5-4.0) — band width relative to SMA stddev
220
+ ema_alpha (0.05-0.5) — EMA smoothing (higher = more reactive)
221
+ ema_vol_multiplier (0.5-4.0) — band width relative to EMA volatility
222
+ sma_weight (0.0-1.0) — SMA share in ensemble (EMA = 1 - weight)
223
+
224
+ Workflow per window:
225
+ 1. Call RecallKnowledge to check past findings before acting.
226
+ 2. If the error is clearly too high/low in one direction, adjust the
227
+ relevant band multiplier via AdjustParameters.
228
+ 3. Make at most two adjustments per window to isolate cause and effect.
229
+ 4. If you observe a reliable pattern, call RecordKnowledge to preserve it.
230
+ 5. When uncertain, do nothing rather than guess.
231
+ PROMPT
232
+ local_tools: [AdjustParameters],
233
+ learn: true,
234
+ learn_domain: "xyzzy stock prediction"
235
+ )
236
+
237
+ warmed_up = false
238
+ pending_pred = nil # { prediction: {high:, low:}, window_prices: [] }
239
+ window_num = 0
240
+
241
+ trap("INT") { puts "\nPredictor stopped."; exit }
242
+
243
+ puts "Connecting to Redis and subscribing to #{CHANNEL}..."
244
+
245
+ redis.subscribe(CHANNEL) do |on|
246
+ on.message do |_channel, payload|
247
+ data = JSON.parse(payload, symbolize_names: true)
248
+ tick = data[:tick]
249
+ price = data[:price].to_f
250
+
251
+ EMAPredictor.update(price)
252
+ prices << price
253
+
254
+ # ── Warmup phase ──────────────────────────────────────────────
255
+ unless warmed_up
256
+ if prices.size < PredictorConfig.sma_window
257
+ puts "Tick %5d $%8.2f [warming up %d/%d]" % [tick, price, prices.size, PredictorConfig.sma_window]
258
+ next
259
+ end
260
+
261
+ warmed_up = true
262
+ pred = EnsemblePredictor.predict(prices)
263
+ pending_pred = { prediction: pred, window_prices: [] }
264
+
265
+ puts "Tick %5d $%8.2f [warmup done]" % [tick, price]
266
+ puts " First prediction → high=$#{pred[:high]} low=$#{pred[:low]}"
267
+ next
268
+ end
269
+
270
+ # ── Accumulate current window ──────────────────────────────────
271
+ pending_pred[:window_prices] << price
272
+ progress = pending_pred[:window_prices].size
273
+ pred = pending_pred[:prediction]
274
+
275
+ puts "Tick %5d $%8.2f [%2d/#{WINDOW_SIZE}] (pred high=$#{pred[:high]} low=$#{pred[:low]})" %
276
+ [tick, price, progress]
277
+
278
+ next unless progress >= WINDOW_SIZE
279
+
280
+ # ── Window closed — evaluate ───────────────────────────────────
281
+ window_num += 1
282
+ result = evaluate_window(window_num, pred, pending_pred[:window_prices])
283
+
284
+ puts "\n#{"─" * 60}"
285
+ puts " Window #{result.window_num} result:"
286
+ puts " Predicted high=$%-8.2f low=$%-.2f" % [result.predicted_high, result.predicted_low]
287
+ puts " Actual high=$%-8.2f low=$%-.2f" % [result.actual_high, result.actual_low]
288
+ puts " Error high=%-8.2f low=%-8.2f mean=%.2f" % [result.high_err, result.low_err, result.mean_err]
289
+ puts "#{"─" * 60}"
290
+
291
+ print " [tuner] analyzing window #{window_num}..."
292
+ tuner_response = robot.run(tuner_prompt(result))
293
+ tuner_line = tuner_response.reply.lines.first&.chomp || "(no response)"
294
+ puts "\r [tuner] #{tuner_line}#{" " * 20}"
295
+ puts " Params: #{PredictorConfig.summary}"
296
+ puts
297
+
298
+ # ── Start next window ──────────────────────────────────────────
299
+ new_pred = EnsemblePredictor.predict(prices)
300
+ pending_pred = { prediction: new_pred, window_prices: [] }
301
+ puts " Next prediction → high=$#{new_pred[:high]} low=$#{new_pred[:low]}"
302
+ puts
303
+ end
304
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Durable
5
+ Entry = Data.define(:content, :reasoning, :category, :domain, :confidence, :use_count, :created_at, :updated_at) do
6
+ CONFIDENCE_INCREMENT = 0.1
7
+ MAX_CONFIDENCE = 1.0
8
+
9
+ # Return a new Entry with confidence incremented and use_count bumped.
10
+ def confirm
11
+ new_confidence = [confidence + CONFIDENCE_INCREMENT, MAX_CONFIDENCE].min
12
+ with(
13
+ confidence: new_confidence.round(10),
14
+ use_count: use_count + 1,
15
+ updated_at: Time.now.iso8601
16
+ )
17
+ end
18
+
19
+ # Serialize to a plain Hash with string keys (safe for YAML round-trip).
20
+ def to_h
21
+ {
22
+ "content" => content,
23
+ "reasoning" => reasoning,
24
+ "category" => category.to_s,
25
+ "domain" => domain,
26
+ "confidence" => confidence,
27
+ "use_count" => use_count,
28
+ "created_at" => created_at,
29
+ "updated_at" => updated_at
30
+ }
31
+ end
32
+
33
+ # Deserialize from a Hash (string or symbol keys).
34
+ def self.from_h(hash)
35
+ h = hash.transform_keys(&:to_s)
36
+ new(
37
+ content: h["content"],
38
+ reasoning: h["reasoning"],
39
+ category: h["category"]&.to_sym,
40
+ domain: h["domain"],
41
+ confidence: h["confidence"].to_f,
42
+ use_count: h["use_count"].to_i,
43
+ created_at: h["created_at"],
44
+ updated_at: h["updated_at"]
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Durable
5
+ module Learning
6
+ # Configure durable learning on a robot after initialization.
7
+ #
8
+ # @param domain [String] topic area for this robot's knowledge
9
+ # @param store_path [String, nil] override default ~/.robot_lab/durable path
10
+ def setup_durable_learning(domain:, store_path: nil)
11
+ @learn_domain = domain.to_s
12
+ opts = store_path ? { path: store_path } : {}
13
+ @durable_store = Store.new(**opts)
14
+
15
+ seed_from_store
16
+ @local_tools = (@local_tools + [RecallKnowledge, RecordKnowledge]).uniq
17
+ end
18
+
19
+ # Run the end-of-session reflection pass.
20
+ # Called automatically from Robot#run when durable learning is active.
21
+ def run_reflector
22
+ return unless @durable_store && @learn_domain && @learnings&.any?
23
+
24
+ Reflector.new(store: @durable_store, domain: @learn_domain).reflect(@learnings)
25
+ end
26
+
27
+ private
28
+
29
+ def seed_from_store
30
+ return unless @durable_store && @learn_domain
31
+
32
+ entries = @durable_store.recall(query: @learn_domain, domain: @learn_domain, min_confidence: 0.0)
33
+ entries.each do |e|
34
+ learn("[#{e.category}] #{e.content}: #{e.reasoning}")
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end