lex-agentic-self 0.1.9 → 0.1.11

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.
Files changed (23) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/Gemfile +2 -0
  4. data/lex-agentic-self.gemspec +3 -3
  5. data/lib/legion/extensions/agentic/self/identity/runners/entra.rb +5 -2
  6. data/lib/legion/extensions/agentic/self/relationship_arc/client.rb +20 -0
  7. data/lib/legion/extensions/agentic/self/relationship_arc/helpers/arc_engine.rb +135 -0
  8. data/lib/legion/extensions/agentic/self/relationship_arc/helpers/constants.rb +39 -0
  9. data/lib/legion/extensions/agentic/self/relationship_arc/helpers/milestone.rb +42 -0
  10. data/lib/legion/extensions/agentic/self/relationship_arc/runners/relationship_arc.rb +80 -0
  11. data/lib/legion/extensions/agentic/self/relationship_arc/version.rb +13 -0
  12. data/lib/legion/extensions/agentic/self/relationship_arc.rb +19 -0
  13. data/lib/legion/extensions/agentic/self/self_talk/runners/self_talk.rb +43 -5
  14. data/lib/legion/extensions/agentic/self/version.rb +1 -1
  15. data/lib/legion/extensions/agentic/self.rb +1 -0
  16. data/spec/legion/extensions/agentic/self/identity/runners/entra_spec.rb +53 -0
  17. data/spec/legion/extensions/agentic/self/relationship_arc/helpers/arc_engine_spec.rb +165 -0
  18. data/spec/legion/extensions/agentic/self/relationship_arc/helpers/constants_spec.rb +31 -0
  19. data/spec/legion/extensions/agentic/self/relationship_arc/helpers/milestone_spec.rb +46 -0
  20. data/spec/legion/extensions/agentic/self/relationship_arc/runners/relationship_arc_spec.rb +64 -0
  21. data/spec/legion/extensions/agentic/self/self_talk/runners/self_talk_llm_spec.rb +2 -2
  22. data/spec/legion/extensions/agentic/self/self_talk/runners/self_talk_spec.rb +60 -0
  23. metadata +24 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 478de6acedf6c72ba3a5c715bc57f3124505486720b473b2db536d4abe48c58c
4
- data.tar.gz: 8846a168b7d882d7e981a3d87eb87565e25b10ac24530221748382fdda1128cb
3
+ metadata.gz: aa196a32fb12a9051c899a77c904db1b019bcf20fa722522f4560aa06325c1c5
4
+ data.tar.gz: 21ea04fbbd1a0bb7ccbaf1baeb93c062c036818cecd316f907d82ba4607fc640
5
5
  SHA512:
6
- metadata.gz: 20df44e864e415b127e1f26a0d28bb0ed19c6589b53a40afbace9587c1166be962b6b8305e74c7ad4d4ea6b41eb9091f90328cbc3e23c3ab44ab9172cced706f
7
- data.tar.gz: c768c0cc1794a6502e37d2c6524feaec104c8d47116e654f7d5dd19330768130212516cc074011b97b4e8e97f21c33931242e4cebf9ce0fba92064919bf7396d
6
+ metadata.gz: 2e5f6191d0093ad34c915a5368d71b1457f9774e393fff5f9d0424784ca38c250ce3c3ae3c3be6c0fc4c9b4a43ac8b2148bd3917b3ed37e102181aa2beae1e1a
7
+ data.tar.gz: 2bc178f3bfc1001ccc64af277a3e9fac685fea350964107883b6d001a4fe8da31fd3287b18e3a4b6838bd293798737c1c8eeb9fac3c77d44010be734e97279d0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.11] - 2026-03-31
4
+
5
+ ### Fixed
6
+ - `ArcEngine#to_h` now returns the last computed `relationship_health` score instead of always nil; `relationship_health()` caches result in `@last_health`
7
+ - `ArcEngine#arc_state_hash` now includes `milestones_today` key (milestones with `created_at` matching today in local time)
8
+ - `SelfTalk::Runners::SelfTalk#stub_turn_content` replaced by `mechanical_turn_content` backed by `VOICE_BANK` — produces real, meaningful content for critic/advocate/explorer/pragmatist voices; unknown types fall back to `VOICE_BANK_GENERIC`
9
+ - `SelfTalk::Runners::SelfTalk#generate_summary_for_dialogue` replaces static "Dialogue concluded" fallback with `mechanical_summary` — includes turn count, voices, and dominant position
10
+ - `Identity::Runners::Entra#rotate_client_secret` now emits a `Legion::Logging.warn` when `rotation_enabled: true` but Graph API rotation is not yet implemented, and returns `action_required` with instructions instead of a silent error
11
+
12
+ ## [0.1.10] - 2026-03-31
13
+
14
+ ### Added
15
+ - RelationshipArc sub-module for Phase C relational intelligence
16
+ - Constants: chapters, milestone types, health weights, chapter thresholds
17
+ - Milestone: typed data class with UUID, significance clamping, serialization
18
+ - ArcEngine: chapter progression, milestone tracking, relationship health, Apollo Local persistence
19
+ - RelationshipArc runner: record_milestone, update_arc, arc_stats with NarrativeIdentity episode stamping
20
+
3
21
  ## [0.1.9] - 2026-03-31
4
22
 
5
23
  ### Added
data/Gemfile CHANGED
@@ -3,3 +3,5 @@
3
3
  source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
+
7
+ gem 'rubocop-legion'
@@ -34,7 +34,7 @@ Gem::Specification.new do |spec|
34
34
 
35
35
  spec.add_development_dependency 'faraday', '~> 2.0'
