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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +83 -0
- data/Rakefile +8 -0
- data/docs/index.md +62 -0
- data/docs/superpowers/plans/2026-05-06-durable-learning.md +1247 -0
- data/docs/superpowers/specs/2026-05-06-durable-learning-design.md +182 -0
- data/examples/33_stock_generator.rb +80 -0
- data/examples/33_stock_predictor.rb +304 -0
- data/lib/robot_lab/durable/entry.rb +49 -0
- data/lib/robot_lab/durable/learning.rb +39 -0
- data/lib/robot_lab/durable/reflector.rb +47 -0
- data/lib/robot_lab/durable/store.rb +119 -0
- data/lib/robot_lab/durable/version.rb +7 -0
- data/lib/robot_lab/durable.rb +24 -0
- data/lib/robot_lab/recall_knowledge.rb +30 -0
- data/lib/robot_lab/record_knowledge.rb +37 -0
- data/mkdocs.yml +116 -0
- metadata +82 -0
|
@@ -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.
|