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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Durable
5
+ class Reflector
6
+ def initialize(store:, domain:)
7
+ @store = store
8
+ @domain = domain.to_s
9
+ end
10
+
11
+ # Examine plain-text learnings accumulated during a session and promote
12
+ # any that are not already represented in the store.
13
+ #
14
+ # @param learnings [Array<String>] robot.learnings from the completed session
15
+ def reflect(learnings)
16
+ Array(learnings).each do |text|
17
+ next if text.nil? || text.strip.empty?
18
+
19
+ text = text.strip
20
+ next if already_stored?(text)
21
+
22
+ now = Time.now.iso8601
23
+ @store.record(
24
+ Entry.new(
25
+ content: text,
26
+ reasoning: "Observed during session (auto-promoted by Reflector)",
27
+ category: :pattern,
28
+ domain: @domain,
29
+ confidence: 0.1,
30
+ use_count: 0,
31
+ created_at: now,
32
+ updated_at: now
33
+ )
34
+ )
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def already_stored?(text)
41
+ @store.recall(query: text, domain: @domain, min_confidence: 0.0).any? do |e|
42
+ e.content.downcase == text.downcase
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module RobotLab
7
+ module Durable
8
+ class Store
9
+ DEFAULT_PATH = File.join(Dir.home, ".robot_lab", "durable")
10
+
11
+ MIN_WORD_LENGTH = 3
12
+
13
+ def initialize(path: DEFAULT_PATH)
14
+ @path = path
15
+ FileUtils.mkdir_p(@path)
16
+ end
17
+
18
+ # Return entries matching query keywords, sorted by descending confidence.
19
+ #
20
+ # @param query [String] natural-language search string
21
+ # @param domain [String, nil] restrict to one domain file; nil searches all
22
+ # @param min_confidence [Float] exclude entries below this threshold
23
+ # @return [Array<Entry>]
24
+ def recall(query:, domain: nil, min_confidence: 0.0)
25
+ entries = domain ? load_domain(domain) : load_all
26
+ words = tokenize(query)
27
+
28
+ entries
29
+ .select { |e| e.confidence >= min_confidence }
30
+ .select { |e| matches?(e, words) }
31
+ .sort_by { |e| -e.confidence }
32
+ end
33
+
34
+ # Persist a new entry. If an entry with the same content already exists
35
+ # in the domain file, increment its confidence and use_count instead.
36
+ #
37
+ # @param entry [Entry]
38
+ # @return [Entry] the stored entry (may differ if an existing one was updated)
39
+ def record(entry)
40
+ with_domain_lock(entry.domain) do
41
+ entries = load_domain(entry.domain)
42
+ idx = entries.find_index { |e| e.content.downcase == entry.content.downcase }
43
+
44
+ if idx
45
+ entries[idx] = entries[idx].confirm
46
+ else
47
+ entries << entry
48
+ end
49
+
50
+ save_domain(entry.domain, entries)
51
+ entries[idx || -1]
52
+ end
53
+ end
54
+
55
+ # Increment confidence and use_count on a stored entry.
56
+ #
57
+ # @param entry [Entry]
58
+ # @return [Entry] the updated entry
59
+ def confirm(entry)
60
+ updated = entry.confirm
61
+ record_exact(updated)
62
+ updated
63
+ end
64
+
65
+ private
66
+
67
+ def matches?(entry, words)
68
+ text = "#{entry.content} #{entry.domain}".downcase
69
+ words.any? { |w| text.include?(w) }
70
+ end
71
+
72
+ def tokenize(str)
73
+ str.downcase.split(/\s+/).reject { |w| w.length < MIN_WORD_LENGTH }
74
+ end
75
+
76
+ def load_domain(domain)
77
+ file = domain_file(domain)
78
+ return [] unless File.exist?(file)
79
+
80
+ raw = Array(YAML.safe_load(File.read(file)) || [])
81
+ raw.map { |h| Entry.from_h(h) }
82
+ end
83
+
84
+ def load_all
85
+ Dir.glob(File.join(@path, "*.yaml")).flat_map do |file|
86
+ raw = Array(YAML.safe_load(File.read(file)) || [])
87
+ raw.map { |h| Entry.from_h(h) }
88
+ end
89
+ end
90
+
91
+ def save_domain(domain, entries)
92
+ File.write(domain_file(domain), YAML.dump(entries.map(&:to_h)))
93
+ end
94
+
95
+ def record_exact(entry)
96
+ with_domain_lock(entry.domain) do
97
+ entries = load_domain(entry.domain)
98
+ idx = entries.find_index { |e| e.content.downcase == entry.content.downcase }
99
+ raise RobotLab::Error, "Cannot confirm: entry not found in domain '#{entry.domain}'" unless idx
100
+ entries[idx] = entry
101
+ save_domain(entry.domain, entries)
102
+ end
103
+ end
104
+
105
+ def domain_file(domain)
106
+ safe = domain.to_s.downcase.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
107
+ File.join(@path, "#{safe}.yaml")
108
+ end
109
+
110
+ def with_domain_lock(domain, &block)
111
+ lock_path = domain_file(domain) + ".lock"
112
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |f|
113
+ f.flock(File::LOCK_EX)
114
+ block.call
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Durable
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "durable/version"
4
+ require_relative "durable/entry"
5
+ require_relative "durable/store"
6
+ require_relative "durable/reflector"
7
+ require_relative "durable/learning"
8
+
9
+ # Minimal error stub so the storage layer works without robot_lab loaded.
10
+ # When robot_lab is present its own RobotLab::Error takes precedence.
11
+ module RobotLab
12
+ Error = StandardError unless defined?(Error)
13
+ end
14
+
15
+ # When robot_lab is loaded, register the knowledge tools and hook the
16
+ # Learning mixin into Robot so `learn: true` works in the constructor.
17
+ if defined?(RobotLab::Tool)
18
+ require_relative "recall_knowledge"
19
+ require_relative "record_knowledge"
20
+ end
21
+
22
+ if defined?(RobotLab::Robot)
23
+ RobotLab::Robot.include(RobotLab::Durable::Learning)
24
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class RecallKnowledge < Tool
5
+ description "Recall relevant knowledge from past sessions before making a decision. " \
6
+ "Use this when uncertain whether to include or skip content, or when you want " \
7
+ "to check if you have seen a similar situation before. " \
8
+ "When in doubt and no relevant knowledge is found, skip the action."
9
+
10
+ param :query, type: "string", desc: "Natural language description of the decision you are about to make"
11
+ param :domain, type: "string", desc: "Topic area to search (e.g. 'newsletter curation')", required: false
12
+
13
+ def execute(query:, domain: nil)
14
+ store = robot&.durable_store
15
+ return "No durable store configured on this robot." unless store
16
+
17
+ entries = store.recall(query: query, domain: domain, min_confidence: 0.0)
18
+
19
+ if entries.empty?
20
+ "No relevant past knowledge found for: #{query}. When in doubt, skip."
21
+ else
22
+ lines = entries.map do |e|
23
+ "[#{e.category}/conf:#{format("%.1f", e.confidence)}] #{e.content} — #{e.reasoning}"
24
+ end
25
+
26
+ "Relevant past knowledge:\n#{lines.join("\n")}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class RecordKnowledge < Tool
5
+ description "Record a piece of knowledge learned during this session. " \
6
+ "Use after a decision or discussion reveals something worth remembering: " \
7
+ "a user preference, a reliable pattern, or a factual insight. " \
8
+ "Recorded knowledge persists across future sessions."
9
+
10
+ param :content, type: "string", desc: "The knowledge to record, in plain language (one clear statement)"
11
+ param :reasoning, type: "string", desc: "Why this is worth remembering — the observation or discussion that led to it"
12
+ param :category, type: "string", desc: "One of: fact, preference, pattern, correction"
13
+ param :domain, type: "string", desc: "Topic area this applies to (e.g. 'newsletter curation', 'ruby tooling')"
14
+
15
+ def execute(content:, reasoning:, category:, domain:)
16
+ store = robot&.durable_store
17
+ return "No durable store configured on this robot." unless store
18
+
19
+ now = Time.now.iso8601
20
+ entry = Durable::Entry.new(
21
+ content:,
22
+ reasoning:,
23
+ category: category.to_sym,
24
+ domain:,
25
+ confidence: 0.1,
26
+ use_count: 0,
27
+ created_at: now,
28
+ updated_at: now
29
+ )
30
+
31
+ store.record(entry)
32
+ robot.learn("#{content} (#{domain})")
33
+
34
+ "Recorded: #{content}"
35
+ end
36
+ end
37
+ end
data/mkdocs.yml ADDED
@@ -0,0 +1,116 @@
1
+ site_name: robot_lab-durable
2
+ site_description: Cross-session durable learning for the RobotLab LLM agent framework
3
+ site_author: Dewayne VanHoozer
4
+ site_url: https://madbomber.github.io/robot_lab-durable
5
+ copyright: Copyright &copy; 2025 Dewayne VanHoozer
6
+
7
+ repo_name: MadBomber/robot_lab-durable
8
+ repo_url: https://github.com/MadBomber/robot_lab-durable
9
+ edit_uri: edit/main/docs/
10
+
11
+ theme:
12
+ name: material
13
+
14
+ palette:
15
+ - scheme: default
16
+ primary: green
17
+ accent: amber
18
+ toggle:
19
+ icon: material/brightness-7
20
+ name: Switch to dark mode
21
+
22
+ - scheme: slate
23
+ primary: green
24
+ accent: amber
25
+ toggle:
26
+ icon: material/brightness-4
27
+ name: Switch to light mode
28
+
29
+ font:
30
+ text: Roboto
31
+ code: Roboto Mono
32
+
33
+ icon:
34
+ repo: fontawesome/brands/github
35
+ logo: material/brain
36
+
37
+ features:
38
+ - navigation.instant
39
+ - navigation.tracking
40
+ - navigation.tabs
41
+ - navigation.tabs.sticky
42
+ - navigation.path
43
+ - navigation.indexes
44
+ - navigation.top
45
+ - navigation.footer
46
+ - toc.follow
47
+ - search.suggest
48
+ - search.highlight
49
+ - search.share
50
+ - header.autohide
51
+ - content.code.copy
52
+ - content.code.annotate
53
+ - content.tabs.link
54
+ - content.tooltips
55
+ - content.action.edit
56
+ - content.action.view
57
+
58
+ plugins:
59
+ - search:
60
+ separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
61
+
62
+ markdown_extensions:
63
+ - abbr
64
+ - admonition
65
+ - attr_list
66
+ - def_list
67
+ - footnotes
68
+ - md_in_html
69
+ - tables
70
+ - toc:
71
+ permalink: true
72
+ title: On this page
73
+ - pymdownx.betterem:
74
+ smart_enable: all
75
+ - pymdownx.caret
76
+ - pymdownx.details
77
+ - pymdownx.emoji:
78
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
79
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
80
+ - pymdownx.highlight:
81
+ anchor_linenums: true
82
+ line_spans: __span
83
+ pygments_lang_class: true
84
+ - pymdownx.inlinehilite
85
+ - pymdownx.magiclink:
86
+ repo_url_shorthand: true
87
+ user: MadBomber
88
+ repo: robot_lab-durable
89
+ normalize_issue_symbols: true
90
+ - pymdownx.mark
91
+ - pymdownx.smartsymbols
92
+ - pymdownx.superfences:
93
+ custom_fences:
94
+ - name: mermaid
95
+ class: mermaid
96
+ format: !!python/name:pymdownx.superfences.fence_code_format
97
+ - pymdownx.tabbed:
98
+ alternate_style: true
99
+ - pymdownx.tasklist:
100
+ custom_checkbox: true
101
+ - pymdownx.tilde
102
+
103
+ extra:
104
+ social:
105
+ - icon: fontawesome/brands/github
106
+ link: https://github.com/MadBomber/robot_lab-durable
107
+ name: robot_lab-durable on GitHub
108
+ - icon: fontawesome/solid/gem
109
+ link: https://rubygems.org/gems/robot_lab-durable
110
+ name: robot_lab-durable on RubyGems
111
+
112
+ nav:
113
+ - Home: index.md
114
+ - Design & Planning:
115
+ - Implementation Plan: superpowers/plans/2026-05-06-durable-learning.md
116
+ - Design Spec: superpowers/specs/2026-05-06-durable-learning-design.md
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: robot_lab-durable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dewayne VanHoozer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: robot_lab
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Provides RobotLab::Durable — a YAML-backed knowledge store that lets
27
+ robot_lab agents accumulate and recall observations across sessions. Includes Entry
28
+ (immutable value object with confidence scoring), Store (file-locked per-domain
29
+ persistence), Reflector (end-of-session promoter), and the Learning mixin with RecallKnowledge/RecordKnowledge
30
+ tools that integrate directly into Robot when robot_lab is present.
31
+ email:
32
+ - dvanhoozer@gmail.com
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - ".envrc"
38
+ - ".github/workflows/deploy-github-pages.yml"
39
+ - CHANGELOG.md
40
+ - LICENSE.txt
41
+ - README.md
42
+ - Rakefile
43
+ - docs/index.md
44
+ - docs/superpowers/plans/2026-05-06-durable-learning.md
45
+ - docs/superpowers/specs/2026-05-06-durable-learning-design.md
46
+ - examples/33_stock_generator.rb
47
+ - examples/33_stock_predictor.rb
48
+ - lib/robot_lab/durable.rb
49
+ - lib/robot_lab/durable/entry.rb
50
+ - lib/robot_lab/durable/learning.rb
51
+ - lib/robot_lab/durable/reflector.rb
52
+ - lib/robot_lab/durable/store.rb
53
+ - lib/robot_lab/durable/version.rb
54
+ - lib/robot_lab/recall_knowledge.rb
55
+ - lib/robot_lab/record_knowledge.rb
56
+ - mkdocs.yml
57
+ homepage: https://github.com/MadBomber/robot_lab-durable
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ homepage_uri: https://github.com/MadBomber/robot_lab-durable
62
+ source_code_uri: https://github.com/MadBomber/robot_lab-durable
63
+ changelog_uri: https://github.com/MadBomber/robot_lab-durable/blob/main/CHANGELOG.md
64
+ rubygems_mfa_required: 'true'
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 3.2.0
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 4.0.11
80
+ specification_version: 4
81
+ summary: Cross-session durable learning for RobotLab agents
82
+ test_files: []