36
36
  spec.add_development_dependency 'rspec', '~> 3.13'
37
- spec.add_development_dependency 'rubocop', '~> 1.60'
38
- spec.add_development_dependency 'rubocop-legion', '~> 0.1'
39
- spec.add_development_dependency 'rubocop-rspec', '~> 2.26'
37
+ spec.add_development_dependency 'rubocop'
38
+ spec.add_development_dependency 'rubocop-legion'
39
+ spec.add_development_dependency 'rubocop-rspec'
40
40
  end
@@ -293,8 +293,11 @@ module Legion
293
293
 
294
294
  return { rotated: false, worker_id: worker_id, dry_run: true, would_rotate: true } if dry_run
295
295
 
296
- # Graph API rotation would go here when permission is granted
297
- { rotated: false, worker_id: worker_id, error: 'graph_api_rotation_not_implemented' }
296
+ # Graph API rotation is blocked on Azure Application.ReadWrite.All permission.
297
+ # Emit a loud warning so callers are never silently misled.
298
+ Legion::Logging.warn "[identity:entra] Client secret rotation is enabled but Graph API rotation is not yet implemented. Worker: #{worker_id}"
299
+ { rotated: false, worker_id: worker_id, error: 'graph_api_rotation_not_implemented',
300
+ action_required: 'Manual rotation needed — Graph API write permission not yet granted' }
298
301
  end
299
302
 
300
303
  def credential_refresh_cycle(**)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/milestone'
