lex-agentic-memory 0.1.19 → 0.1.21

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 440d04008d2af919b940d155b035cf4d1cac9d8e67361792008b166504ed9a2a
4
- data.tar.gz: 0f0d8fade5d070d635b707e792501d9b88624205940b32b9babf194c33970620
3
+ metadata.gz: f403d34cba75eaf3f800a8852c8d6e16d2f56924b2454b7c444a0cee82a00f04
4
+ data.tar.gz: f946558e6cf3103bc32bf44d9accb9a4c3a13d91342e7a06a5f6b5ec98c0c856
5
5
  SHA512:
6
- metadata.gz: d6695c59a99700fb47490bafbf29e9749fca9f9b523d38ec67ee31f3a522f633fc0c5a36d0cdbd6f362e6bbb78ac35a896a2bcf9023f16a3c3d6d9cb25ea6279
7
- data.tar.gz: 7e79c8329c3de95c22a832144d729c5430586048727a88762698df887fb8b9a06689391f9539ac081375082b19e76b8805dcb807554d7f30df342cd3b1eb00f1
6
+ metadata.gz: e49ff76e259d4237f19a7998f8e973438ebcfceb44322990a6213f35bd07481adfea5f8cd037c351217af569931f2344ceb04b5603bc53cadd5c27d02cec4cbe
7
+ data.tar.gz: 28a6d1d21d23f0ba91d034b441ee7e858fc51d1d1662e9445b58ac215e09dd271871f90941121d04566e259d971cf6d615c56412ccf2cd9f08e5658e1dd20f0f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.21] - 2026-04-01
4
+
5
+ ### Changed
6
+ - `ErrorTracer.record_trace` now dispatches `Trace.shared_store` writes/flushes in a background `Thread.new` — error/fatal logging hooks no longer block the calling Puma thread, regardless of store backend (Postgres, CacheStore, or in-memory)
7
+ - Debounce check remains synchronous; only the store write and flush go async
8
+
9
+ ## [0.1.20] - 2026-03-31
10
+
11
+ ### Added
12
+ - CommunicationPattern sub-module for Phase C relational intelligence
13
+ - PatternAnalyzer: time-of-day/day-of-week histograms, channel preference, topic clustering, consistency scoring
14
+ - CommunicationPattern runner: update_patterns, analyze_patterns, pattern_stats
15
+ - Apollo Local persistence for communication pattern state
16
+
3
17
  ## [0.1.19] - 2026-03-31
4
18
 
5
19
  ### Fixed
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
  spec.add_dependency 'msgpack', '~> 1.7'
