lex-somatic-marker 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: d139fd458759ac7941be893a1b5d8030e1923d366d1e8a79a796d18afee34044
4
+ data.tar.gz: 0ffb155f12fdae620c562849a81c1fbbcaf224e7b5b9ca4e81365615fe1dcd54
5
+ SHA512:
6
+ metadata.gz: 273656c152ceb7d137acbd0dbef32305d4acf57802347ec816f2e4f7e5a371b714a0b02ea88a4273b915458df85cc16cdbc2c1edbfdf925411c4b8d44b803cbb
7
+ data.tar.gz: a6924be1392b3adfbdab40357888e81b066c246f306781b6e512c21b7a437e9d3df2059b39cb9a2bf00be9d03861cd9e3155c8b0dfb9b5e19ba5aab81074dba1
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+ end
11
+
12
+ gem 'legion-gaia', path: '../../legion-gaia'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Iverson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # lex-somatic-marker
2
+
3
+ Damasio Somatic Marker Hypothesis implementation for LegionIO cognitive agents. Affective signals associated with past actions bias future decision-making toward approach or avoidance.
4
+
5
+ ## What It Does
6
+
7
+ `lex-somatic-marker` models how learned bodily signals (somatic markers) guide decisions. Each action the agent considers has an associated marker with a valence score. Positive valence produces an `:approach` signal; negative valence produces `:avoid`. A BodyState tracks global arousal, tension, comfort, and gut signal, providing the affective context in which decisions are made.
8
+
9
+ - **Markers**: action-associated valence scores, updated via EMA on each outcome
10
+ - **Signal**: `:approach` (valence > 0.6), `:avoid` (valence < -0.6), or `:neutral`
11
+ - **BodyState**: arousal, tension, comfort, gut_signal — composite valence = `comfort*0.4 + (1-tension)*0.3 + gut_signal*0.3`
12
+ - **Decision ranking**: `make_decision` evaluates multiple options and ranks by valence
13
+ - **Decay**: marker valence and body state drift toward neutral each tick
14
+
15
+ ## Usage
16
+
17
+ ```ruby
18
+ require 'legion/extensions/somatic_marker'
19
+
20
+ client = Legion::Extensions::SomaticMarker::Client.new
21
+
22
+ # Register a marker for an action
23
+ result = client.register_marker(action: 'deploy_without_tests', domain: :engineering)
24
+ marker_id = result[:marker_id]
25
+
26
+ # Bad outcome — reinforce with negative valence
27
+ client.reinforce(marker_id: marker_id, outcome_valence: -0.9)
28
+ # => { valence: -0.11, signal: :neutral } (first update from 0.0)
29
+
30
+ # After repeated bad outcomes, signal becomes :avoid
31
+ client.reinforce(marker_id: marker_id, outcome_valence: -0.9)
32
+ # => { valence: -0.21, signal: :neutral }
33
+
34
+ # Evaluate an option
35
+ client.evaluate_option(action: 'deploy_without_tests')
36
+ # => { signal: :neutral, valence: -0.21, body_valence: 0.59 }
37
+
38
+ # Rank multiple options
39
+ client.make_decision(options: ['deploy_without_tests', 'add_tests_first', 'skip_deployment'])
40
+ # => { ranked_options: [...], recommended: 'add_tests_first' }
41
+
42
+ # Update body state (e.g., from emotion evaluation)
43
+ client.update_body(dimension: :tension, value: 0.8)
44
+ client.body_state
45
+ # => { arousal: 0.5, tension: 0.8, comfort: 0.7, gut_signal: 0.0, composite_valence: 0.46, stressed: false }
46
+
47
+ # Per-tick decay
48
+ client.update_somatic_markers
49
+ ```
50
+
51
+ ## Development
52
+
53
+ ```bash
54
+ bundle install
55
+ bundle exec rspec
56
+ bundle exec rubocop
57
+ ```
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/somatic_marker/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-somatic-marker'
7
+ spec.version = Legion::Extensions::SomaticMarker::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Somatic Marker'
12
+ spec.description = "Damasio's Somatic Marker Hypothesis for brain-modeled agentic AI decision-making"
13
+ spec.homepage = 'https://github.com/LegionIO/lex-somatic-marker'
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-somatic-marker'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-somatic-marker'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-somatic-marker'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-somatic-marker/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-somatic-marker.gemspec Gemfile LICENSE README.md]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module SomaticMarker
8
+ module Actor
9
+ class Decay < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::SomaticMarker::Runners::SomaticMarker
12
+ end
13
+
14
+ def runner_function
15
+ 'update_somatic_markers'
16
+ end
17
+
18
+ def time
19
+ 30
20
+ end
21
+
22
+ def run_now?
23
+ false
24
+ end
25
+
26
+ def use_runner?
27
+ false
28
+ end
29
+
30
+ def check_subtask?
31
+ false
32
+ end
33
+
34
+ def generate_task?
35
+ false
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/somatic_marker/helpers/constants'
4
+ require 'legion/extensions/somatic_marker/helpers/somatic_marker'
5
+ require 'legion/extensions/somatic_marker/helpers/body_state'
6
+ require 'legion/extensions/somatic_marker/helpers/marker_store'
7
+ require 'legion/extensions/somatic_marker/runners/somatic_marker'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module SomaticMarker
12
+ class Client
13
+ include Runners::SomaticMarker
14
+
15
+ def initialize(**)
16
+ @store = Helpers::MarkerStore.new
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :store
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SomaticMarker
6
+ module Helpers
7
+ class BodyState
8
+ include Constants
9
+
10
+ attr_reader :arousal, :tension, :comfort, :gut_signal
11
+
12
+ def initialize(arousal: 0.5, tension: 0.5, comfort: 0.5, gut_signal: 0.0)
13
+ @arousal = arousal.clamp(0.0, 1.0)
14
+ @tension = tension.clamp(0.0, 1.0)
15
+ @comfort = comfort.clamp(0.0, 1.0)
16
+ @gut_signal = gut_signal.clamp(-1.0, 1.0)
17
+ end
18
+
19
+ def update(arousal: nil, tension: nil, comfort: nil, gut_signal: nil)
20
+ @arousal = arousal.clamp(0.0, 1.0) if arousal
21
+ @tension = tension.clamp(0.0, 1.0) if tension
22
+ @comfort = comfort.clamp(0.0, 1.0) if comfort
23
+ @gut_signal = gut_signal.clamp(-1.0, 1.0) if gut_signal
24
+ end
25
+
26
+ def composite_valence
27
+ (@comfort * 0.4) + ((1.0 - @tension) * 0.3) + (@gut_signal * 0.3)
28
+ end
29
+
30
+ def decay
31
+ @arousal = drift(@arousal, 0.5, BODY_STATE_DECAY)
32
+ @tension = drift(@tension, 0.5, BODY_STATE_DECAY)
33
+ @comfort = drift(@comfort, 0.5, BODY_STATE_DECAY)
34
+ @gut_signal = drift(@gut_signal, 0.0, BODY_STATE_DECAY)
35
+ end
36
+
37
+ def stressed?
38
+ @tension > 0.7 && @comfort < 0.3
39
+ end
40
+
41
+ def to_h
42
+ {
43
+ arousal: @arousal,
44
+ tension: @tension,
45
+ comfort: @comfort,
46
+ gut_signal: @gut_signal,
47
+ composite_valence: composite_valence,
48
+ stressed: stressed?
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ def drift(value, target, rate)
55
+ if value > target
56
+ (value - rate).clamp(target, 1.0)
57
+ else
58
+ (value + rate).clamp(-1.0, target)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SomaticMarker
6
+ module Helpers
7
+ module Constants
8
+ MAX_MARKERS = 500
9
+ MAX_OPTIONS_PER_DECISION = 20
10
+ MAX_DECISION_HISTORY = 200
11
+ MARKER_DECAY = 0.01
12
+ MARKER_STRENGTH_FLOOR = 0.05
13
+ MARKER_ALPHA = 0.12
14
+ POSITIVE_BIAS = 0.6
15
+ NEGATIVE_BIAS = -0.6
16
+ DEFAULT_VALENCE = 0.0
17
+ REINFORCEMENT_BOOST = 0.15
18
+ PUNISHMENT_PENALTY = 0.2
19
+ BODY_STATE_DECAY = 0.03
20
+ MAX_BODY_STATES = 50
21
+
22
+ VALENCE_LABELS = {
23
+ (-1.0..-0.6) => :strongly_negative,
24
+ (-0.6..-0.2) => :negative,
25
+ (-0.2..0.2) => :neutral,
26
+ (0.2..0.6) => :positive,
27
+ (0.6..1.0) => :strongly_positive
28
+ }.freeze
29
+
30
+ SIGNAL_LABELS = {
31
+ approach: 'somatic signal favoring action',
32
+ avoid: 'somatic signal against action',
33
+ neutral: 'no clear somatic signal'
34
+ }.freeze
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SomaticMarker
6
+ module Helpers
7
+ class MarkerStore
8
+ include Constants
9
+
10
+ attr_reader :markers, :body_state
11
+
12
+ def initialize
13
+ @markers = {}
14
+ @body_state = BodyState.new
15
+ @decision_history = []
16
+ @next_id = 1
17
+ end
18
+
19
+ def register_marker(action:, domain:, valence:, source: :experience)
20
+ evict_weakest if @markers.size >= MAX_MARKERS
21
+
22
+ id = generate_id
23
+ marker = SomaticMarker.new(
24
+ id: id,
25
+ action: action,
26
+ domain: domain,
27
+ valence: valence,
28
+ source: source
29
+ )
30
+ @markers[id] = marker
31
+ marker
32
+ end
33
+
34
+ def evaluate_option(action:, domain:)
35
+ relevant = markers_for(action: action, domain: domain)
36
+ return { signal: :neutral, valence: DEFAULT_VALENCE, marker_count: 0 } if relevant.empty?
37
+
38
+ weighted_valence = compute_weighted_valence(relevant)
39
+ signal = valence_to_signal(weighted_valence)
40
+ { signal: signal, valence: weighted_valence, marker_count: relevant.size }
41
+ end
42
+
43
+ def decide(options:, domain:)
44
+ capped = options.first(MAX_OPTIONS_PER_DECISION)
45
+ ranked = capped.map do |option|
46
+ eval_result = evaluate_option(action: option, domain: domain)
47
+ {
48
+ action: option,
49
+ signal: eval_result[:signal],
50
+ valence: eval_result[:valence],
51
+ marker_count: eval_result[:marker_count]
52
+ }
53
+ end
54
+
55
+ ranked.sort_by! { |r| -r[:valence] }
56
+
57
+ body_contribution = body_influence
58
+
59
+ record = {
60
+ options: capped,
61
+ ranked: ranked,
62
+ domain: domain,
63
+ body_contribution: body_contribution,
64
+ decided_at: Time.now.utc
65
+ }
66
+
67
+ @decision_history.shift while @decision_history.size >= MAX_DECISION_HISTORY
68
+ @decision_history << record
69
+
70
+ record
71
+ end
72
+
73
+ def reinforce_marker(marker_id:, outcome_valence:)
74
+ marker = @markers[marker_id]
75
+ return nil unless marker
76
+
77
+ marker.reinforce(outcome_valence: outcome_valence)
78
+ marker
79
+ end
80
+
81
+ def update_body_state(arousal: nil, tension: nil, comfort: nil, gut_signal: nil)
82
+ @body_state.update(
83
+ arousal: arousal,
84
+ tension: tension,
85
+ comfort: comfort,
86
+ gut_signal: gut_signal
87
+ )
88
+ @body_state
89
+ end
90
+
91
+ def markers_for(action:, domain:)
92
+ @markers.values.select { |m| m.action == action && m.domain == domain }
93
+ end
94
+
95
+ def body_influence
96
+ {
97
+ composite_valence: @body_state.composite_valence,
98
+ stressed: @body_state.stressed?
99
+ }
100
+ end
101
+
102
+ def decay_all
103
+ @markers.each_value(&:decay)
104
+ faded_ids = @markers.select { |_id, m| m.faded? }.keys
105
+ faded_ids.each { |id| @markers.delete(id) }
106
+ @body_state.decay
107
+ { markers_decayed: @markers.size, markers_removed: faded_ids.size }
108
+ end
109
+
110
+ def decision_history(limit: 10)
111
+ @decision_history.last(limit)
112
+ end
113
+
114
+ def to_h
115
+ {
116
+ marker_count: @markers.size,
117
+ decision_count: @decision_history.size,
118
+ body_state: @body_state.to_h,
119
+ stressed: @body_state.stressed?
120
+ }
121
+ end
122
+
123
+ private
124
+
125
+ def compute_weighted_valence(relevant)
126
+ total_strength = relevant.sum(&:strength)
127
+ return DEFAULT_VALENCE unless total_strength.positive?
128
+
129
+ relevant.sum { |m| m.valence * m.strength } / total_strength
130
+ end
131
+
132
+ def valence_to_signal(weighted_valence)
133
+ if weighted_valence > POSITIVE_BIAS
134
+ :approach
135
+ elsif weighted_valence < NEGATIVE_BIAS
136
+ :avoid
137
+ else
138
+ :neutral
139
+ end
140
+ end
141
+
142
+ def generate_id
143
+ id = "sm_#{@next_id}"
144
+ @next_id += 1
145
+ id
146
+ end
147
+
148
+ def evict_weakest
149
+ weakest_id = @markers.min_by { |_id, m| m.strength }&.first
150
+ @markers.delete(weakest_id) if weakest_id
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SomaticMarker
6
+ module Helpers
7
+ class SomaticMarker
8
+ include Constants
9
+
10
+ attr_reader :id, :action, :domain, :valence, :strength, :source, :created_at
11
+
12
+ def initialize(id:, action:, domain:, valence:, strength: 0.5, source: :experience)
13
+ @id = id
14
+ @action = action
15
+ @domain = domain
16
+ @valence = valence.clamp(-1.0, 1.0)
17
+ @strength = strength.clamp(0.0, 1.0)
18
+ @source = source
19
+ @created_at = Time.now.utc
20
+ end
21
+
22
+ def signal
23
+ if @valence > POSITIVE_BIAS
24
+ :approach
25
+ elsif @valence < NEGATIVE_BIAS
26
+ :avoid
27
+ else
28
+ :neutral
29
+ end
30
+ end
31
+
32
+ def reinforce(outcome_valence:)
33
+ @valence = (MARKER_ALPHA * outcome_valence) + ((1.0 - MARKER_ALPHA) * @valence)
34
+ @valence = @valence.clamp(-1.0, 1.0)
35
+ @strength = (@strength + REINFORCEMENT_BOOST).clamp(0.0, 1.0)
36
+ end
37
+
38
+ def decay
39
+ @strength = (@strength - MARKER_DECAY).clamp(0.0, 1.0)
40
+ end
41
+
42
+ def faded?
43
+ @strength <= MARKER_STRENGTH_FLOOR
44
+ end
45
+
46
+ def valence_label
47
+ VALENCE_LABELS.each do |range, label|
48
+ return label if range.cover?(@valence)
49
+ end
50
+ :neutral
51
+ end
52
+
53
+ def to_h
54
+ {
55
+ id: @id,
56
+ action: @action,
57
+ domain: @domain,
58
+ valence: @valence,
59
+ strength: @strength,
60
+ source: @source,
61
+ signal: signal,
62
+ label: valence_label,
63
+ created_at: @created_at
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SomaticMarker
6
+ module Runners
7
+ module SomaticMarker
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def register_marker(action:, domain:, valence:, source: :experience, **)
12
+ marker = store.register_marker(action: action, domain: domain, valence: valence, source: source)
13
+ Legion::Logging.debug "[somatic_marker] register: action=#{action} domain=#{domain} " \
14
+ "valence=#{valence.round(3)} source=#{source} id=#{marker.id}"
15
+ { success: true, marker: marker.to_h }
16
+ rescue StandardError => e
17
+ Legion::Logging.error "[somatic_marker] register failed: #{e.message}"
18
+ { success: false, error: e.message }
19
+ end
20
+
21
+ def evaluate_option(action:, domain:, **)
22
+ result = store.evaluate_option(action: action, domain: domain)
23
+ Legion::Logging.debug "[somatic_marker] evaluate: action=#{action} domain=#{domain} " \
24
+ "signal=#{result[:signal]} valence=#{result[:valence].round(3)}"
25
+ { success: true }.merge(result)
26
+ rescue StandardError => e
27
+ Legion::Logging.error "[somatic_marker] evaluate failed: #{e.message}"
28
+ { success: false, error: e.message }
29
+ end
30
+
31
+ def make_decision(options:, domain:, **)
32
+ result = store.decide(options: options, domain: domain)
33
+ Legion::Logging.debug "[somatic_marker] decide: domain=#{domain} options=#{options.size} " \
34
+ "top=#{result[:ranked].first&.fetch(:action)}"
35
+ { success: true, decision: result }
36
+ rescue StandardError => e
37
+ Legion::Logging.error "[somatic_marker] decide failed: #{e.message}"
38
+ { success: false, error: e.message }
39
+ end
40
+
41
+ def reinforce(marker_id:, outcome_valence:, **)
42
+ marker = store.reinforce_marker(marker_id: marker_id, outcome_valence: outcome_valence)
43
+ unless marker
44
+ Legion::Logging.debug "[somatic_marker] reinforce: marker_id=#{marker_id} not found"
45
+ return { success: false, error: 'marker not found' }
46
+ end
47
+
48
+ Legion::Logging.debug "[somatic_marker] reinforce: id=#{marker_id} " \
49
+ "outcome=#{outcome_valence.round(3)} new_valence=#{marker.valence.round(3)}"
50
+ { success: true, marker: marker.to_h }
51
+ rescue StandardError => e
52
+ Legion::Logging.error "[somatic_marker] reinforce failed: #{e.message}"
53
+ { success: false, error: e.message }
54
+ end
55
+
56
+ def update_body(arousal: nil, tension: nil, comfort: nil, gut_signal: nil, **)
57
+ state = store.update_body_state(
58
+ arousal: arousal,
59
+ tension: tension,
60
+ comfort: comfort,
61
+ gut_signal: gut_signal
62
+ )
63
+ Legion::Logging.debug "[somatic_marker] body_update: composite=#{state.composite_valence.round(3)} " \
64
+ "stressed=#{state.stressed?}"
65
+ { success: true, body_state: state.to_h }
66
+ rescue StandardError => e
67
+ Legion::Logging.error "[somatic_marker] body update failed: #{e.message}"
68
+ { success: false, error: e.message }
69
+ end
70
+
71
+ def body_state(**)
72
+ state = store.body_state
73
+ Legion::Logging.debug "[somatic_marker] body_state: composite=#{state.composite_valence.round(3)}"
74
+ { success: true, body_state: state.to_h }
75
+ rescue StandardError => e
76
+ Legion::Logging.error "[somatic_marker] body_state failed: #{e.message}"
77
+ { success: false, error: e.message }
78
+ end
79
+
80
+ def markers_for_action(action:, domain:, **)
81
+ markers = store.markers_for(action: action, domain: domain)
82
+ Legion::Logging.debug "[somatic_marker] markers_for: action=#{action} domain=#{domain} " \
83
+ "count=#{markers.size}"
84
+ { success: true, markers: markers.map(&:to_h), count: markers.size }
85
+ rescue StandardError => e
86
+ Legion::Logging.error "[somatic_marker] markers_for_action failed: #{e.message}"
87
+ { success: false, error: e.message }
88
+ end
89
+
90
+ def recent_decisions(limit: 10, **)
91
+ decisions = store.decision_history(limit: limit)
92
+ Legion::Logging.debug "[somatic_marker] recent_decisions: limit=#{limit} count=#{decisions.size}"
93
+ { success: true, decisions: decisions, count: decisions.size }
94
+ rescue StandardError => e
95
+ Legion::Logging.error "[somatic_marker] recent_decisions failed: #{e.message}"
96
+ { success: false, error: e.message }
97
+ end
98
+
99
+ def update_somatic_markers(**)
100
+ result = store.decay_all
101
+ Legion::Logging.debug "[somatic_marker] decay: remaining=#{result[:markers_decayed]} " \
102
+ "removed=#{result[:markers_removed]}"
103
+ { success: true }.merge(result)
104
+ rescue StandardError => e
105
+ Legion::Logging.error "[somatic_marker] decay failed: #{e.message}"
106
+ { success: false, error: e.message }
107
+ end
108
+
109
+ def somatic_marker_stats(**)
110
+ stats = store.to_h
111
+ Legion::Logging.debug "[somatic_marker] stats: markers=#{stats[:marker_count]} " \
112
+ "decisions=#{stats[:decision_count]}"
113
+ { success: true }.merge(stats)
114
+ rescue StandardError => e
115
+ Legion::Logging.error "[somatic_marker] stats failed: #{e.message}"
116
+ { success: false, error: e.message }
117
+ end
118
+
119
+ private
120
+
121
+ def store
122
+ @store ||= Helpers::MarkerStore.new
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SomaticMarker
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end