5
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/arc_engine'
6
+ require 'legion/extensions/agentic/self/relationship_arc/runners/relationship_arc'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Agentic
11
+ module Self
12
+ module RelationshipArc
13
+ class Client
14
+ include Runners::RelationshipArc
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Self
7
+ module RelationshipArc
8
+ module Helpers
9
+ class ArcEngine
10
+ attr_reader :agent_id, :current_chapter, :milestones
11
+
12
+ BOND_STAGE_ORDER = %i[initial forming established deep].freeze
13
+ private_constant :BOND_STAGE_ORDER
14
+
15
+ def initialize(agent_id:)
16
+ @agent_id = agent_id
17
+ @current_chapter = :formative
18
+ @milestones = []
19
+ @dirty = false
20
+ end
21
+
22
+ def add_milestone(type:, description:, significance:, **)
23
+ ms = Milestone.new(type: type, description: description, significance: significance)
24
+ @milestones << ms
25
+ @milestones.shift while @milestones.size > Constants::MAX_MILESTONES
26
+ @dirty = true
27
+ ms
28
+ end
29
+
30
+ def update_chapter!(bond_stage: :initial, **)
31
+ new_chapter = derive_chapter(bond_stage)
32
+ return if Constants::CHAPTERS.index(new_chapter) <= Constants::CHAPTERS.index(@current_chapter)
33
+
34
+ @current_chapter = new_chapter
35
+ @dirty = true
36
+ end
37
+
38
+ def relationship_health(attachment_strength: 0.0, reciprocity_balance: 0.5,
39
+ communication_consistency: 0.5, **)
40
+ w = Constants::HEALTH_WEIGHTS
41
+ score = (attachment_strength.to_f * w[:attachment_strength]) +
42
+ (reciprocity_balance.to_f * w[:reciprocity_balance]) +
43
+ (communication_consistency.to_f * w[:communication_consistency])
44
+ @last_health = score.clamp(0.0, 1.0)
45
+ end
46
+
47
+ def dirty?
48
+ @dirty
49
+ end
50
+
51
+ def mark_clean!
52
+ @dirty = false
53
+ self
54
+ end
55
+
56
+ def to_apollo_entries
57
+ tags = Constants::TAG_PREFIX.dup + [@agent_id]
58
+ tags << 'partner' if partner?(@agent_id)
59
+ [{ content: serialize(arc_state_hash), tags: tags }]
60
+ end
61
+
62
+ def from_apollo(store:)
63
+ result = store.query(text: 'relationship_arc', tags: Constants::TAG_PREFIX + [@agent_id])
64
+ return false unless result[:success] && result[:results]&.any?
65
+
66
+ parsed = deserialize(result[:results].first[:content])
67
+ return false unless parsed
68
+
69
+ @current_chapter = parsed[:current_chapter]&.to_sym || :formative
70
+ @milestones = (parsed[:milestones] || []).map { |mh| Milestone.from_h(mh.transform_keys(&:to_sym)) }
71
+ true
72
+ rescue StandardError => e
73
+ warn "[arc_engine] from_apollo error: #{e.message}"
74
+ false
75
+ end
76
+
77
+ def to_h
78
+ { agent_id: @agent_id, current_chapter: @current_chapter,
79
+ milestones: @milestones.map(&:to_h),
80
+ relationship_health: @last_health, milestone_count: @milestones.size }
81
+ end
82
+
83
+ private
84
+
85
+ def arc_state_hash
86
+ { agent_id: @agent_id, current_chapter: @current_chapter,
87
+ milestones: @milestones.map(&:to_h),
88
+ milestones_today: @milestones.select { |m| milestone_today?(m) }.map(&:to_h) }
89
+ end
90
+
91
+ def milestone_today?(milestone)
92
+ ts = milestone.respond_to?(:created_at) ? milestone.created_at : milestone[:created_at]
93
+ return false unless ts
94
+
95
+ today = ::Time.now
96
+ t = ts.is_a?(::Time) ? ts.localtime : ::Time.parse(ts.to_s)
97
+ t.year == today.year && t.mon == today.mon && t.mday == today.mday
98
+ rescue StandardError => e
99
+ warn "[arc_engine] milestone_today? error: #{e.message}"
100
+ false
101
+ end
102
+
103
+ def derive_chapter(bond_stage)
104
+ Constants::CHAPTER_THRESHOLDS.each_key.reverse_each do |chapter|
105
+ threshold = Constants::CHAPTER_THRESHOLDS[chapter]
106
+ stage_idx = BOND_STAGE_ORDER.index(threshold[:stage]) || 0
107
+ current_stage_idx = BOND_STAGE_ORDER.index(bond_stage) || 0
108
+
109
+ return chapter if @milestones.size >= threshold[:milestones] &&
110
+ current_stage_idx >= stage_idx
111
+ end
112
+ :formative
113
+ end
114
+
115
+ def partner?(agent_id)
116
+ defined?(Legion::Gaia::BondRegistry) && Legion::Gaia::BondRegistry.partner?(agent_id)
117
+ end
118
+
119
+ def serialize(hash)
120
+ defined?(Legion::JSON) ? Legion::JSON.dump(hash) : ::JSON.dump(hash)
121
+ end
122
+
123
+ def deserialize(content)
124
+ parsed = defined?(Legion::JSON) ? Legion::JSON.parse(content) : ::JSON.parse(content, symbolize_names: true)
125
+ parsed.is_a?(Hash) ? parsed.transform_keys(&:to_sym) : nil
126
+ rescue StandardError => _e
127
+ nil
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Self
7
+ module RelationshipArc
8
+ module Helpers
9
+ module Constants
10
+ CHAPTERS = %i[formative developing established deepening].freeze
11
+
12
+ MILESTONE_TYPES = %i[
13
+ first_interaction first_direct_address stage_transition
14
+ prediction_accuracy communication_shift absence_return
15
+ ].freeze
16
+
17
+ HEALTH_WEIGHTS = {
18
+ attachment_strength: 0.4,
19
+ reciprocity_balance: 0.3,
20
+ communication_consistency: 0.3
21
+ }.freeze
22
+
23
+ MAX_MILESTONES = 200
24
+
25
+ CHAPTER_THRESHOLDS = {
26
+ developing: { milestones: 3, stage: :forming },
27
+ established: { milestones: 10, stage: :established },
28
+ deepening: { milestones: 25, stage: :deep }
29
+ }.freeze
30
+
31
+ TAG_PREFIX = %w[bond relationship_arc].freeze
32
+ MILESTONE_TAG_PREFIX = %w[bond milestone].freeze
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Agentic
8
+ module Self
9
+ module RelationshipArc
10
+ module Helpers
11
+ class Milestone
12
+ attr_reader :id, :type, :description, :significance, :created_at
13
+
14
+ def initialize(type:, description:, significance:, id: nil, created_at: nil)
15
+ @id = id || SecureRandom.uuid
16
+ @type = type.to_sym
17
+ @description = description
18
+ @significance = significance.to_f.clamp(0.0, 1.0)
19
+ @created_at = created_at || Time.now.utc
20
+ end
21
+
22
+ def to_h
23
+ { id: @id, type: @type, description: @description,
24
+ significance: @significance, created_at: @created_at.iso8601 }
25
+ end
26
+
27
+ def self.from_h(hash)
28
+ new(
29
+ id: hash[:id],
30
+ type: hash[:type],
31
+ description: hash[:description],
32
+ significance: hash[:significance],
33
+ created_at: hash[:created_at] ? Time.parse(hash[:created_at].to_s) : nil
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Self
7
+ module RelationshipArc
8
+ module Runners
9
+ module RelationshipArc
10
+ include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
+
13
+ def record_milestone(agent_id:, type:, description:, significance:, **)
14
+ unless Helpers::Constants::MILESTONE_TYPES.include?(type.to_sym)
15
+ return { success: false,
16
+ error: "unknown type: #{type}" }
17
+ end
18
+
19
+ engine = arc_engine_for(agent_id)
20
+ ms = engine.add_milestone(type: type, description: description, significance: significance)
21
+
22
+ stamp_narrative_episode(ms)
23
+
24
+ { success: true, milestone: ms.to_h }
25
+ rescue StandardError => e
26
+ { success: false, error: e.message }
27
+ end
28
+
29
+ def update_arc(agent_id:, attachment_state: {}, **)
30
+ engine = arc_engine_for(agent_id)
31
+ engine.update_chapter!(bond_stage: attachment_state[:bond_stage] || :initial)
32
+
33
+ { success: true, current_chapter: engine.current_chapter,
34
+ milestone_count: engine.milestones.size }
35
+ rescue StandardError => e
36
+ { success: false, error: e.message }
37
+ end
38
+
39
+ def arc_stats(agent_id:, **)
40
+ engine = arc_engine_for(agent_id)
41
+ engine.to_h
42
+ end
43
+
44
+ private
45
+
46
+ def arc_engine_for(agent_id)
47
+ @arc_engines ||= {}
48
+ @arc_engines[agent_id.to_s] ||= Helpers::ArcEngine.new(agent_id: agent_id.to_s)
49
+ end
50
+
51
+ def stamp_narrative_episode(milestone)
52
+ narrator = resolve_narrative_identity
53
+ return unless narrator
54
+
55
+ narrator.record_episode(
56
+ content: milestone.description,
57
+ episode_type: :relationship,
58
+ emotional_valence: 0.3,
59
+ significance: milestone.significance,
60
+ domain: :relationship,
61
+ tags: ['partner', 'milestone', milestone.type.to_s]
62
+ )
63
+ rescue StandardError => e
64
+ warn "[relationship_arc] narrative stamp failed: #{e.message}"
65
+ end
66
+
67
+ def resolve_narrative_identity
68
+ return nil unless defined?(Legion::Extensions::Agentic::Self::NarrativeIdentity::Client)
69
+
70
+ @narrative_client ||= Legion::Extensions::Agentic::Self::NarrativeIdentity::Client.new
71
+ rescue StandardError => _e
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Self
7
+ module RelationshipArc
8
+ VERSION = '0.1.0'
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'relationship_arc/version'
4
+ require_relative 'relationship_arc/helpers/constants'
5
+ require_relative 'relationship_arc/helpers/milestone'
6
+ require_relative 'relationship_arc/helpers/arc_engine'
7
+ require_relative 'relationship_arc/runners/relationship_arc'
8
+ require_relative 'relationship_arc/client'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Agentic
13
+ module Self
14
+ module RelationshipArc
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -104,6 +104,35 @@ module Legion
104
104
  { decayed: decayed, voices: voice_list }
105
105
  end
106
106
 
107
+ VOICE_BANK = {
108
+ critic: [
109
+ { content: 'What could go wrong with this approach?', position: :challenge },
110
+ { content: 'Are we overlooking any risks here?', position: :challenge },
111
+ { content: 'This needs more careful consideration.', position: :caution }
112
+ ],
113
+ advocate: [
114
+ { content: 'This aligns with our core values.', position: :support },
115
+ { content: 'The potential benefits outweigh the risks.', position: :support },
116
+ { content: 'We should move forward with this.', position: :affirm }
117
+ ],
118
+ explorer: [
119
+ { content: 'What alternatives have we not considered?', position: :explore },
120
+ { content: 'There may be an unconventional approach here.', position: :explore },
121
+ { content: 'Let us examine this from another angle.', position: :clarify }
122
+ ],
123
+ pragmatist: [
124
+ { content: 'What is the simplest path forward?', position: :simplify },
125
+ { content: 'Focus on what is actionable now.', position: :prioritize },
126
+ { content: 'We need concrete next steps.', position: :clarify }
127
+ ]
128
+ }.freeze
129
+
130
+ VOICE_BANK_GENERIC = [
131
+ { content: 'Let us think this through carefully.', position: :clarify },
132
+ { content: 'More reflection is needed on this topic.', position: :clarify },
133
+ { content: 'What do we know for certain here?', position: :clarify }
134
+ ].freeze
135
+
107
136
  private
108
137
 
109
138
  def engine
@@ -124,7 +153,7 @@ module Legion
124
153
  )