35
35
 
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
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Memory
7
+ module CommunicationPattern
8
+ class Client
9
+ include Runners::CommunicationPattern
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Memory
7
+ module CommunicationPattern
8
+ module Helpers
9
+ module Constants
10
+ HOURS_IN_DAY = 24
11
+ DAYS_IN_WEEK = 7
12
+
13
+ MESSAGE_LENGTH_BUCKETS = %i[short medium long].freeze
14
+ MESSAGE_LENGTH_THRESHOLDS = { short: 50, medium: 200 }.freeze
15
+
16
+ SLIDING_WINDOW_SIZE = 100
17
+ MIN_TRACES_FOR_PATTERN = 10
18
+
19
+ TAG_PREFIX = %w[bond communication_pattern].freeze
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Memory
7
+ module CommunicationPattern
8
+ module Helpers
9
+ class PatternAnalyzer
10
+ attr_reader :agent_id, :trace_count
11
+
12
+ def initialize(agent_id:)
13
+ @agent_id = agent_id
14
+ @tod_histogram = Array.new(Constants::HOURS_IN_DAY, 0)
15
+ @dow_histogram = Array.new(Constants::DAYS_IN_WEEK, 0)
16
+ @channel_counts = Hash.new(0)
17
+ @direct_count = 0
18
+ @topic_counts = Hash.new(0)
19
+ @trace_count = 0
20
+ @dirty = false
21
+ end
22
+
23
+ def update_from_traces(traces)
24
+ traces.each { |t| process_trace(t) }
25
+ @dirty = true
26
+ end
27
+
28
+ def time_of_day_distribution
29
+ @tod_histogram.dup
30
+ end
31
+
32
+ def day_of_week_distribution
33
+ @dow_histogram.dup
34
+ end
35
+
36
+ def channel_preference
37
+ @channel_counts.sort_by { |_k, v| -v }.map(&:first)
38
+ end
39
+
40
+ def direct_address_frequency
41
+ return 0.0 if @trace_count.zero?
42
+
43
+ @direct_count.to_f / @trace_count
44
+ end
45
+
46
+ def topic_clustering
47
+ @topic_counts.dup
48
+ end
49
+
50
+ def consistency
51
+ return 0.0 if @trace_count < Constants::MIN_TRACES_FOR_PATTERN
52
+
53
+ channel_entropy = normalized_entropy(@channel_counts.values)
54
+ tod_entropy = normalized_entropy(@tod_histogram)
55
+ (1.0 - ((channel_entropy + tod_entropy) / 2.0)).clamp(0.0, 1.0)
56
+ end
57
+
58
+ def dirty?
59
+ @dirty
60
+ end
61
+
62
+ def mark_clean!
63
+ @dirty = false
64
+ self
65
+ end
66
+
67
+ def to_apollo_entries
68
+ tags = Constants::TAG_PREFIX.dup + [@agent_id]
69
+ [{ content: serialize(state_hash), tags: tags }]
70
+ end
71
+
72
+ def from_apollo(store:)
73
+ result = store.query(text: 'communication_pattern',
74
+ tags: Constants::TAG_PREFIX + [@agent_id])
75
+ return false unless result[:success] && result[:results]&.any?
76
+
77
+ parsed = deserialize(result[:results].first[:content])
78
+ return false unless parsed
79
+
80
+ restore_state(parsed)
81
+ true
82
+ rescue StandardError => e
83
+ warn "[pattern_analyzer] from_apollo error: #{e.message}"
84
+ false
85
+ end
86
+
87
+ private
88
+
89
+ def process_trace(trace)
90
+ ts = trace[:created_at]
91
+ ts = Time.parse(ts.to_s) unless ts.is_a?(Time)
92
+
93
+ @tod_histogram[ts.hour] += 1
94
+ @dow_histogram[ts.wday] += 1
95
+
96
+ payload = trace[:content_payload] || {}
97
+ payload = payload.transform_keys(&:to_sym) if payload.is_a?(Hash)
98
+
99
+ channel = payload[:channel]&.to_s
100
+ @channel_counts[channel] += 1 if channel
101
+
102
+ @direct_count += 1 if payload[:direct_address]
103
+
104
+ (trace[:domain_tags] || []).each { |tag| @topic_counts[tag.to_s] += 1 }
105
+
106
+ @trace_count += 1
107
+ rescue StandardError => e
108
+ Legion::Logging.warn("[pattern_analyzer] process_trace error: #{e.message}")
109
+ nil
110
+ end
111
+
112
+ def normalized_entropy(counts)
113
+ total = counts.sum.to_f
114
+ return 0.0 if total.zero?
115
+
116
+ probs = counts.select(&:positive?).map { |c| c / total }
117
+ max_entropy = Math.log(probs.size)
118
+ return 0.0 if max_entropy.zero?
119
+
120
+ entropy = -probs.sum { |p| p * Math.log(p) }
121
+ entropy / max_entropy
122
+ end
123
+
124
+ def state_hash
125
+ { agent_id: @agent_id, trace_count: @trace_count,
126
+ tod_histogram: @tod_histogram, dow_histogram: @dow_histogram,
127
+ channel_counts: @channel_counts, direct_count: @direct_count,
128
+ topic_counts: @topic_counts, consistency: consistency }
129
+ end
130
+
131
+ def restore_state(parsed)
132
+ @trace_count = parsed[:trace_count].to_i
133
+ @tod_histogram = parsed[:tod_histogram] || Array.new(24, 0)
134
+ @dow_histogram = parsed[:dow_histogram] || Array.new(7, 0)
135
+ @channel_counts = Hash.new(0).merge(parsed[:channel_counts] || {})
136
+ @direct_count = parsed[:direct_count].to_i
137
+ @topic_counts = Hash.new(0).merge(parsed[:topic_counts] || {})
138
+ end
139
+
140
+ def serialize(hash)
141
+ defined?(Legion::JSON) ? Legion::JSON.dump(hash) : ::JSON.dump(hash)
142
+ end
143
+
144
+ def deserialize(content)
145
+ parsed = defined?(Legion::JSON) ? Legion::JSON.parse(content) : ::JSON.parse(content, symbolize_names: true)
146
+ parsed.is_a?(Hash) ? parsed.transform_keys(&:to_sym) : nil
147
+ rescue StandardError => e
148
+ Legion::Logging.warn("[pattern_analyzer] deserialize error: #{e.message}")
149
+ nil
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Memory
7
+ module CommunicationPattern
8
+ module Runners
9
+ module CommunicationPattern
10
+ include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
+
13
+ def update_patterns(agent_id:, traces: [], **)
14
+ analyzer = analyzer_for(agent_id)
15
+ analyzer.update_from_traces(traces)
16
+
17
+ { success: true, trace_count: analyzer.trace_count,
18
+ channel_preference: analyzer.channel_preference,
19
+ direct_address_frequency: analyzer.direct_address_frequency,
20
+ consistency: analyzer.consistency }
21
+ rescue StandardError => e
22
+ { success: false, error: e.message }
23
+ end
24
+
25
+ def analyze_patterns(agent_id:, **)
26
+ analyzer = analyzer_for(agent_id)
27
+ { time_of_day_distribution: analyzer.time_of_day_distribution,
28
+ day_of_week_distribution: analyzer.day_of_week_distribution,
29
+ channel_preference: analyzer.channel_preference,
30
+ direct_address_frequency: analyzer.direct_address_frequency,
31
+ topic_clustering: analyzer.topic_clustering,
32
+ consistency: analyzer.consistency,
33
+ trace_count: analyzer.trace_count }
34
+ end
35
+
36
+ def pattern_stats(agent_id:, **)
37
+ analyzer = analyzer_for(agent_id)
38
+ { trace_count: analyzer.trace_count,
39
+ channel_preference: analyzer.channel_preference,
40
+ consistency: analyzer.consistency }
41
+ end
42
+
43
+ private
44
+
45
+ def analyzer_for(agent_id)
46
+ @analyzers ||= {}
47
+ @analyzers[agent_id.to_s] ||= Helpers::PatternAnalyzer.new(agent_id: agent_id.to_s)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Memory
7
+ module CommunicationPattern
8
+ VERSION = '0.1.0'
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'communication_pattern/version'
4
+ require_relative 'communication_pattern/helpers/constants'
5
+ require_relative 'communication_pattern/helpers/pattern_analyzer'
6
+ require_relative 'communication_pattern/runners/communication_pattern'
7
+ require_relative 'communication_pattern/client'
@@ -17,8 +17,12 @@ module Legion
17
17
  def setup
