robot_lab-durable 0.2.1 → 0.2.6

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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'htm'
4
+
5
+ module RobotLab
6
+ module Durable
7
+ class Adapter
8
+ attr_reader :htm
9
+
10
+ def initialize(robot_name:)
11
+ @htm = HTM.new(robot_name: robot_name)
12
+ end
13
+
14
+ # Recall entries relevant to a query using HTM hybrid search.
15
+ #
16
+ # @param query [String] Natural-language search string
17
+ # @param limit [Integer] Maximum number of results
18
+ # @param strategy [Symbol] :vector, :fulltext, or :hybrid
19
+ # @return [Array<Entry>]
20
+ def recall(query:, limit: 20, strategy: :hybrid)
21
+ nodes = @htm.recall(query, limit: limit, strategy: strategy, raw: true)
22
+ nodes.map { |node| Entry.from_node(node) }
23
+ end
24
+
25
+ # Store content in HTM long-term memory.
26
+ #
27
+ # @param content [String]
28
+ # @param reasoning [String, nil] Why this is worth remembering
29
+ # @param category [String, Symbol] fact, preference, pattern, correction, or observation
30
+ # @return [Integer] HTM node_id
31
+ def record(content:, reasoning: nil, category: 'fact')
32
+ @htm.remember(
33
+ content,
34
+ metadata: {
35
+ 'reasoning' => reasoning,
36
+ 'category' => category.to_s
37
+ }
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -2,46 +2,15 @@
2
2
 
3
3
  module RobotLab
4
4
  module Durable
5
- CONFIDENCE_INCREMENT = 0.1
6
- MAX_CONFIDENCE = 1.0
7
-
8
- Entry = Data.define(:content, :reasoning, :category, :domain, :confidence, :use_count, :created_at, :updated_at) do
9
- # Return a new Entry with confidence incremented and use_count bumped.
10
- def confirm
11
- new_confidence = [confidence + CONFIDENCE_INCREMENT, MAX_CONFIDENCE].min
12
- with(
13
- confidence: new_confidence.round(10),
14
- use_count: use_count + 1,
15
- updated_at: Time.now.iso8601
16
- )
17
- end
18
-
19
- # Serialize to a plain Hash with string keys (safe for YAML round-trip).
20
- def to_h
21
- {
22
- 'content' => content,
23
- 'reasoning' => reasoning,
24
- 'category' => category.to_s,
25
- 'domain' => domain,
26
- 'confidence' => confidence,
27
- 'use_count' => use_count,
28
- 'created_at' => created_at,
29
- 'updated_at' => updated_at
30
- }
31
- end
32
-
33
- # Deserialize from a Hash (string or symbol keys).
34
- def self.from_h(hash)
35
- h = hash.transform_keys(&:to_s)
5
+ Entry = Data.define(:node_id, :content, :reasoning, :category, :created_at) do
6
+ def self.from_node(node)
7
+ meta = node['metadata'] || {}
36
8
  new(
37
- content: h['content'],
38
- reasoning: h['reasoning'],
39
- category: h['category']&.to_sym,
40
- domain: h['domain'],
41
- confidence: h['confidence'].to_f,
42
- use_count: h['use_count'].to_i,
43
- created_at: h['created_at'],
44
- updated_at: h['updated_at']
9
+ node_id: node['id'],
10
+ content: node['content'],
11
+ reasoning: meta['reasoning'],
12
+ category: (meta['category'] || 'fact').to_sym,
13
+ created_at: node['created_at']
45
14
  )
46
15
  end
47
16
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Durable
5
+ class Hook < RobotLab::Hook
6
+ self.namespace = :durable
7
+
8
+ class << self
9
+ # Initialize an HTM-backed adapter for the robot and make it available
10
+ # thread-locally for the duration of the run, then tear it down.
11
+ def around_run(ctx, &block)
12
+ robot_name = ctx.robot.name || 'default'
13
+ set_adapter(Adapter.new(robot_name: robot_name))
14
+ block.call
15
+ ensure
16
+ clear_adapter
17
+ end
18
+
19
+ # Persist new session learnings to HTM long-term memory.
20
+ # HTM deduplicates by content hash, so double-writes from RecordKnowledge
21
+ # are harmless — the second call simply increments remember_count.
22
+ def on_learn(ctx)
23
+ return unless ctx.stored
24
+
25
+ adapter = current_adapter
26
+ return unless adapter
27
+
28
+ adapter.record(content: ctx.text, category: :observation)
29
+ end
30
+
31
+ # Returns the active durable adapter for the current thread, or nil.
32
+ def current_adapter
33
+ Thread.current[:robot_lab_durable_adapter]
34
+ end
35
+
36
+ private
37
+
38
+ def set_adapter(adapter)
39
+ Thread.current[:robot_lab_durable_adapter] = adapter
40
+ end
41
+
42
+ def clear_adapter
43
+ Thread.current[:robot_lab_durable_adapter] = nil
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RobotLab
4
4
  module Durable
5
- VERSION = '0.2.1'
5
+ VERSION = '0.2.6'
6
6
  end
7
7
  end
@@ -2,25 +2,23 @@
2
2
 
3
3
  require_relative 'durable/version'
4
4
  require_relative 'durable/entry'
5
- require_relative 'durable/store'
6
- require_relative 'durable/reflector'
7
- require_relative 'durable/learning'
5
+ require_relative 'durable/adapter'
8
6
 
9
- # Minimal error stub so the storage layer works without robot_lab loaded.
7
+ # Minimal error stub so the adapter layer works without robot_lab loaded.
10
8
  # When robot_lab is present its own RobotLab::Error takes precedence.
11
9
  module RobotLab
12
10
  Error = StandardError unless defined?(Error)
13
11
  end
14
12
 
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.
13
+ if defined?(RobotLab::Hook)
14
+ require_relative 'durable/hook'
15
+ end
16
+
17
17
  if defined?(RobotLab::Tool)
18
18
  require_relative 'recall_knowledge'
19
19
  require_relative 'record_knowledge'
20
20
  end
21
21
 
22
- RobotLab::Robot.include(RobotLab::Durable::Learning) if defined?(RobotLab::Robot)
23
-
24
22
  if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
25
23
  RobotLab.register_extension(:durable, RobotLab::Durable)
26
24
  end
@@ -7,22 +7,18 @@ module RobotLab
7
7
  'to check if you have seen a similar situation before. ' \
8
8
  'When in doubt and no relevant knowledge is found, skip the action.'
9
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
10
+ param :query, type: 'string', desc: 'Natural language description of the decision you are about to make'
12
11
 
13
- def execute(query:, domain: nil)
14
- store = robot&.durable_store
15
- return 'No durable store configured on this robot.' unless store
12
+ def execute(query:)
13
+ adapter = RobotLab::Durable::Hook.current_adapter
14
+ return 'No durable session active on this robot.' unless adapter
16
15
 
17
- entries = store.recall(query: query, domain: domain, min_confidence: 0.0)
16
+ entries = adapter.recall(query: query)
18
17
 
19
18
  if entries.empty?
20
19
  "No relevant past knowledge found for: #{query}. When in doubt, skip."
21
20
  else
22
- lines = entries.map do |e|
23
- "[#{e.category}/conf:#{format('%.1f', e.confidence)}] #{e.content} — #{e.reasoning}"
24
- end
25
-
21
+ lines = entries.map { |e| "[#{e.category}] #{e.content} — #{e.reasoning}" }
26
22
  "Relevant past knowledge:\n#{lines.join("\n")}"
27
23
  end
28
24
  end
@@ -11,26 +11,16 @@ module RobotLab
11
11
  param :reasoning, type: 'string',
12
12
  desc: 'Why this is worth remembering — the observation or discussion that led to it'
13
13
  param :category, type: 'string', desc: 'One of: fact, preference, pattern, correction'
14
- param :domain, type: 'string', desc: "Topic area this applies to (e.g. 'newsletter curation', 'ruby tooling')"
15
14
 
16
- def execute(content:, reasoning:, category:, domain:)
17
- store = robot&.durable_store
18
- return 'No durable store configured on this robot.' unless store
15
+ def execute(content:, reasoning:, category:)
16
+ adapter = RobotLab::Durable::Hook.current_adapter
17
+ return 'No durable session active on this robot.' unless adapter
19
18
 
20
- now = Time.now.iso8601
21
- entry = Durable::Entry.new(
22
- content:,
23
- reasoning:,
24
- category: category.to_sym,
25
- domain:,
26
- confidence: 0.1,
27
- use_count: 0,
28
- created_at: now,
29
- updated_at: now
30
- )
19
+ adapter.record(content: content, reasoning: reasoning, category: category)
31
20
 
32
- store.record(entry)
33
- robot.learn("#{content} (#{domain})")
21
+ # Update session memory so subsequent LLM calls in this run see the fact.
22
+ # HTM deduplicates by content hash, so the on_learn callback that follows is safe.
23
+ robot.learn(content)
34
24
 
35
25
  "Recorded: #{content}"
36
26
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: robot_lab-durable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -23,10 +23,24 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: 0.2.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
26
+ - !ruby/object:Gem::Dependency
27
+ name: htm
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Provides RobotLab::Durable — an adapter between robot_lab and the HTM
41
+ gem (Hierarchical Temporal Memory). Replaces the former YAML store with PostgreSQL-backed
42
+ long-term memory featuring vector embeddings, hybrid semantic search, hierarchical
43
+ tagging, and multi-robot federation. Includes RecallKnowledge and RecordKnowledge
30
44
  tools that integrate directly into Robot when robot_lab is present.
31
45
  email:
32
46
  - dvanhoozer@gmail.com
@@ -36,8 +50,11 @@ extra_rdoc_files: []
36
50
  files:
37
51
  - ".envrc"
38
52
  - ".github/workflows/deploy-github-pages.yml"
53
+ - ".loki"
54
+ - ".quality/reek_baseline.txt"
39
55
  - ".rubocop.yml"
40
56
  - CHANGELOG.md
57
+ - CLAUDE.md
41
58
  - LICENSE.txt
42
59
  - README.md
43
60
  - Rakefile
@@ -45,13 +62,13 @@ files:
45
62
  - docs/index.md
46
63
  - docs/superpowers/plans/2026-05-06-durable-learning.md
47
64
  - docs/superpowers/specs/2026-05-06-durable-learning-design.md
48
- - examples/33_stock_generator.rb
49
- - examples/33_stock_predictor.rb
65
+ - examples/01_day_trader.rb
66
+ - examples/day_trader_lib/generator.rb
67
+ - examples/day_trader_lib/predictor.rb
50
68
  - lib/robot_lab/durable.rb
69
+ - lib/robot_lab/durable/adapter.rb
51
70
  - lib/robot_lab/durable/entry.rb
52
- - lib/robot_lab/durable/learning.rb
53
- - lib/robot_lab/durable/reflector.rb
54
- - lib/robot_lab/durable/store.rb
71
+ - lib/robot_lab/durable/hook.rb
55
72
  - lib/robot_lab/durable/version.rb
56
73
  - lib/robot_lab/recall_knowledge.rb
57
74
  - lib/robot_lab/record_knowledge.rb
@@ -78,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
95
  - !ruby/object:Gem::Version
79
96
  version: '0'
80
97
  requirements: []
81
- rubygems_version: 4.0.11
98
+ rubygems_version: 4.0.17
82
99
  specification_version: 4
83
- summary: Cross-session durable learning for RobotLab agents
100
+ summary: HTM-backed long-term memory for RobotLab agents
84
101
  test_files: []
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RobotLab
4
- module Durable
5
- module Learning
6
- # Configure durable learning on a robot after initialization.
7
- #
8
- # @param domain [String] topic area for this robot's knowledge
9
- # @param store_path [String, nil] override default ~/.robot_lab/durable path
10
- def setup_durable_learning(domain:, store_path: nil)
11
- @learn_domain = domain.to_s
12
- opts = store_path ? { path: store_path } : {}
13
- @durable_store = Store.new(**opts)
14
-
15
- seed_from_store
16
- @local_tools = (@local_tools + [RecallKnowledge, RecordKnowledge]).uniq
17
- end
18
-
19
- # Run the end-of-session reflection pass.
20
- # Called automatically from Robot#run when durable learning is active.
21
- def run_reflector
22
- return unless @durable_store && @learn_domain && @learnings&.any?
23
-
24
- Reflector.new(store: @durable_store, domain: @learn_domain).reflect(@learnings)
25
- end
26
-
27
- private
28
-
29
- def seed_from_store
30
- return unless @durable_store && @learn_domain
31
-
32
- entries = @durable_store.recall(query: @learn_domain, domain: @learn_domain, min_confidence: 0.0)
33
- entries.each do |e|
34
- learn("[#{e.category}] #{e.content}: #{e.reasoning}")
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,47 +0,0 @@
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
@@ -1,120 +0,0 @@
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(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(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
-
101
- entries[idx] = entry
102
- save_domain(entry.domain, entries)
103
- end
104
- end
105
-
106
- def domain_file(domain)
107
- safe = domain.to_s.downcase.gsub(/[^a-z0-9]+/, '_').delete_prefix('_').delete_suffix('_')
108
- File.join(@path, "#{safe}.yaml")
109
- end
110
-
111
- def with_domain_lock(domain, &block)
112
- lock_path = "#{domain_file(domain)}.lock"
113
- File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |f|
114
- f.flock(File::LOCK_EX)
115
- block.call
116
- end
117
- end
118
- end
119
- end
120
- end