125
154
  return [llm_result, :llm] if llm_result
126
155
  end
127
- [stub_turn_content(voice_data.voice_type, dialogue_data.topic), :mechanical]
156
+ [mechanical_turn_content(voice_data.voice_type, dialogue_data.topic), :mechanical]
128
157
  end
129
158
 
130
159
  def build_prior_turns(dialogue_data)
@@ -135,13 +164,14 @@ module Legion
135
164
  end
136
165
  end
137
166
 
138
- def stub_turn_content(voice_type, topic)
139
- { content: "[#{voice_type} perspective on #{topic}]", position: :clarify }
167
+ def mechanical_turn_content(voice_type, _topic)
168
+ bank = VOICE_BANK.fetch(voice_type.to_sym, VOICE_BANK_GENERIC)
169
+ bank.sample
140
170
  end
141
171
 
142
172
  def generate_summary_for_dialogue(dialogue_id)
143
173
  dialogue_data = engine.dialogues[dialogue_id]
144
- return 'Dialogue concluded' unless dialogue_data
174
+ return mechanical_summary(nil) unless dialogue_data
145
175
 
146
176
  if Helpers::LlmEnhancer.available?
147
177
  turns = dialogue_data.turns.map do |t|
@@ -161,7 +191,15 @@ module Legion
161
191
  return llm_result[:summary] if llm_result
162
192
  end
163
193
 
164
- 'Dialogue concluded'
194
+ mechanical_summary(dialogue_data)
195
+ end
196
+
197
+ def mechanical_summary(dialogue_data)
198
+ turns = dialogue_data&.turns || []
199
+ voices = turns.filter_map { |t| t.respond_to?(:voice_id) ? engine.voices[t.voice_id]&.name || t.voice_id : t[:voice_id] }.uniq
200
+ positions = turns.filter_map { |t| t.respond_to?(:position) ? t.position : t[:position] }.tally
201
+ dominant = positions.max_by { |_, count| count }&.first
202
+ "#{turns.size} turns across #{voices.join(', ')} voices. Dominant position: #{dominant || 'none'}."
165
203
  end
166
204
  end
167
205
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Self
7
- VERSION = '0.1.9'
7
+ VERSION = '0.1.11'
8
8
  end
9
9
  end
10
10
  end
@@ -17,6 +17,7 @@ require_relative 'self/agency'
17
17
  require_relative 'self/reflection'
18
18
  require_relative 'self/anosognosia'
19
19
  require_relative 'self/default_mode_network'
20
+ require_relative 'self/relationship_arc'
20
21
 
21
22
  module Legion
22
23
  module Extensions
@@ -338,6 +338,59 @@ RSpec.describe Legion::Extensions::Agentic::Self::Identity::Runners::Entra do
338
338
  expect(result[:action_required]).to be false
339
339
  expect(result[:days_remaining]).to be > 30
340
340
  end