18
18
  return if @active
19
19
 
20
- @recent = {}
21
- @runner = Object.new.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
20
+ @recent = {}
21
+ @recent_mutex = ::Mutex.new
22
+ @runner = Object.new.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
23
+ @write_queue = ::Queue.new
24
+ @worker = ::Thread.new { drain_queue }
25
+ @worker.name = 'legion-error-tracer'
22
26
  wrap_logging_methods
23
27
  @active = true
24
28
  Legion::Logging.info '[memory] ErrorTracer active — errors/fatals will become episodic traces'
@@ -30,6 +34,19 @@ module Legion
30
34
 
31
35
  private
32
36
 
37
+ def drain_queue
38
+ loop do
39
+ payload = @write_queue.pop
40
+ break if payload == :stop
41
+
42
+ @runner.store_trace(**payload)
43
+ store = @runner.send(:default_store)
44
+ store.flush if store.respond_to?(:flush)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+ end
49
+
33
50
  def wrap_logging_methods
34
51
  original_error = Legion::Logging.method(:error)
35
52
  original_fatal = Legion::Logging.method(:fatal)
@@ -50,23 +67,21 @@ module Legion
50
67
  def record_trace(message, level)
51
68
  return unless message.is_a?(String) && !message.empty?
52
69
 
53
- # Debounce: skip if same message within window
54
70
  now = Time.now.utc
