lex-prospective-memory 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6f9b96274e56e32c5e49088b65443551c071b613f3b299535d4456125ea924d9
4
+ data.tar.gz: 41224c4bc01e0b66da4d25ba71c3451150002099fe94ae4572d1855561c98d0d
5
+ SHA512:
6
+ metadata.gz: 46f6902aeb852cf1733825242cd35347e2e5dcc2e8febdc83d123702171648a3b6474070fa755af91fc39be2f9f84aa7b301d6197e266b8d6450c095425d1ef1
7
+ data.tar.gz: 4fe8ce5afa4afec96fe2aab1475f8b3b3bd69c79d54b62f09f2d949ab2903555600a9a604cf016c6b01ec2c5e0cdd2b3c819259f838db66f0a2130cf5018e26a
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/prospective_memory/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-prospective-memory'
7
+ spec.version = Legion::Extensions::ProspectiveMemory::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Prospective Memory'
12
+ spec.description = 'Prospective memory engine: manages intentions, monitors trigger conditions, and executes when conditions are met'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-prospective-memory'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-prospective-memory'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-prospective-memory'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-prospective-memory'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-prospective-memory/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-prospective-memory.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/prospective_memory/helpers/constants'
4
+ require 'legion/extensions/prospective_memory/helpers/intention'
5
+ require 'legion/extensions/prospective_memory/helpers/prospective_engine'
6
+ require 'legion/extensions/prospective_memory/runners/prospective_memory'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module ProspectiveMemory
11
+ class Client
12
+ include Runners::ProspectiveMemory
13
+
14
+ def initialize(**)
15
+ @prospective_engine = Helpers::ProspectiveEngine.new
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :prospective_engine
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module ProspectiveMemory
6
+ module Helpers
7
+ module Constants
8
+ MAX_INTENTIONS = 300
9
+ DEFAULT_URGENCY = 0.5
10
+ URGENCY_DECAY = 0.01
11
+ URGENCY_BOOST = 0.1
12
+ CHECK_INTERVAL = 60
13
+
14
+ URGENCY_LABELS = {
15
+ (0.8..) => :critical,
16
+ (0.6...0.8) => :high,
17
+ (0.4...0.6) => :moderate,
18
+ (0.2...0.4) => :low,
19
+ (..0.2) => :deferred
20
+ }.freeze
21
+
22
+ STATUS_TYPES = %i[pending monitoring triggered executed expired cancelled].freeze
23
+ TRIGGER_TYPES = %i[time_based event_based context_based activity_based].freeze
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module ProspectiveMemory
8
+ module Helpers
9
+ class Intention
10
+ attr_reader :id, :description, :trigger_type, :trigger_condition,
11
+ :domain, :created_at, :triggered_at, :executed_at, :expires_at, :status
12
+
13
+ attr_accessor :urgency
14
+
15
+ def initialize(description:, trigger_type:, trigger_condition:, urgency: Constants::DEFAULT_URGENCY,
16
+ domain: nil, expires_at: nil)
17
+ @id = SecureRandom.uuid
18
+ @description = description
19
+ @trigger_type = trigger_type
20
+ @trigger_condition = trigger_condition
21
+ @urgency = urgency.clamp(0.0, 1.0)
22
+ @domain = domain
23
+ @status = :pending
24
+ @created_at = Time.now.utc
25
+ @triggered_at = nil
26
+ @executed_at = nil
27
+ @expires_at = expires_at
28
+ end
29
+
30
+ def monitor!
31
+ @status = :monitoring
32
+ end
33
+
34
+ def trigger!
35
+ @status = :triggered
36
+ @triggered_at = Time.now.utc
37
+ end
38
+
39
+ def execute!
40
+ @status = :executed
41
+ @executed_at = Time.now.utc
42
+ end
43
+
44
+ def expire!
45
+ @status = :expired
46
+ end
47
+
48
+ def cancel!
49
+ @status = :cancelled
50
+ end
51
+
52
+ def expired?
53
+ return false unless @expires_at
54
+
55
+ Time.now.utc > @expires_at
56
+ end
57
+
58
+ def boost_urgency!(amount: Constants::URGENCY_BOOST)
59
+ @urgency = (@urgency + amount).clamp(0.0, 1.0).round(10)
60
+ end
61
+
62
+ def decay_urgency!
63
+ @urgency = (@urgency - Constants::URGENCY_DECAY).clamp(0.0, 1.0).round(10)
64
+ end
65
+
66
+ def urgency_label
67
+ Constants::URGENCY_LABELS.find { |range, _label| range.cover?(@urgency) }&.last || :deferred
68
+ end
69
+
70
+ def to_h
71
+ {
72
+ id: @id,
73
+ description: @description,
74
+ trigger_type: @trigger_type,
75
+ trigger_condition: @trigger_condition,
76
+ urgency: @urgency,
77
+ urgency_label: urgency_label,
78
+ status: @status,
79
+ domain: @domain,
80
+ created_at: @created_at,
81
+ triggered_at: @triggered_at,
82
+ executed_at: @executed_at,
83
+ expires_at: @expires_at
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module ProspectiveMemory
6
+ module Helpers
7
+ class ProspectiveEngine
8
+ attr_reader :intentions
9
+
10
+ def initialize
11
+ @intentions = {}
12
+ end
13
+
14
+ def create_intention(description:, trigger_type:, trigger_condition:,
15
+ urgency: Constants::DEFAULT_URGENCY, domain: nil, expires_at: nil)
16
+ evict_oldest! if @intentions.size >= Constants::MAX_INTENTIONS
17
+
18
+ intention = Intention.new(
19
+ description: description,
20
+ trigger_type: trigger_type,
21
+ trigger_condition: trigger_condition,
22
+ urgency: urgency,
23
+ domain: domain,
24
+ expires_at: expires_at
25
+ )
26
+ @intentions[intention.id] = intention
27
+ intention
28
+ end
29
+
30
+ def monitor_intention(intention_id:)
31
+ intention = fetch(intention_id)
32
+ return nil unless intention
33
+
34
+ intention.monitor!
35
+ intention
36
+ end
37
+
38
+ def trigger_intention(intention_id:)
39
+ intention = fetch(intention_id)
40
+ return nil unless intention
41
+
42
+ intention.trigger!
43
+ intention
44
+ end
45
+
46
+ def execute_intention(intention_id:)
47
+ intention = fetch(intention_id)
48
+ return nil unless intention
49
+
50
+ intention.execute!
51
+ intention
52
+ end
53
+
54
+ def cancel_intention(intention_id:)
55
+ intention = fetch(intention_id)
56
+ return nil unless intention
57
+
58
+ intention.cancel!
59
+ intention
60
+ end
61
+
62
+ def check_expirations
63
+ expired_count = 0
64
+ @intentions.each_value do |intention|
65
+ next unless %i[pending monitoring].include?(intention.status) && intention.expired?
66
+
67
+ intention.expire!
68
+ expired_count += 1
69
+ end
70
+ expired_count
71
+ end
72
+
73
+ def pending_intentions
74
+ by_status(:pending)
75
+ end
76
+
77
+ def monitoring_intentions
78
+ by_status(:monitoring)
79
+ end
80
+
81
+ def triggered_intentions
82
+ by_status(:triggered)
83
+ end
84
+
85
+ def by_domain(domain:)
86
+ @intentions.values.select { |i| i.domain == domain }
87
+ end
88
+
89
+ def by_urgency(min_urgency: 0.5)
90
+ @intentions.values.select { |i| i.urgency >= min_urgency }
91
+ end
92
+
93
+ def most_urgent(limit: 5)
94
+ active = @intentions.values.reject { |i| %i[executed expired cancelled].include?(i.status) }
95
+ active.sort_by { |i| -i.urgency }.first(limit)
96
+ end
97
+
98
+ def decay_all_urgency
99
+ @intentions.each_value do |intention|
100
+ next unless %i[pending monitoring].include?(intention.status)
101
+
102
+ intention.decay_urgency!
103
+ end
104
+ end
105
+
106
+ def execution_rate
107
+ terminal = @intentions.values.select { |i| %i[executed expired cancelled].include?(i.status) }
108
+ return 0.0 if terminal.empty?
109
+
110
+ executed = terminal.count { |i| i.status == :executed }
111
+ (executed.to_f / terminal.size).round(10)
112
+ end
113
+
114
+ def intention_report
115
+ all = @intentions.values
116
+ counts = Constants::STATUS_TYPES.to_h do |s|
117
+ [s, all.count { |i| i.status == s }]
118
+ end
119
+ {
120
+ total: all.size,
121
+ by_status: counts,
122
+ execution_rate: execution_rate,
123
+ most_urgent: most_urgent(limit: 5).map(&:to_h)
124
+ }
125
+ end
126
+
127
+ def to_h
128
+ {
129
+ intentions: @intentions.transform_values(&:to_h),
130
+ intention_count: @intentions.size,
131
+ execution_rate: execution_rate
132
+ }
133
+ end
134
+
135
+ private
136
+
137
+ def fetch(intention_id)
138
+ @intentions[intention_id]
139
+ end
140
+
141
+ def by_status(status)
142
+ @intentions.values.select { |i| i.status == status }
143
+ end
144
+
145
+ def evict_oldest!
146
+ oldest_id = @intentions.min_by { |_id, i| i.created_at }&.first
147
+ @intentions.delete(oldest_id) if oldest_id
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module ProspectiveMemory
6
+ module Runners
7
+ module ProspectiveMemory
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_intention(description:, trigger_type:, trigger_condition:,
12
+ urgency: Helpers::Constants::DEFAULT_URGENCY,
13
+ domain: nil, expires_at: nil, **)
14
+ unless Helpers::Constants::TRIGGER_TYPES.include?(trigger_type)
15
+ return { error: :invalid_trigger_type, valid_types: Helpers::Constants::TRIGGER_TYPES }
16
+ end
17
+
18
+ intention = prospective_engine.create_intention(
19
+ description: description,
20
+ trigger_type: trigger_type,
21
+ trigger_condition: trigger_condition,
22
+ urgency: urgency,
23
+ domain: domain,
24
+ expires_at: expires_at
25
+ )
26
+
27
+ Legion::Logging.debug "[prospective_memory] created intention id=#{intention.id[0..7]} " \
28
+ "type=#{trigger_type} urgency=#{intention.urgency.round(2)}"
29
+
30
+ { created: true, intention: intention.to_h }
31
+ end
32
+
33
+ def monitor_intention(intention_id:, **)
34
+ intention = prospective_engine.monitor_intention(intention_id: intention_id)
35
+ if intention
36
+ Legion::Logging.debug "[prospective_memory] monitoring intention id=#{intention_id[0..7]}"
37
+ { updated: true, intention: intention.to_h }
38
+ else
39
+ { updated: false, reason: :not_found }
40
+ end
41
+ end
42
+
43
+ def trigger_intention(intention_id:, **)
44
+ intention = prospective_engine.trigger_intention(intention_id: intention_id)
45
+ if intention
46
+ Legion::Logging.info "[prospective_memory] triggered intention id=#{intention_id[0..7]}"
47
+ { updated: true, intention: intention.to_h }
48
+ else
49
+ { updated: false, reason: :not_found }
50
+ end
51
+ end
52
+
53
+ def execute_intention(intention_id:, **)
54
+ intention = prospective_engine.execute_intention(intention_id: intention_id)
55
+ if intention
56
+ Legion::Logging.info "[prospective_memory] executed intention id=#{intention_id[0..7]}"
57
+ { updated: true, intention: intention.to_h }
58
+ else
59
+ { updated: false, reason: :not_found }
60
+ end
61
+ end
62
+
63
+ def cancel_intention(intention_id:, **)
64
+ intention = prospective_engine.cancel_intention(intention_id: intention_id)
65
+ if intention
66
+ Legion::Logging.debug "[prospective_memory] cancelled intention id=#{intention_id[0..7]}"
67
+ { updated: true, intention: intention.to_h }
68
+ else
69
+ { updated: false, reason: :not_found }
70
+ end
71
+ end
72
+
73
+ def check_expirations(**)
74
+ expired_count = prospective_engine.check_expirations
75
+ Legion::Logging.debug "[prospective_memory] expiration check expired=#{expired_count}"
76
+ { expired_count: expired_count }
77
+ end
78
+
79
+ def pending_intentions(**)
80
+ intentions = prospective_engine.pending_intentions
81
+ Legion::Logging.debug "[prospective_memory] pending count=#{intentions.size}"
82
+ { intentions: intentions.map(&:to_h), count: intentions.size }
83
+ end
84
+
85
+ def monitoring_intentions(**)
86
+ intentions = prospective_engine.monitoring_intentions
87
+ Legion::Logging.debug "[prospective_memory] monitoring count=#{intentions.size}"
88
+ { intentions: intentions.map(&:to_h), count: intentions.size }
89
+ end
90
+
91
+ def triggered_intentions(**)
92
+ intentions = prospective_engine.triggered_intentions
93
+ Legion::Logging.debug "[prospective_memory] triggered count=#{intentions.size}"
94
+ { intentions: intentions.map(&:to_h), count: intentions.size }
95
+ end
96
+
97
+ def intentions_by_domain(domain:, **)
98
+ intentions = prospective_engine.by_domain(domain: domain)
99
+ Legion::Logging.debug "[prospective_memory] by_domain domain=#{domain} count=#{intentions.size}"
100
+ { intentions: intentions.map(&:to_h), count: intentions.size, domain: domain }
101
+ end
102
+
103
+ def intentions_by_urgency(min_urgency: 0.5, **)
104
+ intentions = prospective_engine.by_urgency(min_urgency: min_urgency)
105
+ Legion::Logging.debug "[prospective_memory] by_urgency min=#{min_urgency} count=#{intentions.size}"
106
+ { intentions: intentions.map(&:to_h), count: intentions.size }
107
+ end
108
+
109
+ def most_urgent_intentions(limit: 5, **)
110
+ intentions = prospective_engine.most_urgent(limit: limit)
111
+ Legion::Logging.debug "[prospective_memory] most_urgent limit=#{limit} found=#{intentions.size}"
112
+ { intentions: intentions.map(&:to_h), count: intentions.size }
113
+ end
114
+
115
+ def decay_urgency(**)
116
+ prospective_engine.decay_all_urgency
117
+ Legion::Logging.debug '[prospective_memory] urgency decay cycle complete'
118
+ { decayed: true }
119
+ end
120
+
121
+ def execution_rate(**)
122
+ rate = prospective_engine.execution_rate
123
+ Legion::Logging.debug "[prospective_memory] execution_rate=#{rate.round(4)}"
124
+ { execution_rate: rate }
125
+ end
126
+
127
+ def intention_report(**)
128
+ report = prospective_engine.intention_report
129
+ Legion::Logging.debug "[prospective_memory] report total=#{report[:total]}"
130
+ report
131
+ end
132
+
133
+ private
134
+
135
+ def prospective_engine
136
+ @prospective_engine ||= Helpers::ProspectiveEngine.new
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module ProspectiveMemory
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/prospective_memory/version'
4
+ require 'legion/extensions/prospective_memory/helpers/constants'
5
+ require 'legion/extensions/prospective_memory/helpers/intention'
6
+ require 'legion/extensions/prospective_memory/helpers/prospective_engine'
7
+ require 'legion/extensions/prospective_memory/runners/prospective_memory'
8
+ require 'legion/extensions/prospective_memory/client'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module ProspectiveMemory
13
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/prospective_memory/client'
4
+
5
+ RSpec.describe Legion::Extensions::ProspectiveMemory::Client do
6
+ it 'responds to all runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:create_intention)
9
+ expect(client).to respond_to(:monitor_intention)
10
+ expect(client).to respond_to(:trigger_intention)
11
+ expect(client).to respond_to(:execute_intention)
12
+ expect(client).to respond_to(:cancel_intention)
13
+ expect(client).to respond_to(:check_expirations)
14
+ expect(client).to respond_to(:pending_intentions)
15
+ expect(client).to respond_to(:monitoring_intentions)
16
+ expect(client).to respond_to(:triggered_intentions)
17
+ expect(client).to respond_to(:intentions_by_domain)
18
+ expect(client).to respond_to(:intentions_by_urgency)
19
+ expect(client).to respond_to(:most_urgent_intentions)
20
+ expect(client).to respond_to(:decay_urgency)
21
+ expect(client).to respond_to(:execution_rate)
22
+ expect(client).to respond_to(:intention_report)
23
+ end
24
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::ProspectiveMemory::Helpers::Constants do
4
+ describe 'MAX_INTENTIONS' do
5
+ it 'is 300' do
6
+ expect(described_class::MAX_INTENTIONS).to eq(300)
7
+ end
8
+ end
9
+
10
+ describe 'DEFAULT_URGENCY' do
11
+ it 'is 0.5' do
12
+ expect(described_class::DEFAULT_URGENCY).to eq(0.5)
13
+ end
14
+ end
15
+
16
+ describe 'URGENCY_DECAY' do
17
+ it 'is 0.01' do
18
+ expect(described_class::URGENCY_DECAY).to eq(0.01)
19
+ end
20
+ end
21
+
22
+ describe 'URGENCY_BOOST' do
23
+ it 'is 0.1' do
24
+ expect(described_class::URGENCY_BOOST).to eq(0.1)
25
+ end
26
+ end
27
+
28
+ describe 'CHECK_INTERVAL' do
29
+ it 'is 60' do
30
+ expect(described_class::CHECK_INTERVAL).to eq(60)
31
+ end
32
+ end
33
+
34
+ describe 'URGENCY_LABELS' do
35
+ it 'maps 0.9 to :critical' do
36
+ label = described_class::URGENCY_LABELS.find { |range, _| range.cover?(0.9) }&.last
37
+ expect(label).to eq(:critical)
38
+ end
39
+
40
+ it 'maps 0.7 to :high' do
41
+ label = described_class::URGENCY_LABELS.find { |range, _| range.cover?(0.7) }&.last
42
+ expect(label).to eq(:high)
43
+ end
44
+
45
+ it 'maps 0.5 to :moderate' do
46
+ label = described_class::URGENCY_LABELS.find { |range, _| range.cover?(0.5) }&.last
47
+ expect(label).to eq(:moderate)
48
+ end
49
+
50
+ it 'maps 0.3 to :low' do
51
+ label = described_class::URGENCY_LABELS.find { |range, _| range.cover?(0.3) }&.last
52
+ expect(label).to eq(:low)
53
+ end
54
+
55
+ it 'maps 0.1 to :deferred' do
56
+ label = described_class::URGENCY_LABELS.find { |range, _| range.cover?(0.1) }&.last
57
+ expect(label).to eq(:deferred)
58
+ end
59
+
60
+ it 'is frozen' do
61
+ expect(described_class::URGENCY_LABELS).to be_frozen
62
+ end
63
+ end
64
+
65
+ describe 'STATUS_TYPES' do
66
+ it 'includes all expected statuses' do
67
+ expect(described_class::STATUS_TYPES).to include(:pending, :monitoring, :triggered, :executed, :expired, :cancelled)
68
+ end
69
+
70
+ it 'is frozen' do
71
+ expect(described_class::STATUS_TYPES).to be_frozen
72
+ end
73
+ end
74
+
75
+ describe 'TRIGGER_TYPES' do
76
+ it 'includes all expected trigger types' do
77
+ expect(described_class::TRIGGER_TYPES).to include(:time_based, :event_based, :context_based, :activity_based)
78
+ end
79
+
80
+ it 'is frozen' do
81
+ expect(described_class::TRIGGER_TYPES).to be_frozen
82
+ end
83
+ end
84
+ end