341
+
342
+ context 'when rotation_enabled is true and secret is expiring (fix 5)' do
343
+ before do
344
+ stub_const('Legion::Settings', Class.new do
345
+ def self.dig(*keys)
346
+ map = {
347
+ %i[identity entra rotation_enabled] => true,
348
+ %i[identity entra rotation_buffer_days] => 30
349
+ }
350
+ map[keys]
351
+ end
352
+
353
+ def self.[](_key) = {}
354
+ end)
355
+ end
356
+
357
+ it 'emits a warning log when graph api rotation is not implemented' do
358
+ vault_mod = Module.new do
359
+ def self.read_client_secret(**)
360
+ { client_secret: 'val', client_secret_expires_at: (Time.now + (86_400 * 10)).iso8601 }
361
+ end
362
+ end
363
+ stub_const('Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets', vault_mod)
364
+
365
+ warned = false
366
+ allow(Legion::Logging).to receive(:warn) do |msg|
367
+ warned = true if msg.include?('graph_api_rotation_not_implemented') ||
368
+ msg.include?('Graph API rotation is not yet implemented')
369
+ end
370
+
371
+ result = client.rotate_client_secret(worker_id: 'w1')
372
+
373
+ expect(warned).to be true
374
+ expect(result[:rotated]).to be false
375
+ expect(result[:error]).to eq('graph_api_rotation_not_implemented')
376
+ expect(result[:action_required]).to include('Manual rotation needed')
377
+ end
378
+
379
+ it 'returns dry_run result without warning when dry_run: true' do
380
+ vault_mod = Module.new do
381
+ def self.read_client_secret(**)
382
+ { client_secret: 'val', client_secret_expires_at: (Time.now + (86_400 * 10)).iso8601 }
383
+ end
384
+ end
385
+ stub_const('Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets', vault_mod)
386
+
387
+ expect(Legion::Logging).not_to receive(:warn).with(a_string_including('not yet implemented'))
388
+
389
+ result = client.rotate_client_secret(worker_id: 'w1', dry_run: true)
390
+ expect(result[:dry_run]).to be true
391
+ expect(result[:would_rotate]).to be true
392
+ end
393
+ end
341
394
  end
342
395
 