55
71
  key = "#{level}:#{message[0..100]}"
56
- return if @recent[key] && (now - @recent[key]) < DEBOUNCE_WINDOW
57
72
 
58
- @recent[key] = now
73
+ @recent_mutex.synchronize do
74
+ return if @recent[key] && (now - @recent[key]) < DEBOUNCE_WINDOW
59
75
 
60
- # Clean old entries periodically
61
- @recent.delete_if { |_, t| (now - t) > DEBOUNCE_WINDOW } if @recent.size > 500
76
+ @recent[key] = now
77
+ @recent.delete_if { |_, t| (now - t) > DEBOUNCE_WINDOW } if @recent.size > 500
78
+ end
62
79
 
63
- # Extract component from [bracket] prefix
64
80
  component = message.match(/\A\[([^\]]+)\]/)&.captures&.first || 'unknown'
65
-
66
81
  valence = level == :fatal ? FATAL_VALENCE : ERROR_VALENCE
67
82
  intensity = level == :fatal ? FATAL_INTENSITY : ERROR_INTENSITY
68
83
 
69
- @runner.store_trace(
84
+ @write_queue.push(
70
85
  type: :episodic,
71
86
  content_payload: message,
72
87
  domain_tags: ['error', component.downcase],
@@ -76,12 +91,7 @@ module Legion
76
91
  unresolved: true,
77
92
  confidence: 0.9
78
93
  )
79
-
80
- # Flush if cache-backed
81
- store = @runner.send(:default_store)
82
- store.flush if store.respond_to?(:flush)
83
- rescue StandardError => _e
84
- # Never let trace creation break the logging pipeline
94
+ rescue StandardError
85
95
  nil
86
96
  end
87
97
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Memory
7
- VERSION = '0.1.19'
7
+ VERSION = '0.1.21'
8
8
  end
9
9
  end
10
10
  end
@@ -19,6 +19,7 @@ require_relative 'memory/semantic_priming'
19
19
  require_relative 'memory/semantic_satiation'
20
20
  require_relative 'memory/source_monitoring'
21
21
  require_relative 'memory/transfer'
22
+ require_relative 'memory/communication_pattern'
22
23
 
23
24
  module Legion
24
25
  module Extensions
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/memory/communication_pattern/helpers/constants'
5
+
6
+ RSpec.describe Legion::Extensions::Agentic::Memory::CommunicationPattern::Helpers::Constants do
7
+ describe 'HOURS_IN_DAY' do
8
+ it 'is 24' do
9
+ expect(described_class::HOURS_IN_DAY).to eq(24)
10
+ end
11
+ end
12
+
13
+ describe 'DAYS_IN_WEEK' do
14
+ it 'is 7' do
15
+ expect(described_class::DAYS_IN_WEEK).to eq(7)
16
+ end
17
+ end
18
+
19
+ describe 'MESSAGE_LENGTH_BUCKETS' do
20
+ it 'defines 3 buckets' do
21
+ expect(described_class::MESSAGE_LENGTH_BUCKETS).to eq(%i[short medium long])
22
+ end
23
+ end
24
+
25
+ describe 'SLIDING_WINDOW_SIZE' do
26
+ it 'is 100' do
27
+ expect(described_class::SLIDING_WINDOW_SIZE).to eq(100)
28
+ end
29
+ end
30
+
31
+ describe 'MIN_TRACES_FOR_PATTERN' do
32
+ it 'is 10' do
33
+ expect(described_class::MIN_TRACES_FOR_PATTERN).to eq(10)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/memory/communication_pattern/helpers/constants'
5
+ require 'legion/extensions/agentic/memory/communication_pattern/helpers/pattern_analyzer'
6
+
7
+ RSpec.describe Legion::Extensions::Agentic::Memory::CommunicationPattern::Helpers::PatternAnalyzer do
8
+ subject(:analyzer) { described_class.new(agent_id: 'partner-1') }
9
+
10
+ let(:trace_9am) do
11
+ { trace_id: 'tr1', trace_type: :episodic, created_at: Time.utc(2026, 3, 31, 9, 0),
12
+ content_payload: { channel: 'teams', direct_address: true, agent_id: 'partner-1' },
13
+ domain_tags: %w[partner observation], source_agent_id: 'partner-1' }
14
+ end
15
+
16
+ let(:trace_2pm) do
17
+ { trace_id: 'tr2', trace_type: :episodic, created_at: Time.utc(2026, 3, 31, 14, 0),
18
+ content_payload: { channel: 'cli', direct_address: false, agent_id: 'partner-1' },
19
+ domain_tags: %w[partner observation work], source_agent_id: 'partner-1' }
20
+ end
21
+
22
+ describe '#update_from_traces' do
23
+ it 'processes an array of traces' do
24
+ analyzer.update_from_traces([trace_9am, trace_2pm])
25
+ expect(analyzer.trace_count).to eq(2)
26
+ end
27
+
28
+ it 'marks dirty' do
29
+ analyzer.update_from_traces([trace_9am])
30
+ expect(analyzer).to be_dirty
31
+ end
32
+ end
33
+
34
+ describe '#time_of_day_distribution' do
35
+ before { analyzer.update_from_traces([trace_9am, trace_2pm]) }
36
+
37
+ it 'returns 24-bucket histogram' do
38
+ dist = analyzer.time_of_day_distribution
39
+ expect(dist.size).to eq(24)
40
+ end
41
+
42
+ it 'has counts in the right buckets' do
43
+ dist = analyzer.time_of_day_distribution
44
+ expect(dist[9]).to eq(1)
45
+ expect(dist[14]).to eq(1)
46
+ expect(dist[0]).to eq(0)
47
+ end
48
+ end
49
+
50
+ describe '#day_of_week_distribution' do
51
+ before { analyzer.update_from_traces([trace_9am]) }
52
+
53
+ it 'returns 7-bucket histogram' do
54
+ dist = analyzer.day_of_week_distribution
55
+ expect(dist.size).to eq(7)
56
+ end
57
+ end
58
+
59
+ describe '#channel_preference' do
60
+ before { analyzer.update_from_traces([trace_9am, trace_2pm, trace_9am.merge(trace_id: 'tr3')]) }
61
+
62
+ it 'ranks channels by frequency' do
63
+ prefs = analyzer.channel_preference
64
+ expect(prefs.first).to eq('teams')
65
+ end
66
+ end
67
+
68
+ describe '#direct_address_frequency' do
69
+ before { analyzer.update_from_traces([trace_9am, trace_2pm]) }
70
+
71
+ it 'computes ratio of direct address traces' do
72
+ ratio = analyzer.direct_address_frequency
73
+ expect(ratio).to be_within(0.01).of(0.5)
74
+ end
75
+ end
76
+
77
+ describe '#topic_clustering' do
78
+ before { analyzer.update_from_traces([trace_9am, trace_2pm]) }
79
+
80
+ it 'returns domain tag frequencies' do
81
+ topics = analyzer.topic_clustering
82
+ expect(topics).to have_key('partner')
83
+ expect(topics['partner']).to eq(2)
84
+ end
85
+ end
86
+
87
+ describe '#consistency' do
88
+ it 'returns 0.0 with no data' do
89
+ expect(analyzer.consistency).to eq(0.0)
90
+ end
91
+
92
+ it 'returns positive value with data' do
93
+ analyzer.update_from_traces([trace_9am, trace_2pm])
94
+ expect(analyzer.consistency).to eq(0.0)
95
+ end
96
+ end
97
+
98
+ describe 'Apollo persistence' do
99
+ describe '#to_apollo_entries' do
100
+ before { analyzer.update_from_traces([trace_9am]) }
101
+
102
+ it 'returns entries with correct tags' do
103
+ entries = analyzer.to_apollo_entries
104
+ expect(entries.first[:tags]).to include('bond', 'communication_pattern', 'partner-1')
105
+ end
106
+ end
107
+
108
+ describe '#from_apollo' do
109
+ let(:mock_store) { double('apollo_local') }
110
+
111
+ it 'restores state' do
112
+ analyzer.update_from_traces([trace_9am, trace_2pm])
113
+ content = analyzer.send(:serialize, analyzer.send(:state_hash))
114
+
115
+ new_analyzer = described_class.new(agent_id: 'partner-1')
116
+ allow(mock_store).to receive(:query)
117
+ .and_return({ success: true, results: [{ content: content }] })
118
+
119
+ expect(new_analyzer.from_apollo(store: mock_store)).to be true
120
+ expect(new_analyzer.trace_count).to eq(2)
121
+ end
122
+ end
123
+
124
+ describe '#mark_clean!' do
125
+ it 'clears dirty flag' do
126
+ analyzer.update_from_traces([trace_9am])
127
+ analyzer.mark_clean!
128
+ expect(analyzer).not_to be_dirty
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/memory/communication_pattern/helpers/constants'
5
+ require 'legion/extensions/agentic/memory/communication_pattern/helpers/pattern_analyzer'
6
+ require 'legion/extensions/agentic/memory/communication_pattern/runners/communication_pattern'
7
+
8
+ RSpec.describe Legion::Extensions::Agentic::Memory::CommunicationPattern::Runners::CommunicationPattern do
9
+ let(:host) { Object.new.extend(described_module) }
10
+ let(:described_module) { described_class }
11
+
12
+ before { host.instance_variable_set(:@analyzers, nil) }
13
+
14
+ describe '#update_patterns' do
15
+ let(:traces) do
16
+ [
17
+ { trace_id: 'tr1', trace_type: :episodic, created_at: Time.utc(2026, 3, 31, 9, 0),
18
+ content_payload: { channel: 'teams', direct_address: true }, domain_tags: %w[partner],
19
+ source_agent_id: 'partner-1' },
20
+ { trace_id: 'tr2', trace_type: :episodic, created_at: Time.utc(2026, 3, 31, 14, 0),
21
+ content_payload: { channel: 'cli', direct_address: false }, domain_tags: %w[partner],
22
+ source_agent_id: 'partner-1' }
23
+ ]
24
+ end
25
+
26
+ it 'processes traces and returns result' do
27
+ result = host.update_patterns(agent_id: 'partner-1', traces: traces)
28
+ expect(result[:success]).to be true
29
+ expect(result[:trace_count]).to eq(2)
30
+ end
31
+
32
+ it 'returns channel preference' do
33
+ result = host.update_patterns(agent_id: 'partner-1', traces: traces)
34
+ expect(result[:channel_preference]).to be_an(Array)
35
+ end
36
+
37
+ it 'returns direct address frequency' do
38
+ result = host.update_patterns(agent_id: 'partner-1', traces: traces)
39
+ expect(result[:direct_address_frequency]).to be_within(0.01).of(0.5)
40
+ end
41
+ end
42
+
43
+ describe '#analyze_patterns' do
44
+ it 'returns current patterns for an agent' do
45
+ host.update_patterns(agent_id: 'p1', traces: [
46
+ { trace_id: 't1', trace_type: :episodic, created_at: Time.now.utc,
47
+ content_payload: { channel: 'teams' }, domain_tags: [], source_agent_id: 'p1' }
48
+ ])
49
+ result = host.analyze_patterns(agent_id: 'p1')
50
+ expect(result).to have_key(:time_of_day_distribution)
51
+ expect(result).to have_key(:channel_preference)
52
+ end
53
+
54
+ it 'returns empty result for unknown agent' do
55
+ result = host.analyze_patterns(agent_id: 'unknown')
56
+ expect(result[:trace_count]).to eq(0)
57
+ end
58
+ end
59
+
60
+ describe '#pattern_stats' do
61
+ it 'returns stats hash' do
62
+ result = host.pattern_stats(agent_id: 'p1')
63
+ expect(result).to have_key(:trace_count)
64
+ end
65
+ end
66
+ end
@@ -11,13 +11,24 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
11
11
  # Reset state between examples
12
12
  described_class.instance_variable_set(:@active, nil)
13
13
  described_class.instance_variable_set(:@recent, nil)
14
+ described_class.instance_variable_set(:@recent_mutex, nil)
14
15
  described_class.instance_variable_set(:@runner, nil)
16
+ described_class.instance_variable_set(:@write_queue, nil)
17
+ old_worker = described_class.instance_variable_get(:@worker)
18
+ old_worker&.kill
19
+ described_class.instance_variable_set(:@worker, nil)
15
20
  end
16
21
 
17
22
  after do
18
23
  # Restore logging singleton methods to prevent cross-test side effects
19
24
  Legion::Logging.define_singleton_method(:error, &original_error)
20
25
  Legion::Logging.define_singleton_method(:fatal, &original_fatal)
26
+ # Stop worker thread if still alive
27
+ worker = described_class.instance_variable_get(:@worker)
28
+ if worker&.alive?
29
+ described_class.instance_variable_get(:@write_queue)&.push(:stop)
30
+ worker.join(1)
31
+ end
21
32
  end
22
33
 
23
34
  describe '.setup' do
@@ -31,6 +42,13 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
31
42
  described_class.setup
32
43
  expect(described_class.active?).to be true
33
44
  end
45
+
46
+ it 'starts a single background worker thread' do
47
+ described_class.setup
48
+ worker = described_class.instance_variable_get(:@worker)
49
+ expect(worker).to be_a(Thread)
50
+ expect(worker).to be_alive
51
+ end
34
52
  end
35
53
 
36
54
  describe '.active?' do
@@ -43,4 +61,114 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
43
61
  expect(described_class.active?).to be true
44
62
  end
45
63
  end
64
+
65
+ describe 'record_trace (async dispatch via queue)' do
66
+ let(:store_dbl) { double('store', flush: nil) }
67
+ let(:runner_double) do
68
+ dbl = double('runner')
69
+ allow(dbl).to receive(:store_trace)
70
+ allow(dbl).to receive(:default_store).and_return(store_dbl)
71
+ dbl
72
+ end
73
+ let(:write_queue) { Queue.new }
74
+
75
+ before do
76
+ described_class.instance_variable_set(:@active, true)
77
+ described_class.instance_variable_set(:@recent, {})
78
+ described_class.instance_variable_set(:@recent_mutex, Mutex.new)
79
+ described_class.instance_variable_set(:@runner, runner_double)
80
+ described_class.instance_variable_set(:@write_queue, write_queue)
81
+ end
82
+
83
+ it 'enqueues a payload hash (fire-and-forget dispatch)' do
84
+ described_class.send(:record_trace, 'something broke', :error)
85
+ expect(write_queue.size).to eq(1)
86
+ payload = write_queue.pop
87
+ expect(payload).to be_a(Hash)
88
+ end
89
+
90
+ it 'enqueues a payload with expected keys' do
91
+ described_class.send(:record_trace, '[mycomponent] disk full', :error)
92
+ payload = write_queue.pop
93
+ expect(payload).to include(
94
+ type: :episodic,
95
+ content_payload: '[mycomponent] disk full',
96
+ domain_tags: %w[error mycomponent],
97
+ unresolved: true
98
+ )
99
+ end
100
+
101
+ it 'uses fatal valence and intensity for :fatal level' do
102
+ described_class.send(:record_trace, 'total meltdown', :fatal)
103
+ payload = write_queue.pop
104
+ expect(payload).to include(
105
+ emotional_valence: Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer::FATAL_VALENCE,
106
+ emotional_intensity: Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer::FATAL_INTENSITY
107
+ )
108
+ end
109
+
110
+ it 'does not enqueue (debounced) when same message is within the window' do
111
+ described_class.send(:record_trace, 'repeated error', :error)
112
+ described_class.send(:record_trace, 'repeated error', :error)
113
+ expect(write_queue.size).to eq(1)
114
+ end
115
+
116
+ it 'returns nil for blank messages without raising' do
117
+ expect(described_class.send(:record_trace, '', :error)).to be_nil
118
+ expect(write_queue.size).to eq(0)
119
+ end
120
+
121
+ it 'does not propagate errors from store_trace' do
122
+ worker_queue = Queue.new
123
+ described_class.instance_variable_set(:@write_queue, worker_queue)
124
+ allow(runner_double).to receive(:store_trace).and_raise(StandardError, 'db down')
125
+
126
+ # rubocop:disable ThreadSafety/NewThread
127
+ worker = Thread.new do
128
+ payload = worker_queue.pop
129
+ break if payload == :stop
130
+
131
+ runner_double.store_trace(**payload)
132
+ rescue StandardError
133
+ nil
134
+ end
135
+ # rubocop:enable ThreadSafety/NewThread
136
+
137
+ described_class.send(:record_trace, 'store failure', :error)
138
+ expect { worker.join(2) }.not_to raise_error
139
+ end
140
+
141
+ it 'the background worker calls store_trace with the enqueued payload' do
142
+ dedicated_queue = Queue.new
143
+ described_class.instance_variable_set(:@write_queue, dedicated_queue)
144
+
145
+ # rubocop:disable ThreadSafety/NewThread
146
+ worker = Thread.new do
147
+ loop do
148
+ payload = dedicated_queue.pop
149
+ break if payload == :stop
150
+
151
+ runner_double.store_trace(**payload)
152
+ store = runner_double.send(:default_store)
153
+ store.flush if store.respond_to?(:flush)
154
+ rescue StandardError
155
+ nil
156
+ end
157
+ end
158
+ # rubocop:enable ThreadSafety/NewThread
159
+
160
+ described_class.send(:record_trace, '[mycomponent] disk full', :error)
161
+ dedicated_queue.push(:stop)
162
+ worker.join(2)
163
+
164
+ expect(runner_double).to have_received(:store_trace).with(
165
+ hash_including(
166
+ type: :episodic,
167
+ content_payload: '[mycomponent] disk full',
168
+ domain_tags: %w[error mycomponent],
169
+ unresolved: true
170
+ )
171
+ )
172
+ end
173
+ end
46
174
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-memory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.19
4
+ version: 0.1.21
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 memory domain: episodic, semantic, and working memory'
181
181
  email:
182
182
  - matthewdiverson@gmail.com
@@ -199,6 +199,12 @@ files:
199
199
  - lib/legion/extensions/agentic/memory/archaeology/helpers/excavation_site.rb
200
200
  - lib/legion/extensions/agentic/memory/archaeology/runners/cognitive_archaeology.rb
201
201
  - lib/legion/extensions/agentic/memory/archaeology/version.rb
202
+ - lib/legion/extensions/agentic/memory/communication_pattern.rb
203
+ - lib/legion/extensions/agentic/memory/communication_pattern/client.rb
204
+ - lib/legion/extensions/agentic/memory/communication_pattern/helpers/constants.rb
205
+ - lib/legion/extensions/agentic/memory/communication_pattern/helpers/pattern_analyzer.rb
206
+ - lib/legion/extensions/agentic/memory/communication_pattern/runners/communication_pattern.rb
207
+ - lib/legion/extensions/agentic/memory/communication_pattern/version.rb
202
208
  - lib/legion/extensions/agentic/memory/compression.rb
203
209
  - lib/legion/extensions/agentic/memory/compression/actors/maintenance.rb
204
210
  - lib/legion/extensions/agentic/memory/compression/client.rb
@@ -363,6 +369,9 @@ files:
363
369
  - spec/legion/extensions/agentic/memory/archaeology/helpers/constants_spec.rb
364
370
  - spec/legion/extensions/agentic/memory/archaeology/helpers/excavation_site_spec.rb
365
371
  - spec/legion/extensions/agentic/memory/archaeology/runners/cognitive_archaeology_spec.rb
372
+ - spec/legion/extensions/agentic/memory/communication_pattern/helpers/constants_spec.rb
373
+ - spec/legion/extensions/agentic/memory/communication_pattern/helpers/pattern_analyzer_spec.rb
374
+ - spec/legion/extensions/agentic/memory/communication_pattern/runners/communication_pattern_spec.rb
366
375
  - spec/legion/extensions/agentic/memory/compression/actors/maintenance_spec.rb
367
376
  - spec/legion/extensions/agentic/memory/compression/helpers/compression_engine_spec.rb
368
377
  - spec/legion/extensions/agentic/memory/compression/helpers/constants_spec.rb