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,1247 @@
1
+ # Durable Learning Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add cross-session and within-session learning capability to RobotLab robots via a `Durable::Store`, two tools (`RecallKnowledge`, `RecordKnowledge`), an end-of-session `Durable::Reflector`, and a `Durable::Learning` mixin opt-in on `Robot`.
6
+
7
+ **Architecture:** Knowledge entries are structured YAML files in `~/.robot_lab/durable/` (one file per domain), read/written by `Durable::Store`. Robots that opt-in with `learn: true` get two tools and an end-of-session reflection pass. The existing `@learnings` / `learn()` / `inject_learnings` mechanism on `Robot` serves as the within-session layer; durable storage is the cross-session layer.
8
+
9
+ **Tech Stack:** Ruby `Data.define` (value objects), `YAML` stdlib, Minitest, Zeitwerk autoloading (files in `lib/robot_lab/durable/` auto-register as `RobotLab::Durable::*`).
10
+
11
+ ---
12
+
13
+ ## File Map
14
+
15
+ | Action | Path | Responsibility |
16
+ |--------|------|----------------|
17
+ | Create | `lib/robot_lab/durable/entry.rb` | `Entry` value object — one knowledge record |
18
+ | Create | `lib/robot_lab/durable/store.rb` | Read/write/confirm YAML domain files |
19
+ | Create | `lib/robot_lab/durable/reflector.rb` | Promote session learnings to store at session end |
20
+ | Create | `lib/robot_lab/durable/learning.rb` | `Learning` mixin — wires everything to Robot |
21
+ | Create | `lib/robot_lab/recall_knowledge.rb` | `RecallKnowledge` tool — query store before deciding |
22
+ | Create | `lib/robot_lab/record_knowledge.rb` | `RecordKnowledge` tool — write to store during session |
23
+ | Create | `test/robot_lab/durable/entry_test.rb` | Unit tests for `Entry` |
24
+ | Create | `test/robot_lab/durable/store_test.rb` | Unit tests for `Store` |
25
+ | Create | `test/robot_lab/durable/reflector_test.rb` | Unit tests for `Reflector` |
26
+ | Create | `test/robot_lab/recall_knowledge_test.rb` | Unit tests for `RecallKnowledge` |
27
+ | Create | `test/robot_lab/record_knowledge_test.rb` | Unit tests for `RecordKnowledge` |
28
+ | Modify | `lib/robot_lab/robot.rb` | Add `learn:`, `learn_domain:`, `durable_store` |
29
+ | Modify | `examples/32_newsletter_reader.rb` | Add `learn: true, learn_domain: "newsletter curation"` |
30
+
31
+ ---
32
+
33
+ ## Task 1: `Durable::Entry`
34
+
35
+ **Files:**
36
+ - Create: `lib/robot_lab/durable/entry.rb`
37
+ - Create: `test/robot_lab/durable/entry_test.rb`
38
+
39
+ - [ ] **Step 1: Create the test file**
40
+
41
+ ```ruby
42
+ # test/robot_lab/durable/entry_test.rb
43
+ # frozen_string_literal: true
44
+
45
+ require "test_helper"
46
+
47
+ class RobotLab::Durable::EntryTest < Minitest::Test
48
+ def build_entry(overrides = {})
49
+ RobotLab::Durable::Entry.new(
50
+ content: overrides.fetch(:content, "Skip LangChain content"),
51
+ reasoning: overrides.fetch(:reasoning, "User is Ruby-only"),
52
+ category: overrides.fetch(:category, :preference),
53
+ domain: overrides.fetch(:domain, "newsletter curation"),
54
+ confidence: overrides.fetch(:confidence, 0.1),
55
+ use_count: overrides.fetch(:use_count, 0),
56
+ created_at: overrides.fetch(:created_at, "2026-05-06T12:00:00Z"),
57
+ updated_at: overrides.fetch(:updated_at, "2026-05-06T12:00:00Z")
58
+ )
59
+ end
60
+
61
+ def test_entry_is_immutable
62
+ entry = build_entry
63
+ assert_raises(NoMethodError) { entry.content = "changed" }
64
+ end
65
+
66
+ def test_confirm_increments_confidence_by_0_1
67
+ entry = build_entry(confidence: 0.2, use_count: 1)
68
+ confirmed = entry.confirm
69
+ assert_in_delta 0.3, confirmed.confidence, 0.001
70
+ end
71
+
72
+ def test_confirm_increments_use_count
73
+ entry = build_entry(use_count: 3)
74
+ confirmed = entry.confirm
75
+ assert_equal 4, confirmed.use_count
76
+ end
77
+
78
+ def test_confirm_does_not_exceed_max_confidence
79
+ entry = build_entry(confidence: 0.95)
80
+ confirmed = entry.confirm
81
+ assert_in_delta 1.0, confirmed.confidence, 0.001
82
+ confirmed2 = confirmed.confirm
83
+ assert_in_delta 1.0, confirmed2.confidence, 0.001
84
+ end
85
+
86
+ def test_confirm_returns_new_entry_leaves_original_unchanged
87
+ entry = build_entry(confidence: 0.1)
88
+ entry.confirm
89
+ assert_in_delta 0.1, entry.confidence, 0.001
90
+ end
91
+
92
+ def test_to_h_returns_string_keys
93
+ entry = build_entry
94
+ h = entry.to_h
95
+ assert_equal "Skip LangChain content", h["content"]
96
+ assert_equal "preference", h["category"]
97
+ assert_in_delta 0.1, h["confidence"], 0.001
98
+ end
99
+
100
+ def test_from_h_with_string_keys
101
+ h = {
102
+ "content" => "Skip LangChain content",
103
+ "reasoning" => "User is Ruby-only",
104
+ "category" => "preference",
105
+ "domain" => "newsletter curation",
106
+ "confidence" => 0.2,
107
+ "use_count" => 1,
108
+ "created_at" => "2026-05-06T12:00:00Z",
109
+ "updated_at" => "2026-05-06T12:00:00Z"
110
+ }
111
+ entry = RobotLab::Durable::Entry.from_h(h)
112
+ assert_equal "Skip LangChain content", entry.content
113
+ assert_equal :preference, entry.category
114
+ assert_in_delta 0.2, entry.confidence, 0.001
115
+ end
116
+
117
+ def test_from_h_roundtrips_through_to_h
118
+ original = build_entry(confidence: 0.4, use_count: 2)
119
+ roundtripped = RobotLab::Durable::Entry.from_h(original.to_h)
120
+ assert_equal original.content, roundtripped.content
121
+ assert_equal original.reasoning, roundtripped.reasoning
122
+ assert_equal original.category, roundtripped.category
123
+ assert_in_delta original.confidence, roundtripped.confidence, 0.001
124
+ assert_equal original.use_count, roundtripped.use_count
125
+ end
126
+ end
127
+ ```
128
+
129
+ - [ ] **Step 2: Run the test to verify it fails**
130
+
131
+ ```bash
132
+ bundle exec rake test_file[robot_lab/durable/entry_test.rb]
133
+ ```
134
+
135
+ Expected: `NameError: uninitialized constant RobotLab::Durable` or similar failure.
136
+
137
+ - [ ] **Step 3: Create `lib/robot_lab/durable/entry.rb`**
138
+
139
+ ```ruby
140
+ # lib/robot_lab/durable/entry.rb
141
+ # frozen_string_literal: true
142
+
143
+ module RobotLab
144
+ module Durable
145
+ Entry = Data.define(:content, :reasoning, :category, :domain, :confidence, :use_count, :created_at, :updated_at) do
146
+ CONFIDENCE_INCREMENT = 0.1
147
+ MAX_CONFIDENCE = 1.0
148
+
149
+ # Return a new Entry with confidence incremented and use_count bumped.
150
+ def confirm
151
+ new_confidence = [confidence + CONFIDENCE_INCREMENT, MAX_CONFIDENCE].min
152
+ with(
153
+ confidence: new_confidence.round(10),
154
+ use_count: use_count + 1,
155
+ updated_at: Time.now.iso8601
156
+ )
157
+ end
158
+
159
+ # Serialize to a plain Hash with string keys (safe for YAML round-trip).
160
+ def to_h
161
+ {
162
+ "content" => content,
163
+ "reasoning" => reasoning,
164
+ "category" => category.to_s,
165
+ "domain" => domain,
166
+ "confidence" => confidence,
167
+ "use_count" => use_count,
168
+ "created_at" => created_at,
169
+ "updated_at" => updated_at
170
+ }
171
+ end
172
+
173
+ # Deserialize from a Hash (string or symbol keys).
174
+ def self.from_h(hash)
175
+ new(
176
+ content: hash["content"] || hash[:content],
177
+ reasoning: hash["reasoning"] || hash[:reasoning],
178
+ category: (hash["category"] || hash[:category]).to_sym,
179
+ domain: hash["domain"] || hash[:domain],
180
+ confidence: (hash["confidence"] || hash[:confidence]).to_f,
181
+ use_count: (hash["use_count"] || hash[:use_count]).to_i,
182
+ created_at: hash["created_at"] || hash[:created_at],
183
+ updated_at: hash["updated_at"] || hash[:updated_at]
184
+ )
185
+ end
186
+ end
187
+ end
188
+ end
189
+ ```
190
+
191
+ - [ ] **Step 4: Run tests to verify they pass**
192
+
193
+ ```bash
194
+ bundle exec rake test_file[robot_lab/durable/entry_test.rb]
195
+ ```
196
+
197
+ Expected: all green.
198
+
199
+ - [ ] **Step 5: Commit**
200
+
201
+ ```bash
202
+ git add lib/robot_lab/durable/entry.rb test/robot_lab/durable/entry_test.rb
203
+ git commit -m "feat(durable): add Durable::Entry value object"
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Task 2: `Durable::Store`
209
+
210
+ **Files:**
211
+ - Create: `lib/robot_lab/durable/store.rb`
212
+ - Create: `test/robot_lab/durable/store_test.rb`
213
+
214
+ - [ ] **Step 1: Create the test file**
215
+
216
+ ```ruby
217
+ # test/robot_lab/durable/store_test.rb
218
+ # frozen_string_literal: true
219
+
220
+ require "test_helper"
221
+ require "tmpdir"
222
+
223
+ class RobotLab::Durable::StoreTest < Minitest::Test
224
+ def setup
225
+ @tmpdir = Dir.mktmpdir("robot_lab_durable_test")
226
+ @store = RobotLab::Durable::Store.new(path: @tmpdir)
227
+ end
228
+
229
+ def teardown
230
+ FileUtils.remove_entry(@tmpdir)
231
+ end
232
+
233
+ def build_entry(overrides = {})
234
+ RobotLab::Durable::Entry.new(
235
+ content: overrides.fetch(:content, "Skip LangChain content"),
236
+ reasoning: overrides.fetch(:reasoning, "User is Ruby-only"),
237
+ category: overrides.fetch(:category, :preference),
238
+ domain: overrides.fetch(:domain, "newsletter curation"),
239
+ confidence: overrides.fetch(:confidence, 0.1),
240
+ use_count: overrides.fetch(:use_count, 0),
241
+ created_at: "2026-05-06T12:00:00Z",
242
+ updated_at: "2026-05-06T12:00:00Z"
243
+ )
244
+ end
245
+
246
+ # ── record ────────────────────────────────────────────────
247
+
248
+ def test_record_persists_entry_to_disk
249
+ entry = build_entry
250
+ @store.record(entry)
251
+
252
+ file = File.join(@tmpdir, "newsletter_curation.yaml")
253
+ assert File.exist?(file)
254
+ end
255
+
256
+ def test_record_appends_new_entry
257
+ @store.record(build_entry(content: "First"))
258
+ @store.record(build_entry(content: "Second"))
259
+
260
+ entries = @store.recall(query: "newsletter", domain: "newsletter curation")
261
+ assert_equal 2, entries.size
262
+ end
263
+
264
+ def test_record_updates_existing_entry_by_content_match
265
+ @store.record(build_entry(content: "Skip LangChain content", confidence: 0.1))
266
+ @store.record(build_entry(content: "Skip LangChain content", confidence: 0.1))
267
+
268
+ entries = @store.recall(query: "LangChain", domain: "newsletter curation")
269
+ assert_equal 1, entries.size
270
+ end
271
+
272
+ def test_record_increments_confidence_on_duplicate
273
+ @store.record(build_entry(content: "Skip LangChain content", confidence: 0.1))
274
+ @store.record(build_entry(content: "Skip LangChain content", confidence: 0.1))
275
+
276
+ entries = @store.recall(query: "LangChain", domain: "newsletter curation")
277
+ assert_in_delta 0.2, entries.first.confidence, 0.001
278
+ end
279
+
280
+ # ── recall ────────────────────────────────────────────────
281
+
282
+ def test_recall_returns_empty_array_when_no_entries
283
+ results = @store.recall(query: "anything", domain: "newsletter curation")
284
+ assert_empty results
285
+ end
286
+
287
+ def test_recall_matches_on_content_keyword
288
+ @store.record(build_entry(content: "Skip LangChain tutorials"))
289
+ results = @store.recall(query: "LangChain", domain: "newsletter curation")
290
+ assert_equal 1, results.size
291
+ assert_equal "Skip LangChain tutorials", results.first.content
292
+ end
293
+
294
+ def test_recall_is_case_insensitive
295
+ @store.record(build_entry(content: "Skip langchain tutorials"))
296
+ results = @store.recall(query: "LangChain", domain: "newsletter curation")
297
+ assert_equal 1, results.size
298
+ end
299
+
300
+ def test_recall_filters_by_min_confidence
301
+ @store.record(build_entry(content: "Low confidence entry", confidence: 0.1))
302
+ @store.record(build_entry(content: "High confidence entry", confidence: 0.8))
303
+
304
+ results = @store.recall(query: "confidence entry", domain: "newsletter curation", min_confidence: 0.5)
305
+ assert_equal 1, results.size
306
+ assert_equal "High confidence entry", results.first.content
307
+ end
308
+
309
+ def test_recall_sorts_by_descending_confidence
310
+ @store.record(build_entry(content: "Low entry", confidence: 0.2))
311
+ @store.record(build_entry(content: "High entry", confidence: 0.8))
312
+
313
+ results = @store.recall(query: "entry", domain: "newsletter curation")
314
+ assert_equal "High entry", results.first.content
315
+ end
316
+
317
+ def test_recall_without_domain_searches_all_domains
318
+ @store.record(build_entry(domain: "newsletter curation", content: "Newsletter fact"))
319
+ @store.record(build_entry(domain: "ruby tooling", content: "Tooling fact"))
320
+
321
+ results = @store.recall(query: "fact")
322
+ assert_equal 2, results.size
323
+ end
324
+
325
+ # ── confirm ───────────────────────────────────────────────
326
+
327
+ def test_confirm_increments_confidence_on_disk
328
+ entry = @store.record(build_entry(confidence: 0.2))
329
+ @store.confirm(entry)
330
+
331
+ results = @store.recall(query: "LangChain", domain: "newsletter curation")
332
+ assert_in_delta 0.3, results.first.confidence, 0.001
333
+ end
334
+
335
+ # ── domain file naming ────────────────────────────────────
336
+
337
+ def test_spaces_in_domain_become_underscores_in_filename
338
+ @store.record(build_entry(domain: "newsletter curation"))
339
+ assert File.exist?(File.join(@tmpdir, "newsletter_curation.yaml"))
340
+ end
341
+ end
342
+ ```
343
+
344
+ - [ ] **Step 2: Run the test to verify it fails**
345
+
346
+ ```bash
347
+ bundle exec rake test_file[robot_lab/durable/store_test.rb]
348
+ ```
349
+
350
+ Expected: `NameError: uninitialized constant RobotLab::Durable::Store`.
351
+
352
+ - [ ] **Step 3: Create `lib/robot_lab/durable/store.rb`**
353
+
354
+ ```ruby
355
+ # lib/robot_lab/durable/store.rb
356
+ # frozen_string_literal: true
357
+
358
+ require "yaml"
359
+ require "fileutils"
360
+
361
+ module RobotLab
362
+ module Durable
363
+ class Store
364
+ DEFAULT_PATH = File.join(Dir.home, ".robot_lab", "durable")
365
+
366
+ def initialize(path: DEFAULT_PATH)
367
+ @path = path
368
+ FileUtils.mkdir_p(@path)
369
+ end
370
+
371
+ # Return entries matching query keywords, sorted by descending confidence.
372
+ #
373
+ # @param query [String] natural-language search string
374
+ # @param domain [String, nil] restrict to one domain file; nil searches all
375
+ # @param min_confidence [Float] exclude entries below this threshold
376
+ # @return [Array<Entry>]
377
+ def recall(query:, domain: nil, min_confidence: 0.0)
378
+ entries = domain ? load_domain(domain) : load_all
379
+ words = tokenize(query)
380
+
381
+ entries
382
+ .select { |e| e.confidence >= min_confidence }
383
+ .select { |e| matches?(e, words) }
384
+ .sort_by { |e| -e.confidence }
385
+ end
386
+
387
+ # Persist a new entry. If an entry with the same content already exists
388
+ # in the domain file, increment its confidence and use_count instead.
389
+ #
390
+ # @param entry [Entry]
391
+ # @return [Entry] the stored entry (may differ if an existing one was updated)
392
+ def record(entry)
393
+ entries = load_domain(entry.domain)
394
+ idx = entries.find_index { |e| e.content.downcase == entry.content.downcase }
395
+
396
+ if idx
397
+ entries[idx] = entries[idx].confirm
398
+ else
399
+ entries << entry
400
+ end
401
+
402
+ save_domain(entry.domain, entries)
403
+ entries[idx || -1]
404
+ end
405
+
406
+ # Increment confidence and use_count on a stored entry.
407
+ #
408
+ # @param entry [Entry]
409
+ # @return [Entry] the updated entry
410
+ def confirm(entry)
411
+ updated = entry.confirm
412
+ record_exact(updated)
413
+ updated
414
+ end
415
+
416
+ private
417
+
418
+ def matches?(entry, words)
419
+ text = "#{entry.content} #{entry.domain}".downcase
420
+ words.any? { |w| text.include?(w) }
421
+ end
422
+
423
+ def tokenize(str)
424
+ str.downcase.split(/\s+/).reject { |w| w.length < 3 }
425
+ end
426
+
427
+ def load_domain(domain)
428
+ file = domain_file(domain)
429
+ return [] unless File.exist?(file)
430
+
431
+ raw = YAML.safe_load(File.read(file)) || []
432
+ raw.map { |h| Entry.from_h(h) }
433
+ end
434
+
435
+ def load_all
436
+ Dir.glob(File.join(@path, "*.yaml")).flat_map do |file|
437
+ raw = YAML.safe_load(File.read(file)) || []
438
+ raw.map { |h| Entry.from_h(h) }
439
+ end
440
+ end
441
+
442
+ def save_domain(domain, entries)
443
+ File.write(domain_file(domain), YAML.dump(entries.map(&:to_h)))
444
+ end
445
+
446
+ # Replace a specific entry by exact content match (used by confirm).
447
+ def record_exact(entry)
448
+ entries = load_domain(entry.domain)
449
+ idx = entries.find_index { |e| e.content.downcase == entry.content.downcase }
450
+ entries[idx] = entry if idx
451
+ save_domain(entry.domain, entries)
452
+ end
453
+
454
+ def domain_file(domain)
455
+ filename = domain.to_s.downcase.gsub(/\s+/, "_") + ".yaml"
456
+ File.join(@path, filename)
457
+ end
458
+ end
459
+ end
460
+ end
461
+ ```
462
+
463
+ - [ ] **Step 4: Run tests to verify they pass**
464
+
465
+ ```bash
466
+ bundle exec rake test_file[robot_lab/durable/store_test.rb]
467
+ ```
468
+
469
+ Expected: all green.
470
+
471
+ - [ ] **Step 5: Commit**
472
+
473
+ ```bash
474
+ git add lib/robot_lab/durable/store.rb test/robot_lab/durable/store_test.rb
475
+ git commit -m "feat(durable): add Durable::Store for YAML-backed knowledge persistence"
476
+ ```
477
+
478
+ ---
479
+
480
+ ## Task 3: `RecordKnowledge` Tool
481
+
482
+ **Files:**
483
+ - Create: `lib/robot_lab/record_knowledge.rb`
484
+ - Create: `test/robot_lab/record_knowledge_test.rb`
485
+
486
+ - [ ] **Step 1: Create the test file**
487
+
488
+ ```ruby
489
+ # test/robot_lab/record_knowledge_test.rb
490
+ # frozen_string_literal: true
491
+
492
+ require "test_helper"
493
+ require "tmpdir"
494
+
495
+ class RobotLab::RecordKnowledgeTest < Minitest::Test
496
+ def setup
497
+ @tmpdir = Dir.mktmpdir("robot_lab_record_test")
498
+ @store = RobotLab::Durable::Store.new(path: @tmpdir)
499
+ @robot = build_robot(name: "test_bot")
500
+ @robot.instance_variable_set(:@durable_store, @store)
501
+ @tool = RobotLab::RecordKnowledge.new(robot: @robot)
502
+ end
503
+
504
+ def teardown
505
+ FileUtils.remove_entry(@tmpdir)
506
+ end
507
+
508
+ def test_records_entry_to_store
509
+ @tool.execute(
510
+ content: "Skip Python-only tools",
511
+ reasoning: "User works exclusively in Ruby",
512
+ category: "preference",
513
+ domain: "newsletter curation"
514
+ )
515
+
516
+ results = @store.recall(query: "Python", domain: "newsletter curation")
517
+ assert_equal 1, results.size
518
+ assert_equal "Skip Python-only tools", results.first.content
519
+ end
520
+
521
+ def test_returns_confirmation_string
522
+ result = @tool.execute(
523
+ content: "Prefer gems with low dependency count",
524
+ reasoning: "User values minimal dependency footprint",
525
+ category: "preference",
526
+ domain: "newsletter curation"
527
+ )
528
+
529
+ assert_match(/Recorded/, result)
530
+ assert_match(/dependency/, result)
531
+ end
532
+
533
+ def test_adds_learning_to_robot
534
+ @tool.execute(
535
+ content: "Include RubyLLM news",
536
+ reasoning: "User maintains RubyLLM integrations",
537
+ category: "preference",
538
+ domain: "newsletter curation"
539
+ )
540
+
541
+ assert @robot.learnings.any? { |l| l.include?("RubyLLM") }
542
+ end
543
+
544
+ def test_returns_error_when_no_store_configured
545
+ @robot.instance_variable_set(:@durable_store, nil)
546
+ result = @tool.execute(
547
+ content: "anything", reasoning: "any", category: "fact", domain: "test"
548
+ )
549
+ assert_match(/No durable store/, result)
550
+ end
551
+ end
552
+ ```
553
+
554
+ - [ ] **Step 2: Run the test to verify it fails**
555
+
556
+ ```bash
557
+ bundle exec rake test_file[robot_lab/record_knowledge_test.rb]
558
+ ```
559
+
560
+ Expected: `NameError: uninitialized constant RobotLab::RecordKnowledge`.
561
+
562
+ - [ ] **Step 3: Create `lib/robot_lab/record_knowledge.rb`**
563
+
564
+ ```ruby
565
+ # lib/robot_lab/record_knowledge.rb
566
+ # frozen_string_literal: true
567
+
568
+ module RobotLab
569
+ class RecordKnowledge < Tool
570
+ description "Record a piece of knowledge learned during this session. " \
571
+ "Use after a decision or discussion reveals something worth remembering: " \
572
+ "a user preference, a reliable pattern, or a factual insight. " \
573
+ "Recorded knowledge persists across future sessions."
574
+
575
+ param :content, type: "string", desc: "The knowledge to record, in plain language (one clear statement)"
576
+ param :reasoning, type: "string", desc: "Why this is worth remembering — the observation or discussion that led to it"
577
+ param :category, type: "string", desc: "One of: fact, preference, pattern, correction"
578
+ param :domain, type: "string", desc: "Topic area this applies to (e.g. 'newsletter curation', 'ruby tooling')"
579
+
580
+ def execute(content:, reasoning:, category:, domain:)
581
+ store = robot&.durable_store
582
+ return "No durable store configured on this robot." unless store
583
+
584
+ entry = Durable::Entry.new(
585
+ content:,
586
+ reasoning:,
587
+ category: category.to_sym,
588
+ domain:,
589
+ confidence: 0.1,
590
+ use_count: 0,
591
+ created_at: Time.now.iso8601,
592
+ updated_at: Time.now.iso8601
593
+ )
594
+
595
+ store.record(entry)
596
+ robot.learn("#{content} (#{domain})")
597
+
598
+ "Recorded: #{content}"
599
+ end
600
+ end
601
+ end
602
+ ```
603
+
604
+ - [ ] **Step 4: Run tests to verify they pass**
605
+
606
+ ```bash
607
+ bundle exec rake test_file[robot_lab/record_knowledge_test.rb]
608
+ ```
609
+
610
+ Expected: all green.
611
+
612
+ - [ ] **Step 5: Commit**
613
+
614
+ ```bash
615
+ git add lib/robot_lab/record_knowledge.rb test/robot_lab/record_knowledge_test.rb
616
+ git commit -m "feat(durable): add RecordKnowledge tool"
617
+ ```
618
+
619
+ ---
620
+
621
+ ## Task 4: `RecallKnowledge` Tool
622
+
623
+ **Files:**
624
+ - Create: `lib/robot_lab/recall_knowledge.rb`
625
+ - Create: `test/robot_lab/recall_knowledge_test.rb`
626
+
627
+ - [ ] **Step 1: Create the test file**
628
+
629
+ ```ruby
630
+ # test/robot_lab/recall_knowledge_test.rb
631
+ # frozen_string_literal: true
632
+
633
+ require "test_helper"
634
+ require "tmpdir"
635
+
636
+ class RobotLab::RecallKnowledgeTest < Minitest::Test
637
+ def setup
638
+ @tmpdir = Dir.mktmpdir("robot_lab_recall_test")
639
+ @store = RobotLab::Durable::Store.new(path: @tmpdir)
640
+ @robot = build_robot(name: "test_bot")
641
+ @robot.instance_variable_set(:@durable_store, @store)
642
+ @tool = RobotLab::RecallKnowledge.new(robot: @robot)
643
+ end
644
+
645
+ def teardown
646
+ FileUtils.remove_entry(@tmpdir)
647
+ end
648
+
649
+ def seed_entry(content:, confidence: 0.5, domain: "newsletter curation")
650
+ @store.record(
651
+ RobotLab::Durable::Entry.new(
652
+ content:,
653
+ reasoning: "seeded in test",
654
+ category: :preference,
655
+ domain:,
656
+ confidence:,
657
+ use_count: 0,
658
+ created_at: "2026-05-06T12:00:00Z",
659
+ updated_at: "2026-05-06T12:00:00Z"
660
+ )
661
+ )
662
+ end
663
+
664
+ def test_returns_matching_entries_as_formatted_string
665
+ seed_entry(content: "Skip LangChain tutorials")
666
+
667
+ result = @tool.execute(query: "LangChain", domain: "newsletter curation")
668
+
669
+ assert_match(/Skip LangChain tutorials/, result)
670
+ assert_match(/Relevant past knowledge/, result)
671
+ end
672
+
673
+ def test_returns_no_match_message_when_empty
674
+ result = @tool.execute(query: "LangChain", domain: "newsletter curation")
675
+ assert_match(/No relevant past knowledge/, result)
676
+ end
677
+
678
+ def test_increments_confidence_on_recall
679
+ seed_entry(content: "Skip LangChain tutorials", confidence: 0.3)
680
+ @tool.execute(query: "LangChain", domain: "newsletter curation")
681
+
682
+ results = @store.recall(query: "LangChain", domain: "newsletter curation")
683
+ assert_in_delta 0.4, results.first.confidence, 0.001
684
+ end
685
+
686
+ def test_returns_error_when_no_store_configured
687
+ @robot.instance_variable_set(:@durable_store, nil)
688
+ result = @tool.execute(query: "anything")
689
+ assert_match(/No durable store/, result)
690
+ end
691
+
692
+ def test_includes_category_and_confidence_in_output
693
+ seed_entry(content: "Include RubyLLM updates", confidence: 0.6)
694
+ result = @tool.execute(query: "RubyLLM", domain: "newsletter curation")
695
+ assert_match(/preference/, result)
696
+ assert_match(/0\./, result)
697
+ end
698
+ end
699
+ ```
700
+
701
+ - [ ] **Step 2: Run the test to verify it fails**
702
+
703
+ ```bash
704
+ bundle exec rake test_file[robot_lab/recall_knowledge_test.rb]
705
+ ```
706
+
707
+ Expected: `NameError: uninitialized constant RobotLab::RecallKnowledge`.
708
+
709
+ - [ ] **Step 3: Create `lib/robot_lab/recall_knowledge.rb`**
710
+
711
+ ```ruby
712
+ # lib/robot_lab/recall_knowledge.rb
713
+ # frozen_string_literal: true
714
+
715
+ module RobotLab
716
+ class RecallKnowledge < Tool
717
+ description "Recall relevant knowledge from past sessions before making a decision. " \
718
+ "Use this when uncertain whether to include or skip content, or when you want " \
719
+ "to check if you have seen a similar situation before. " \
720
+ "When in doubt and no relevant knowledge is found, skip the action."
721
+
722
+ param :query, type: "string", desc: "Natural language description of the decision you are about to make"
723
+ param :domain, type: "string", desc: "Topic area to search (e.g. 'newsletter curation')", required: false
724
+
725
+ def execute(query:, domain: nil)
726
+ store = robot&.durable_store
727
+ return "No durable store configured on this robot." unless store
728
+
729
+ entries = store.recall(query: query, domain: domain, min_confidence: 0.0)
730
+
731
+ if entries.empty?
732
+ "No relevant past knowledge found for: #{query}. When in doubt, skip."
733
+ else
734
+ entries.each { |e| store.confirm(e) }
735
+
736
+ lines = entries.map do |e|
737
+ "[#{e.category}/conf:#{format("%.1f", e.confidence)}] #{e.content} — #{e.reasoning}"
738
+ end
739
+
740
+ "Relevant past knowledge:\n#{lines.join("\n")}"
741
+ end
742
+ end
743
+ end
744
+ end
745
+ ```
746
+
747
+ - [ ] **Step 4: Run tests to verify they pass**
748
+
749
+ ```bash
750
+ bundle exec rake test_file[robot_lab/recall_knowledge_test.rb]
751
+ ```
752
+
753
+ Expected: all green.
754
+
755
+ - [ ] **Step 5: Commit**
756
+
757
+ ```bash
758
+ git add lib/robot_lab/recall_knowledge.rb test/robot_lab/recall_knowledge_test.rb
759
+ git commit -m "feat(durable): add RecallKnowledge tool"
760
+ ```
761
+
762
+ ---
763
+
764
+ ## Task 5: `Durable::Reflector`
765
+
766
+ **Files:**
767
+ - Create: `lib/robot_lab/durable/reflector.rb`
768
+ - Create: `test/robot_lab/durable/reflector_test.rb`
769
+
770
+ - [ ] **Step 1: Create the test file**
771
+
772
+ ```ruby
773
+ # test/robot_lab/durable/reflector_test.rb
774
+ # frozen_string_literal: true
775
+
776
+ require "test_helper"
777
+ require "tmpdir"
778
+
779
+ class RobotLab::Durable::ReflectorTest < Minitest::Test
780
+ def setup
781
+ @tmpdir = Dir.mktmpdir("robot_lab_reflector_test")
782
+ @store = RobotLab::Durable::Store.new(path: @tmpdir)
783
+ @reflector = RobotLab::Durable::Reflector.new(store: @store, domain: "newsletter curation")
784
+ end
785
+
786
+ def teardown
787
+ FileUtils.remove_entry(@tmpdir)
788
+ end
789
+
790
+ def test_promotes_new_learning_to_store
791
+ @reflector.reflect(["User prefers practical tooling examples"])
792
+
793
+ results = @store.recall(query: "practical tooling", domain: "newsletter curation")
794
+ assert_equal 1, results.size
795
+ assert_equal "User prefers practical tooling examples", results.first.content
796
+ end
797
+
798
+ def test_does_not_duplicate_existing_entry
799
+ @store.record(
800
+ RobotLab::Durable::Entry.new(
801
+ content: "User prefers practical tooling examples",
802
+ reasoning: "already stored",
803
+ category: :pattern,
804
+ domain: "newsletter curation",
805
+ confidence: 0.3,
806
+ use_count: 2,
807
+ created_at: "2026-05-06T12:00:00Z",
808
+ updated_at: "2026-05-06T12:00:00Z"
809
+ )
810
+ )
811
+
812
+ @reflector.reflect(["User prefers practical tooling examples"])
813
+
814
+ results = @store.recall(query: "practical tooling", domain: "newsletter curation")
815
+ assert_equal 1, results.size
816
+ end
817
+
818
+ def test_promotes_multiple_learnings
819
+ @reflector.reflect(["First insight", "Second insight"])
820
+
821
+ r1 = @store.recall(query: "First insight", domain: "newsletter curation")
822
+ r2 = @store.recall(query: "Second insight", domain: "newsletter curation")
823
+ assert_equal 1, r1.size
824
+ assert_equal 1, r2.size
825
+ end
826
+
827
+ def test_skips_empty_learnings
828
+ @reflector.reflect(["", " ", nil].compact)
829
+
830
+ results = @store.recall(query: "anything", domain: "newsletter curation")
831
+ assert_empty results
832
+ end
833
+
834
+ def test_new_entries_start_with_low_confidence
835
+ @reflector.reflect(["Something worth knowing"])
836
+
837
+ results = @store.recall(query: "worth knowing", domain: "newsletter curation")
838
+ assert_in_delta 0.1, results.first.confidence, 0.001
839
+ end
840
+ end
841
+ ```
842
+
843
+ - [ ] **Step 2: Run the test to verify it fails**
844
+
845
+ ```bash
846
+ bundle exec rake test_file[robot_lab/durable/reflector_test.rb]
847
+ ```
848
+
849
+ Expected: `NameError: uninitialized constant RobotLab::Durable::Reflector`.
850
+
851
+ - [ ] **Step 3: Create `lib/robot_lab/durable/reflector.rb`**
852
+
853
+ ```ruby
854
+ # lib/robot_lab/durable/reflector.rb
855
+ # frozen_string_literal: true
856
+
857
+ module RobotLab
858
+ module Durable
859
+ class Reflector
860
+ def initialize(store:, domain:)
861
+ @store = store
862
+ @domain = domain.to_s
863
+ end
864
+
865
+ # Examine plain-text learnings accumulated during a session and promote
866
+ # any that are not already represented in the store.
867
+ #
868
+ # @param learnings [Array<String>] robot.learnings from the completed session
869
+ def reflect(learnings)
870
+ learnings.each do |text|
871
+ next if text.nil? || text.strip.empty?
872
+
873
+ text = text.strip
874
+ next if already_stored?(text)
875
+
876
+ @store.record(
877
+ Entry.new(
878
+ content: text,
879
+ reasoning: "Observed during session (auto-promoted by Reflector)",
880
+ category: :pattern,
881
+ domain: @domain,
882
+ confidence: 0.1,
883
+ use_count: 0,
884
+ created_at: Time.now.iso8601,
885
+ updated_at: Time.now.iso8601
886
+ )
887
+ )
888
+ end
889
+ end
890
+
891
+ private
892
+
893
+ def already_stored?(text)
894
+ words = text.downcase.split(/\s+/).reject { |w| w.length < 4 }
895
+ return false if words.empty?
896
+
897
+ @store.recall(query: text, domain: @domain, min_confidence: 0.0).any? do |e|
898
+ e.content.downcase == text.downcase
899
+ end
900
+ end
901
+ end
902
+ end
903
+ end
904
+ ```
905
+
906
+ - [ ] **Step 4: Run tests to verify they pass**
907
+
908
+ ```bash
909
+ bundle exec rake test_file[robot_lab/durable/reflector_test.rb]
910
+ ```
911
+
912
+ Expected: all green.
913
+
914
+ - [ ] **Step 5: Commit**
915
+
916
+ ```bash
917
+ git add lib/robot_lab/durable/reflector.rb test/robot_lab/durable/reflector_test.rb
918
+ git commit -m "feat(durable): add Durable::Reflector for end-of-session learning promotion"
919
+ ```
920
+
921
+ ---
922
+
923
+ ## Task 6: `Durable::Learning` Mixin
924
+
925
+ **Files:**
926
+ - Create: `lib/robot_lab/durable/learning.rb`
927
+
928
+ - [ ] **Step 1: Create `lib/robot_lab/durable/learning.rb`**
929
+
930
+ No separate test file — integration is tested via Robot in Task 7.
931
+
932
+ ```ruby
933
+ # lib/robot_lab/durable/learning.rb
934
+ # frozen_string_literal: true
935
+
936
+ module RobotLab
937
+ module Durable
938
+ module Learning
939
+ def self.included(base)
940
+ base.attr_reader :durable_store, :learn_domain
941
+ end
942
+
943
+ # Configure durable learning on a robot after initialization.
944
+ #
945
+ # @param domain [String] topic area for this robot's knowledge
946
+ # @param store_path [String, nil] override default ~/.robot_lab/durable path
947
+ def setup_durable_learning(domain:, store_path: nil)
948
+ @learn_domain = domain.to_s
949
+ opts = store_path ? { path: store_path } : {}
950
+ @durable_store = Store.new(**opts)
951
+
952
+ seed_from_store
953
+ @local_tools = (@local_tools + [RecallKnowledge, RecordKnowledge]).uniq
954
+ end
955
+
956
+ # Run the end-of-session reflection pass.
957
+ # Called automatically from Robot#run when durable learning is active.
958
+ def run_reflector
959
+ return unless @durable_store && @learn_domain && @learnings&.any?
960
+
961
+ Reflector.new(store: @durable_store, domain: @learn_domain).reflect(@learnings)
962
+ end
963
+
964
+ private
965
+
966
+ def seed_from_store
967
+ return unless @durable_store && @learn_domain
968
+
969
+ entries = @durable_store.recall(query: @learn_domain, domain: @learn_domain, min_confidence: 0.0)
970
+ entries.each do |e|
971
+ learn("[#{e.category}] #{e.content}: #{e.reasoning}")
972
+ end
973
+ end
974
+ end
975
+ end
976
+ end
977
+ ```
978
+
979
+ - [ ] **Step 2: Run the full test suite to verify nothing is broken**
980
+
981
+ ```bash
982
+ bundle exec rake test
983
+ ```
984
+
985
+ Expected: all existing tests still pass.
986
+
987
+ - [ ] **Step 3: Commit**
988
+
989
+ ```bash
990
+ git add lib/robot_lab/durable/learning.rb
991
+ git commit -m "feat(durable): add Durable::Learning mixin"
992
+ ```
993
+
994
+ ---
995
+
996
+ ## Task 7: Robot Integration
997
+
998
+ **Files:**
999
+ - Modify: `lib/robot_lab/robot.rb`
1000
+ - Create: `test/robot_lab/robot/durable_learning_test.rb`
1001
+
1002
+ - [ ] **Step 1: Create the integration test**
1003
+
1004
+ ```ruby
1005
+ # test/robot_lab/robot/durable_learning_test.rb
1006
+ # frozen_string_literal: true
1007
+
1008
+ require "test_helper"
1009
+ require "tmpdir"
1010
+
1011
+ class RobotLab::Robot::DurableLearningTest < Minitest::Test
1012
+ def setup
1013
+ @tmpdir = Dir.mktmpdir("robot_lab_robot_durable_test")
1014
+ end
1015
+
1016
+ def teardown
1017
+ FileUtils.remove_entry(@tmpdir)
1018
+ end
1019
+
1020
+ def test_learn_false_does_not_set_durable_store
1021
+ robot = RobotLab::Robot.new(name: "no_learn", template: :assistant)
1022
+ assert_nil robot.durable_store
1023
+ end
1024
+
1025
+ def test_learn_true_sets_durable_store
1026
+ robot = RobotLab::Robot.new(
1027
+ name: "learner",
1028
+ template: :assistant,
1029
+ learn: true,
1030
+ learn_domain: "test domain",
1031
+ store_path: @tmpdir
1032
+ )
1033
+ refute_nil robot.durable_store
1034
+ end
1035
+
1036
+ def test_learn_true_adds_recall_and_record_tools
1037
+ robot = RobotLab::Robot.new(
1038
+ name: "learner",
1039
+ template: :assistant,
1040
+ learn: true,
1041
+ learn_domain: "test domain",
1042
+ store_path: @tmpdir
1043
+ )
1044
+ tool_names = robot.local_tools.map { |t| t.is_a?(Class) ? t.name : t.class.name }
1045
+ assert tool_names.any? { |n| n.include?("RecallKnowledge") }
1046
+ assert tool_names.any? { |n| n.include?("RecordKnowledge") }
1047
+ end
1048
+
1049
+ def test_learn_true_seeds_learnings_from_existing_store
1050
+ store = RobotLab::Durable::Store.new(path: @tmpdir)
1051
+ store.record(
1052
+ RobotLab::Durable::Entry.new(
1053
+ content: "Skip Python-only tools",
1054
+ reasoning: "Ruby-only context",
1055
+ category: :preference,
1056
+ domain: "test domain",
1057
+ confidence: 0.5,
1058
+ use_count: 2,
1059
+ created_at: "2026-05-06T12:00:00Z",
1060
+ updated_at: "2026-05-06T12:00:00Z"
1061
+ )
1062
+ )
1063
+
1064
+ robot = RobotLab::Robot.new(
1065
+ name: "learner",
1066
+ template: :assistant,
1067
+ learn: true,
1068
+ learn_domain: "test domain",
1069
+ store_path: @tmpdir
1070
+ )
1071
+
1072
+ assert robot.learnings.any? { |l| l.include?("Skip Python-only tools") }
1073
+ end
1074
+
1075
+ def test_learn_domain_readable
1076
+ robot = RobotLab::Robot.new(
1077
+ name: "learner",
1078
+ template: :assistant,
1079
+ learn: true,
1080
+ learn_domain: "newsletter curation",
1081
+ store_path: @tmpdir
1082
+ )
1083
+ assert_equal "newsletter curation", robot.learn_domain
1084
+ end
1085
+ end
1086
+ ```
1087
+
1088
+ - [ ] **Step 2: Run the test to verify it fails**
1089
+
1090
+ ```bash
1091
+ bundle exec rake test_file[robot_lab/robot/durable_learning_test.rb]
1092
+ ```
1093
+
1094
+ Expected: failures — `learn:` param not yet accepted.
1095
+
1096
+ - [ ] **Step 3: Modify `lib/robot_lab/robot.rb` — include the mixin**
1097
+
1098
+ Add after the existing `include Robot::HistorySearch` line (around line 8):
1099
+
1100
+ ```ruby
1101
+ include Durable::Learning
1102
+ ```
1103
+
1104
+ - [ ] **Step 4: Modify `lib/robot_lab/robot.rb` — add constructor params**
1105
+
1106
+ In the `initialize` parameter list (after `config: nil`), add:
1107
+
1108
+ ```ruby
1109
+ learn: false,
1110
+ learn_domain: nil,
1111
+ store_path: nil,
1112
+ ```
1113
+
1114
+ - [ ] **Step 5: Modify `lib/robot_lab/robot.rb` — call setup in initialize body**
1115
+
1116
+ After the line `@learnings = Array(persisted) if persisted` (around line 202), add:
1117
+
1118
+ ```ruby
1119
+ if learn && learn_domain
1120
+ setup_durable_learning(domain: learn_domain, store_path: store_path)
1121
+ end
1122
+ ```
1123
+
1124
+ - [ ] **Step 6: Modify `lib/robot_lab/robot.rb` — call reflector in run**
1125
+
1126
+ In the `run` method's `ensure` block (near the end), after `restore_tool_call_callback` add:
1127
+
1128
+ ```ruby
1129
+ run_reflector if @durable_store
1130
+ ```
1131
+
1132
+ - [ ] **Step 7: Run the integration tests**
1133
+
1134
+ ```bash
1135
+ bundle exec rake test_file[robot_lab/robot/durable_learning_test.rb]
1136
+ ```
1137
+
1138
+ Expected: all green.
1139
+
1140
+ - [ ] **Step 8: Run the full suite**
1141
+
1142
+ ```bash
1143
+ bundle exec rake test
1144
+ ```
1145
+
1146
+ Expected: all existing tests still pass.
1147
+
1148
+ - [ ] **Step 9: Commit**
1149
+
1150
+ ```bash
1151
+ git add lib/robot_lab/robot.rb test/robot_lab/robot/durable_learning_test.rb
1152
+ git commit -m "feat(durable): integrate Durable::Learning into Robot via learn: param"
1153
+ ```
1154
+
1155
+ ---
1156
+
1157
+ ## Task 8: Update Newsletter Reader Example
1158
+
1159
+ **Files:**
1160
+ - Modify: `examples/32_newsletter_reader.rb`
1161
+
1162
+ - [ ] **Step 1: Update the robot constructor in the example**
1163
+
1164
+ Replace:
1165
+
1166
+ ```ruby
1167
+ robot = RobotLab.build(
1168
+ name: "newsletter_analyst",
1169
+ system_prompt: <<~PROMPT,
1170
+ ...
1171
+ PROMPT
1172
+ local_tools: [FetchLatestNewsletter],
1173
+ model: "claude-haiku-4-5-20251001"
1174
+ )
1175
+ ```
1176
+
1177
+ With:
1178
+
1179
+ ```ruby
1180
+ robot = RobotLab.build(
1181
+ name: "newsletter_analyst",
1182
+ system_prompt: <<~PROMPT,
1183
+ You are a sharp technical editor summarizing the RoboRuby Ruby AI newsletter
1184
+ for busy developers. When given newsletter content, extract and present:
1185
+
1186
+ 1. **Headline story** — the biggest news in Ruby/AI this issue.
1187
+ 2. **Notable gems or tools** — new or updated libraries worth knowing about.
1188
+ 3. **Key articles or tutorials** — important reads linked in the issue.
1189
+ 4. **Quick takes** — 3-5 bullets on other interesting items.
1190
+
1191
+ The content includes Markdown links in [text](url) format. You MUST preserve
1192
+ these links in your output — every article title, gem name, and tool mentioned
1193
+ should be a clickable Markdown link using the URL from the source content.
1194
+
1195
+ Before deciding what to include, use RecallKnowledge to check past preferences.
1196
+ When you notice something new about what content resonates, use RecordKnowledge.
1197
+ When uncertain whether to include something and no past knowledge applies, skip it.
1198
+
1199
+ Use the FetchLatestNewsletter tool to get the content, then give your summary.
1200
+ Be concise and opinionated — developers are busy.
1201
+ PROMPT
1202
+ local_tools: [FetchLatestNewsletter],
1203
+ model: "claude-haiku-4-5-20251001",
1204
+ learn: true,
1205
+ learn_domain: "newsletter curation"
1206
+ )
1207
+ ```
1208
+
1209
+ - [ ] **Step 2: Verify the example loads without error**
1210
+
1211
+ ```bash
1212
+ ruby -c examples/32_newsletter_reader.rb
1213
+ ```
1214
+
1215
+ Expected: `Syntax OK`.
1216
+
1217
+ - [ ] **Step 3: Commit**
1218
+
1219
+ ```bash
1220
+ git add examples/32_newsletter_reader.rb
1221
+ git commit -m "feat(examples): add durable learning to newsletter reader robot"
1222
+ ```
1223
+
1224
+ ---
1225
+
1226
+ ## Self-Review
1227
+
1228
+ **Spec coverage check:**
1229
+
1230
+ | Spec requirement | Task |
1231
+ |-----------------|------|
1232
+ | `Durable::Entry` — content, reasoning, category, domain, confidence, use_count | Task 1 |
1233
+ | `Durable::Store` — recall, record, confirm, YAML files per domain | Task 2 |
1234
+ | `RecordKnowledge` tool — write to store + update learnings | Task 3 |
1235
+ | `RecallKnowledge` tool — read from store, confirm on recall | Task 4 |
1236
+ | `Durable::Reflector` — promote session learnings at session end | Task 5 |
1237
+ | `Durable::Learning` mixin — wire tools + seed + reflector hook | Task 6 |
1238
+ | Robot `learn:`, `learn_domain:` params, `durable_store` reader | Task 7 |
1239
+ | Conservative bias: when no match + uncertainty, skip | Baked into RecallKnowledge output string and system prompt guidance |
1240
+ | Newsletter reader updated with `learn: true` | Task 8 |
1241
+ | Confidence starts at 0.1, increments 0.1 per confirmation | Task 1 (`Entry#confirm`) |
1242
+ | Cross-session: `~/.robot_lab/durable/` default path | Task 2 (`Store::DEFAULT_PATH`) |
1243
+ | Within-session: existing `@learnings` / `learn()` mechanism reused | Task 6 (`seed_from_store`, `run_reflector`) |
1244
+
1245
+ **Placeholder scan:** None found.
1246
+
1247
+ **Type consistency:** `Entry.from_h` / `Entry#to_h` string keys used consistently in `Store`. `store.confirm` → `entry.confirm` chain consistent throughout. `robot.durable_store` reader used in both tools.