343
396
  # ---------------------------------------------------------------------------
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
5
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/milestone'
6
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/arc_engine'
7
+
8
+ RSpec.describe Legion::Extensions::Agentic::Self::RelationshipArc::Helpers::ArcEngine do
9
+ subject(:engine) { described_class.new(agent_id: 'partner-1') }
10
+
11
+ describe '#initialize' do
12
+ it 'starts in formative chapter' do
13
+ expect(engine.current_chapter).to eq(:formative)
14
+ end
15
+
16
+ it 'starts with empty milestones' do
17
+ expect(engine.milestones).to be_empty
18
+ end
19
+ end
20
+
21
+ describe '#add_milestone' do
22
+ it 'adds a milestone' do
23
+ engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8)
24
+ expect(engine.milestones.size).to eq(1)
25
+ end
26
+
27
+ it 'returns the milestone' do
28
+ ms = engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8)
29
+ expect(ms).to be_a(Legion::Extensions::Agentic::Self::RelationshipArc::Helpers::Milestone)
30
+ end
31
+
32
+ it 'marks dirty' do
33
+ engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8)
34
+ expect(engine).to be_dirty
35
+ end
36
+
37
+ it 'caps at MAX_MILESTONES' do
38
+ 201.times { |i| engine.add_milestone(type: :first_interaction, description: "ms #{i}", significance: 0.1) }
39
+ expect(engine.milestones.size).to eq(200)
40
+ end
41
+ end
42
+
43
+ describe '#update_chapter!' do
44
+ it 'transitions to developing after enough milestones' do
45
+ 4.times { engine.add_milestone(type: :first_interaction, description: 'x', significance: 0.5) }
46
+ engine.update_chapter!(bond_stage: :forming)
47
+ expect(engine.current_chapter).to eq(:developing)
48
+ end
49
+
50
+ it 'never regresses' do
51
+ engine.instance_variable_set(:@current_chapter, :established)
52
+ engine.update_chapter!(bond_stage: :initial)
53
+ expect(engine.current_chapter).to eq(:established)
54
+ end
55
+ end
56
+
57
+ describe '#relationship_health' do
58
+ it 'computes weighted health score' do
59
+ health = engine.relationship_health(
60
+ attachment_strength: 0.8,
61
+ reciprocity_balance: 0.6,
62
+ communication_consistency: 0.7
63
+ )
64
+ expected = (0.8 * 0.4) + (0.6 * 0.3) + (0.7 * 0.3)
65
+ expect(health).to be_within(0.01).of(expected)
66
+ end
67
+
68
+ it 'clamps to 0.0..1.0' do
69
+ health = engine.relationship_health(
70
+ attachment_strength: 1.5,
71
+ reciprocity_balance: 1.5,
72
+ communication_consistency: 1.5
73
+ )
74
+ expect(health).to eq(1.0)
75
+ end
76
+ end
77
+
78
+ describe '#dirty? and #mark_clean!' do
79
+ it 'starts clean' do
80
+ expect(engine).not_to be_dirty
81
+ end
82
+
83
+ it 'cleans up' do
84
+ engine.add_milestone(type: :first_interaction, description: 'x', significance: 0.5)
85
+ engine.mark_clean!
86
+ expect(engine).not_to be_dirty
87
+ end
88
+ end
89
+
90
+ describe '#to_apollo_entries' do
91
+ before { engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8) }
92
+
93
+ it 'returns entries with arc state' do
94
+ entries = engine.to_apollo_entries
95
+ expect(entries).to be_an(Array)
96
+ expect(entries.first[:tags]).to include('bond', 'relationship_arc', 'partner-1')
97
+ end
98
+ end
99
+
100
+ describe '#from_apollo' do
101
+ let(:mock_store) { double('apollo_local') }
102
+
103
+ it 'restores state from Apollo' do
104
+ engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8)
105
+ engine.instance_variable_set(:@current_chapter, :developing)
106
+ content = engine.send(:serialize, engine.send(:arc_state_hash))
107
+
108
+ new_engine = described_class.new(agent_id: 'partner-1')
109
+ allow(mock_store).to receive(:query)
110
+ .and_return({ success: true, results: [{ content: content, tags: %w[bond relationship_arc partner-1] }] })
111
+
112
+ expect(new_engine.from_apollo(store: mock_store)).to be true
113
+ expect(new_engine.current_chapter).to eq(:developing)
114
+ expect(new_engine.milestones.size).to eq(1)
115
+ end
116
+ end
117
+
118
+ describe '#to_h' do
119
+ it 'includes all fields' do
120
+ h = engine.to_h
121
+ expect(h).to include(:agent_id, :current_chapter, :milestones, :relationship_health)
122
+ end
123
+
124
+ it 'relationship_health is nil before relationship_health() is called' do
125
+ expect(engine.to_h[:relationship_health]).to be_nil
126
+ end
127
+
128
+ it 'relationship_health reflects the last computed value' do
129
+ engine.relationship_health(
130
+ attachment_strength: 0.8,
131
+ reciprocity_balance: 0.6,
132
+ communication_consistency: 0.7
133
+ )
134
+ h = engine.to_h
135
+ expected = (0.8 * 0.4) + (0.6 * 0.3) + (0.7 * 0.3)
136
+ expect(h[:relationship_health]).to be_within(0.01).of(expected)
137
+ end
138
+
139
+ it 'includes milestone_count' do
140
+ engine.add_milestone(type: :first_interaction, description: 'x', significance: 0.5)
141
+ expect(engine.to_h[:milestone_count]).to eq(1)
142
+ end
143
+ end
144
+
145
+ describe '#arc_state_hash (via to_apollo_entries)' do
146
+ it 'includes milestones_today for milestones added today' do
147
+ engine.add_milestone(type: :first_interaction, description: 'today', significance: 0.9)
148
+ entries = engine.to_apollo_entries
149
+ parsed = JSON.parse(entries.first[:content], symbolize_names: true)
150
+ expect(parsed).to have_key(:milestones_today)
151
+ expect(parsed[:milestones_today].size).to eq(1)
152
+ end
153
+
154
+ it 'milestones_today is empty when milestones have old timestamps' do
155
+ ms = Legion::Extensions::Agentic::Self::RelationshipArc::Helpers::Milestone.new(
156
+ type: :first_interaction, description: 'old', significance: 0.5,
157
+ created_at: Time.now.utc - (2 * 86_400)
158
+ )
159
+ engine.instance_variable_get(:@milestones) << ms
160
+ entries = engine.to_apollo_entries
161
+ parsed = JSON.parse(entries.first[:content], symbolize_names: true)
162
+ expect(parsed[:milestones_today]).to eq([])
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
5
+
6
+ RSpec.describe Legion::Extensions::Agentic::Self::RelationshipArc::Helpers::Constants do
7
+ describe 'CHAPTERS' do
8
+ it 'defines 4 chapters in order' do
9
+ expect(described_class::CHAPTERS).to eq(%i[formative developing established deepening])
10
+ end
11
+ end
12
+
13
+ describe 'MILESTONE_TYPES' do
14
+ it 'includes expected types' do
15
+ expect(described_class::MILESTONE_TYPES).to include(:first_interaction, :stage_transition,
16
+ :prediction_accuracy, :absence_return)
17
+ end
18
+ end
19
+
20
+ describe 'HEALTH_WEIGHTS' do
21
+ it 'sums to 1.0' do
22
+ expect(described_class::HEALTH_WEIGHTS.values.sum).to eq(1.0)
23
+ end
24
+ end
25
+
26
+ describe 'MAX_MILESTONES' do
27
+ it 'caps at 200' do
28
+ expect(described_class::MAX_MILESTONES).to eq(200)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
5
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/milestone'
6
+
7
+ RSpec.describe Legion::Extensions::Agentic::Self::RelationshipArc::Helpers::Milestone do
8
+ subject(:milestone) do
9
+ described_class.new(type: :first_interaction, description: 'First hello', significance: 0.8)
10
+ end
11
+
12
+ describe '#initialize' do
13
+ it 'sets type' do
14
+ expect(milestone.type).to eq(:first_interaction)
15
+ end
16
+
17
+ it 'generates a UUID' do
18
+ expect(milestone.id).to match(/\A[0-9a-f-]{36}\z/)
19
+ end
20
+
21
+ it 'clamps significance to 0.0..1.0' do
22
+ ms = described_class.new(type: :first_interaction, description: 'test', significance: 1.5)
23
+ expect(ms.significance).to eq(1.0)
24
+ end
25
+
26
+ it 'records timestamp' do
27
+ expect(milestone.created_at).to be_a(Time)
28
+ end
29
+ end
30
+
31
+ describe '#to_h' do
32
+ it 'returns complete hash' do
33
+ h = milestone.to_h
34
+ expect(h).to include(:id, :type, :description, :significance, :created_at)
35
+ end
36
+ end
37
+
38
+ describe '.from_h' do
39
+ it 'round-trips' do
40
+ restored = described_class.from_h(milestone.to_h)
41
+ expect(restored.type).to eq(milestone.type)
42
+ expect(restored.description).to eq(milestone.description)
43
+ expect(restored.significance).to eq(milestone.significance)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
5
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/milestone'
6
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/arc_engine'
7
+ require 'legion/extensions/agentic/self/relationship_arc/runners/relationship_arc'
8
+
9
+ RSpec.describe Legion::Extensions::Agentic::Self::RelationshipArc::Runners::RelationshipArc do
10
+ let(:described_module) { described_class }
11
+ let(:host) { Object.new.extend(described_module) }
12
+
13
+ before { host.instance_variable_set(:@arc_engines, nil) }
14
+
15
+ describe '#record_milestone' do
16
+ it 'records a milestone for an agent' do
17
+ result = host.record_milestone(agent_id: 'p1', type: :first_interaction,
18
+ description: 'Hello', significance: 0.8)
19
+ expect(result[:success]).to be true
20
+ expect(result[:milestone][:type]).to eq(:first_interaction)
21
+ end
22
+
23
+ it 'returns error for unknown type' do
24
+ result = host.record_milestone(agent_id: 'p1', type: :bogus,
25
+ description: 'x', significance: 0.5)
26
+ expect(result[:success]).to be false
27
+ end
28
+
29
+ it 'calls NarrativeIdentity record_episode when available' do
30
+ narrator = double('narrator')
31
+ allow(host).to receive(:resolve_narrative_identity).and_return(narrator)
32
+ allow(narrator).to receive(:record_episode).and_return({ success: true })
33
+
34
+ host.record_milestone(agent_id: 'p1', type: :first_interaction,
35
+ description: 'Hello', significance: 0.8)
36
+ expect(narrator).to have_received(:record_episode)
37
+ .with(hash_including(episode_type: :relationship, significance: 0.8))
38
+ end
39
+ end
40
+
41
+ describe '#update_arc' do
42
+ it 'updates chapter based on attachment state' do
43
+ 3.times do
44
+ host.record_milestone(agent_id: 'p1', type: :first_interaction,
45
+ description: 'x', significance: 0.5)
46
+ end
47
+ result = host.update_arc(agent_id: 'p1', attachment_state: { bond_stage: :forming })
48
+ expect(result[:success]).to be true
49
+ end
50
+
51
+ it 'returns arc summary' do
52
+ result = host.update_arc(agent_id: 'p1', attachment_state: {})
53
+ expect(result).to have_key(:current_chapter)
54
+ expect(result).to have_key(:milestone_count)
55
+ end
56
+ end
57
+
58
+ describe '#arc_stats' do
59
+ it 'returns stats' do
60
+ result = host.arc_stats(agent_id: 'p1')
61
+ expect(result).to have_key(:current_chapter)
62
+ end
63
+ end
64
+ end
@@ -137,7 +137,7 @@ RSpec.describe Legion::Extensions::Agentic::Self::SelfTalk::Runners::SelfTalk, '
137
137
  result = client.conclude_dialogue(dialogue_id: did)
138
138
  expect(result[:concluded]).to be true
139
139
  report = client.dialogue_report(dialogue_id: did)
140
- expect(report[:dialogue][:conclusion]).to eq('Dialogue concluded')
140
+ expect(report[:dialogue][:conclusion]).to match(/turns across .* voices\. Dominant position:/)
141
141
  end
142
142
  end
143
143
 
@@ -151,7 +151,7 @@ RSpec.describe Legion::Extensions::Agentic::Self::SelfTalk::Runners::SelfTalk, '
151
151
  result = client.conclude_dialogue(dialogue_id: did)
152
152
  expect(result[:concluded]).to be true
153
153
  report = client.dialogue_report(dialogue_id: did)
154
- expect(report[:dialogue][:conclusion]).to eq('Dialogue concluded')
154
+ expect(report[:dialogue][:conclusion]).to match(/turns across .* voices\. Dominant position:/)
155
155
  end
156
156
  end
157
157
 
@@ -193,4 +193,64 @@ RSpec.describe Legion::Extensions::Agentic::Self::SelfTalk::Runners::SelfTalk do
193
193
  expect(voice[:name]).to eq('Critic')
194
194
  end
195
195
  end
196
+
197
+ describe 'mechanical voice bank (fix 3)' do
198
+ let(:runner_class) do
199
+ Class.new do
200
+ include Legion::Extensions::Agentic::Self::SelfTalk::Runners::SelfTalk
201
+ end
202
+ end
203
+ let(:runner) { runner_class.new }
204
+
205
+ it 'produces real content for known voice types' do
206
+ %i[critic advocate explorer pragmatist].each do |type|
207
+ result = runner.send(:mechanical_turn_content, type, 'test topic')
208
+ expect(result[:content]).not_to be_empty
209
+ expect(result[:content]).not_to match(/\A\[/)
210
+ expect(result[:position]).not_to be_nil
211
+ end
212
+ end
213
+
214
+ it 'falls back to generic bank for unknown voice types' do
215
+ result = runner.send(:mechanical_turn_content, :unknown_voice, 'some topic')
216
+ expect(result[:content]).not_to be_empty
217
+ expect(result[:position]).not_to be_nil
218
+ end
219
+
220
+ it 'VOICE_BANK covers critic, advocate, explorer, pragmatist' do
221
+ bank = Legion::Extensions::Agentic::Self::SelfTalk::Runners::SelfTalk::VOICE_BANK
222
+ expect(bank.keys).to include(:critic, :advocate, :explorer, :pragmatist)
223
+ end
224
+ end
225
+
226
+ describe 'mechanical summary (fix 4)' do
227
+ let(:runner_class) do
228
+ Class.new do
229
+ include Legion::Extensions::Agentic::Self::SelfTalk::Runners::SelfTalk
230
+ end
231
+ end
232
+ let(:runner) { runner_class.new }
233
+
234
+ it 'produces a non-empty summary when dialogue_data is nil' do
235
+ summary = runner.send(:mechanical_summary, nil)
236
+ expect(summary).to be_a(String)
237
+ expect(summary).not_to be_empty
238
+ end
239
+
240
+ it 'includes turn count in summary' do
241
+ did = start_dialogue(topic: 'summary test')
242
+ vid = register_voice(name: 'Critic', type: :critic)
243
+ client.add_turn(dialogue_id: did, voice_id: vid, content: 'Point one', position: :challenge)
244
+ client.add_turn(dialogue_id: did, voice_id: vid, content: 'Point two', position: :challenge)
245
+
246
+ result = client.conclude_dialogue(dialogue_id: did)
247
+ expect(result[:concluded]).to be true
248
+ end
249
+
250
+ it 'conclude_dialogue without summary uses mechanical_summary' do
251
+ did = start_dialogue(topic: 'no summary')
252
+ result = client.conclude_dialogue(dialogue_id: did)
253
+ expect(result[:concluded]).to be true
254
+ end
255
+ end
196
256
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-self
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.1.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -139,44 +139,44 @@ dependencies:
139
139
  name: rubocop
140
140
  requirement: !ruby/object:Gem::Requirement
141
141
  requirements:
142
- - - "~>"
142
+ - - ">="
143
143
  - !ruby/object:Gem::Version
144
- version: '1.60'
144
+ version: '0'
145
145
  type: :development
146
146
  prerelease: false
147
147
  version_requirements: !ruby/object:Gem::Requirement
148
148
  requirements:
149
- - - "~>"
149
+ - - ">="
150
150
  - !ruby/object:Gem::Version
151
- version: '1.60'
151
+ version: '0'
152
152
  - !ruby/object:Gem::Dependency
153
153
  name: rubocop-legion
154
154
  requirement: !ruby/object:Gem::Requirement
155
155
  requirements:
156
- - - "~>"
156
+ - - ">="
157
157
  - !ruby/object:Gem::Version
158
- version: '0.1'
158
+ version: '0'
159
159
  type: :development
160
160
  prerelease: false
161
161
  version_requirements: !ruby/object:Gem::Requirement
162
162
  requirements:
163
- - - "~>"
163
+ - - ">="
164
164
  - !ruby/object:Gem::Version
165
- version: '0.1'
165
+ version: '0'
166
166
  - !ruby/object:Gem::Dependency
167
167
  name: rubocop-rspec
168
168
  requirement: !ruby/object:Gem::Requirement
169
169
  requirements:
170
- - - "~>"
170
+ - - ">="
171
171
  - !ruby/object:Gem::Version
172
- version: '2.26'
172
+ version: '0'
173
173
  type: :development
174
174
  prerelease: false
175
175
  version_requirements: !ruby/object:Gem::Requirement
176
176
  requirements:
177
- - - "~>"
177
+ - - ">="
178
178
  - !ruby/object:Gem::Version
179
- version: '2.26'
179
+ version: '0'
180
180
  description: 'LEX agentic self domain: identity, metacognition, self-model'
181
181
  email:
182
182
  - matthewdiverson@gmail.com
@@ -309,6 +309,13 @@ files:
309
309
  - lib/legion/extensions/agentic/self/reflection/helpers/reflection_store.rb
310
310
  - lib/legion/extensions/agentic/self/reflection/runners/reflection.rb
311
311
  - lib/legion/extensions/agentic/self/reflection/version.rb
312
+ - lib/legion/extensions/agentic/self/relationship_arc.rb
313
+ - lib/legion/extensions/agentic/self/relationship_arc/client.rb
314
+ - lib/legion/extensions/agentic/self/relationship_arc/helpers/arc_engine.rb
315
+ - lib/legion/extensions/agentic/self/relationship_arc/helpers/constants.rb
316
+ - lib/legion/extensions/agentic/self/relationship_arc/helpers/milestone.rb
317
+ - lib/legion/extensions/agentic/self/relationship_arc/runners/relationship_arc.rb
318
+ - lib/legion/extensions/agentic/self/relationship_arc/version.rb
312
319
  - lib/legion/extensions/agentic/self/self_model.rb
313
320
  - lib/legion/extensions/agentic/self/self_model/client.rb
314
321
  - lib/legion/extensions/agentic/self/self_model/helpers/capability.rb
@@ -416,6 +423,10 @@ files:
416
423
  - spec/legion/extensions/agentic/self/reflection/helpers/reflection_spec.rb
417
424
  - spec/legion/extensions/agentic/self/reflection/helpers/reflection_store_spec.rb
418
425
  - spec/legion/extensions/agentic/self/reflection/runners/reflection_spec.rb
426
+ - spec/legion/extensions/agentic/self/relationship_arc/helpers/arc_engine_spec.rb
427
+ - spec/legion/extensions/agentic/self/relationship_arc/helpers/constants_spec.rb
428
+ - spec/legion/extensions/agentic/self/relationship_arc/helpers/milestone_spec.rb
429
+ - spec/legion/extensions/agentic/self/relationship_arc/runners/relationship_arc_spec.rb
419
430
  - spec/legion/extensions/agentic/self/self_model/client_spec.rb
420
431
  - spec/legion/extensions/agentic/self/self_model/helpers/capability_spec.rb
421
432
  - spec/legion/extensions/agentic/self/self_model/helpers/knowledge_domain_spec.rb