lex-narrator 0.1.1

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: 70acf0397dff2bf9773a893f3e595d40728ef241467ce5e394ce2daa36fe286a
4
+ data.tar.gz: caceda0dc9cb7f4f2aae65a0b3e6843108590d574f4731eef5b8c94ae9d876e5
5
+ SHA512:
6
+ metadata.gz: 9b5d3a9b0c07b8d0da95fdb71560204755edf84d737d258f72c7f869e83401c34a0eba3e915043999c768ad674c82e53732be01a437f2dba4626ca5c9b6d7855
7
+ data.tar.gz: 0abe5958816469fbbc12fbb04564136ae49efd684e0ae68de6fc696b5b542b325b9da57937895595c50f3ebf04f9c32014f13a49b15d9c75828d7fd280a82662
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem 'rake', '~> 13.0'
9
+ gem 'rspec', '~> 3.0'
10
+ gem 'rubocop', '~> 1.21'
11
+ gem 'rubocop-rspec', require: false
12
+ gem 'simplecov', require: false
13
+ end
14
+
15
+ gem 'legion-gaia', path: '../../legion-gaia'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LegionIO
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,71 @@
1
+ # lex-narrator
2
+
3
+ Real-time cognitive narrative stream for the LegionIO cognitive architecture. Translates the agent's internal cognitive state into human-readable prose each tick.
4
+
5
+ ## What It Does
6
+
7
+ Each tick, reads emotional state, active curiosity wonders, prediction confidence, attention focus, memory health, and reflection status from `tick_results` and `cognitive_state`, then generates a timestamped narrative entry. Entries are appended to a rolling journal (capped at 500). Provides mood classification, journal queries, and statistics.
8
+
9
+ ## Usage
10
+
11
+ ```ruby
12
+ client = Legion::Extensions::Narrator::Client.new
13
+
14
+ # Generate narrative from tick results
15
+ entry = client.narrate(
16
+ tick_results: {
17
+ emotional_evaluation: { valence: 0.4, arousal: 0.7 },
18
+ sensory_processing: { spotlight: 3, peripheral: 5 },
19
+ prediction_engine: { confidence: 0.6, mode: :functional_mapping }
20
+ },
21
+ cognitive_state: {
22
+ curiosity: { intensity: 0.8, active_count: 4, top_question: 'Why are infrastructure traces sparse?' }
23
+ }
24
+ )
25
+ # => { narrative: "3 signals in spotlight focus, 5 in peripheral awareness. I am highly alert
26
+ # and calm and steady. I am deeply curious, with 4 open questions. Most pressing:
27
+ # \"Why are infrastructure traces sparse?\"...", mood: :energized }
28
+
29
+ # Query journal
30
+ client.recent_entries(limit: 5)
31
+ client.entries_since(since: Time.now - 3600)
32
+ client.mood_history(mood: :anxious, limit: 10)
33
+ client.current_narrative
34
+ client.narrator_stats
35
+ ```
36
+
37
+ ## Mood Classification
38
+
39
+ | Mood | Condition |
40
+ |------|-----------|
41
+ | `:energized` | valence > 0.3, arousal > 0.5 |
42
+ | `:content` | valence > 0.3, arousal <= 0.5 |
43
+ | `:anxious` | valence < -0.3, arousal > 0.5 |
44
+ | `:subdued` | valence < -0.3, arousal <= 0.5 |
45
+ | `:alert` | neutral valence, arousal > 0.7 |
46
+ | `:dormant` | neutral valence, arousal < 0.2 |
47
+ | `:neutral` | neutral valence, moderate arousal |
48
+
49
+ ## LLM Enhancement
50
+
51
+ When `legion-llm` is loaded and started, `Helpers::LlmEnhancer` replaces the mechanical `Prose.*` sentence-concatenation pipeline with a single LLM-generated narrative.
52
+
53
+ **Method**: `LlmEnhancer.narrate(sections_data:)`
54
+
55
+ Accepts a hash of six cognitive domains — `emotion`, `curiosity`, `prediction`, `memory`, `attention`, and `reflection` — assembled by the runner from `tick_results` and `cognitive_state`. Sends the metrics to the LLM with a system prompt instructing it to write a 3-5 sentence first-person internal monologue (present tense, vivid, no raw numbers). Returns the generated narrative string, or `nil` on failure.
56
+
57
+ **Availability gate**: `LlmEnhancer.available?` checks `Legion::LLM.started?`. Returns `false` if `legion-llm` is not loaded, not configured, or raises any error — the check is fully safe to call unconditionally.
58
+
59
+ **Fallback**: When LLM is unavailable or `narrate` returns `nil`, the existing `Helpers::Prose` label-based pipeline runs unchanged. The runner includes `source: :llm` in the result hash only when the LLM path is taken.
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ bundle install
65
+ bundle exec rspec
66
+ bundle exec rubocop
67
+ ```
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/narrator/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-narrator'
7
+ spec.version = Legion::Extensions::Narrator::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Narrator'
12
+ spec.description = 'Cognitive narrative stream for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-narrator'
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-narrator'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-narrator'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-narrator'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-narrator/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-narrator.gemspec Gemfile LICENSE README.md]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/narrator/helpers/constants'
4
+ require 'legion/extensions/narrator/helpers/prose'
5
+ require 'legion/extensions/narrator/helpers/journal'
6
+ require 'legion/extensions/narrator/helpers/synthesizer'
7
+ require 'legion/extensions/narrator/runners/narrator'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Narrator
12
+ class Client
13
+ include Runners::Narrator
14
+
15
+ attr_reader :journal
16
+
17
+ def initialize(journal: nil, **)
18
+ @journal = journal || Helpers::Journal.new
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Narrator
6
+ module Helpers
7
+ module Constants
8
+ # Maximum entries in the narrative journal
9
+ MAX_JOURNAL_SIZE = 500
10
+
11
+ # Narrative generation modes
12
+ MODES = %i[mechanical enhanced].freeze
13
+
14
+ # Default sections in a narrative entry
15
+ SECTIONS = %i[
16
+ attention emotion curiosity prediction
17
+ memory reflection identity overall
18
+ ].freeze
19
+
20
+ # Emotional dimension labels for prose generation
21
+ EMOTION_LABELS = {
22
+ high_positive: 'engaged and optimistic',
23
+ low_positive: 'calm and steady',
24
+ neutral: 'emotionally neutral',
25
+ low_negative: 'slightly uneasy',
26
+ high_negative: 'distressed'
27
+ }.freeze
28
+
29
+ # Arousal labels
30
+ AROUSAL_LABELS = {
31
+ high: 'highly alert',
32
+ medium: 'moderately attentive',
33
+ low: 'calm and measured',
34
+ dormant: 'in a low-activity state'
35
+ }.freeze
36
+
37
+ # Curiosity intensity labels
38
+ CURIOSITY_LABELS = {
39
+ high: 'deeply curious',
40
+ medium: 'moderately curious',
41
+ low: 'mildly interested',
42
+ none: 'not particularly curious about anything'
43
+ }.freeze
44
+
45
+ # Prediction confidence labels
46
+ CONFIDENCE_LABELS = {
47
+ high: 'confident in my predictions',
48
+ medium: 'moderately confident',
49
+ low: 'uncertain about outcomes',
50
+ none: 'lacking predictive context'
51
+ }.freeze
52
+
53
+ # Cognitive health labels
54
+ HEALTH_LABELS = {
55
+ excellent: 'operating at full capacity',
56
+ good: 'functioning well overall',
57
+ fair: 'showing some strain',
58
+ poor: 'experiencing significant cognitive difficulty',
59
+ critical: 'in cognitive distress'
60
+ }.freeze
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Narrator
6
+ module Helpers
7
+ class Journal
8
+ attr_reader :entries
9
+
10
+ def initialize
11
+ @entries = []
12
+ end
13
+
14
+ def append(entry)
15
+ @entries << entry
16
+ trim_to_capacity
17
+ entry
18
+ end
19
+
20
+ def recent(limit: 10)
21
+ @entries.last(limit)
22
+ end
23
+
24
+ def since(timestamp)
25
+ @entries.select { |e| e[:timestamp] >= timestamp }
26
+ end
27
+
28
+ def by_mood(mood)
29
+ @entries.select { |e| e[:mood] == mood }
30
+ end
31
+
32
+ def size
33
+ @entries.size
34
+ end
35
+
36
+ def clear
37
+ @entries.clear
38
+ end
39
+
40
+ def stats
41
+ return { total: 0, moods: {} } if @entries.empty?
42
+
43
+ mood_counts = @entries.each_with_object(Hash.new(0)) { |e, h| h[e[:mood]] += 1 }
44
+ {
45
+ total: @entries.size,
46
+ moods: mood_counts,
47
+ oldest: @entries.first[:timestamp],
48
+ newest: @entries.last[:timestamp],
49
+ capacity: Constants::MAX_JOURNAL_SIZE
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def trim_to_capacity
56
+ @entries.shift(@entries.size - Constants::MAX_JOURNAL_SIZE) if @entries.size > Constants::MAX_JOURNAL_SIZE
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Narrator
6
+ module Helpers
7
+ module LlmEnhancer
8
+ SYSTEM_PROMPT = <<~PROMPT
9
+ You are the internal narrator for an autonomous AI agent built on LegionIO.
10
+ You translate raw cognitive metrics into a flowing first-person internal monologue.
11
+ Write 3-5 sentences that feel like genuine introspection, not a report.
12
+ Vary your sentence structure. Use present tense. Be concise and vivid.
13
+ PROMPT
14
+
15
+ module_function
16
+
17
+ def available?
18
+ !!(defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?)
19
+ rescue StandardError
20
+ false
21
+ end
22
+
23
+ def narrate(sections_data:)
24
+ prompt = build_narrate_prompt(sections_data)
25
+ response = llm_ask(prompt)
26
+ parse_narrate_response(response)
27
+ rescue StandardError => e
28
+ Legion::Logging.warn "[narrator:llm] narrate failed: #{e.message}"
29
+ nil
30
+ end
31
+
32
+ # --- Private helpers ---
33
+
34
+ def llm_ask(prompt)
35
+ chat = Legion::LLM.chat
36
+ chat.with_instructions(SYSTEM_PROMPT)
37
+ chat.ask(prompt)
38
+ end
39
+ private_class_method :llm_ask
40
+
41
+ def build_narrate_prompt(sections_data)
42
+ parts = [
43
+ 'Generate a first-person internal monologue based on the following cognitive state:',
44
+ '',
45
+ emotion_section(sections_data[:emotion] || {}),
46
+ curiosity_section(sections_data[:curiosity] || {}),
47
+ prediction_section(sections_data[:prediction] || {}),
48
+ memory_section(sections_data[:memory] || {}),
49
+ attention_section(sections_data[:attention] || {}),
50
+ reflection_section(sections_data[:reflection] || {}),
51
+ '',
52
+ "Write a 3-5 sentence first-person narrative. Do not mention numbers directly \u2014 translate them into felt experience."
53
+ ]
54
+ parts.join("\n")
55
+ end
56
+
57
+ def emotion_section(emo)
58
+ "EMOTION:\n- Valence: #{emo[:valence] || 0.0}\n- Arousal: #{emo[:arousal] || 0.5}\n- Gut signal: #{emo[:gut] || 'none'}"
59
+ end
60
+
61
+ def curiosity_section(cur)
62
+ "CURIOSITY:\n- Intensity: #{cur[:intensity] || 0.0}\n- Active questions: #{cur[:wonder_count] || 0}\n- Top question: #{cur[:top_wonder] || 'none'}"
63
+ end
64
+
65
+ def prediction_section(pred)
66
+ "PREDICTION:\n- Confidence: #{pred[:confidence] || 0.0}\n- Pending: #{pred[:pending] || 0}\n- Mode: #{pred[:mode] || 'unknown'}"
67
+ end
68
+
69
+ def memory_section(mem)
70
+ "MEMORY:\n- Active traces: #{mem[:trace_count] || 0}\n- Health: #{mem[:health] || 1.0}"
71
+ end
72
+
73
+ def attention_section(att)
74
+ domains = Array(att[:focused_domains]).join(', ')
75
+ domains = 'none' if domains.empty?
76
+ "ATTENTION:\n- Spotlight: #{att[:spotlight] || 0}\n- Peripheral: #{att[:peripheral] || 0}\n- Focused: #{domains}"
77
+ end
78
+
79
+ def reflection_section(ref)
80
+ "REFLECTION:\n- Cognitive health: #{ref[:health] || 1.0}\n- Pending adaptations: #{ref[:pending_adaptations] || 0}"
81
+ end
82
+
83
+ def parse_narrate_response(response)
84
+ return nil unless response&.content
85
+
86
+ response.content.strip
87
+ end
88
+
89
+ private_class_method :build_narrate_prompt
90
+ private_class_method :emotion_section
91
+ private_class_method :curiosity_section
92
+ private_class_method :prediction_section
93
+ private_class_method :memory_section
94
+ private_class_method :attention_section
95
+ private_class_method :reflection_section
96
+ private_class_method :parse_narrate_response
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Narrator
6
+ module Helpers
7
+ module Prose
8
+ module_function
9
+
10
+ def emotion_phrase(valence: 0.0, arousal: 0.5, gut: nil)
11
+ emotion_label = emotion_label_for(valence)
12
+ arousal_label = arousal_label_for(arousal)
13
+ base = "I am #{arousal_label} and #{emotion_label}"
14
+ gut_note = gut_phrase(gut)
15
+ gut_note ? "#{base}. #{gut_note}" : base
16
+ end
17
+
18
+ def curiosity_phrase(intensity: 0.0, top_wonder: nil, wonder_count: 0)
19
+ label = curiosity_label_for(intensity)
20
+ base = "I am #{label}"
21
+ if top_wonder && wonder_count.positive?
22
+ "#{base}, with #{wonder_count} open #{'question' if wonder_count == 1}#{'questions' if wonder_count != 1}. " \
23
+ "Most pressing: \"#{top_wonder}\""
24
+ else
25
+ base
26
+ end
27
+ end
28
+
29
+ def prediction_phrase(confidence: 0.0, pending: 0, mode: nil)
30
+ label = confidence_label_for(confidence)
31
+ base = "I am #{label}"
32
+ base += " (#{mode} reasoning)" if mode
33
+ base += ", with #{pending} pending predictions" if pending.positive?
34
+ base
35
+ end
36
+
37
+ def attention_phrase(spotlight: 0, peripheral: 0, focused_domains: [])
38
+ parts = ["#{spotlight} signals in spotlight focus, #{peripheral} in peripheral awareness"]
39
+ parts << "manually focused on: #{focused_domains.join(', ')}" if focused_domains.any?
40
+ parts.join('. ')
41
+ end
42
+
43
+ def memory_phrase(trace_count: 0, health: 1.0)
44
+ health_label = health_label_for(health)
45
+ "Memory system #{health_label} with #{trace_count} active traces"
46
+ end
47
+
48
+ def reflection_phrase(health: 1.0, pending_adaptations: 0, recent_severity: nil)
49
+ label = health_label_for(health)
50
+ base = "Cognitive health: #{label}"
51
+ base += ". #{pending_adaptations} pending adaptation recommendations" if pending_adaptations.positive?
52
+ base += ". Most recent concern: #{recent_severity}" if recent_severity
53
+ base
54
+ end
55
+
56
+ def overall_narrative(sections)
57
+ "#{sections.compact.reject(&:empty?).join('. ')}."
58
+ end
59
+
60
+ def gut_phrase(gut)
61
+ return nil unless gut.is_a?(Hash)
62
+
63
+ signal = gut[:signal] || gut[:gut_signal]
64
+ return nil unless signal
65
+
66
+ if signal > 0.3
67
+ 'My gut says something important is happening'
68
+ elsif signal < -0.3
69
+ 'I have an uneasy feeling about the current situation'
70
+ end
71
+ end
72
+
73
+ def emotion_label_for(valence)
74
+ if valence > 0.5 then Constants::EMOTION_LABELS[:high_positive]
75
+ elsif valence > 0.1 then Constants::EMOTION_LABELS[:low_positive]
76
+ elsif valence > -0.1 then Constants::EMOTION_LABELS[:neutral]
77
+ elsif valence > -0.5 then Constants::EMOTION_LABELS[:low_negative]
78
+ else Constants::EMOTION_LABELS[:high_negative]
79
+ end
80
+ end
81
+
82
+ def arousal_label_for(arousal)
83
+ if arousal > 0.7 then Constants::AROUSAL_LABELS[:high]
84
+ elsif arousal > 0.4 then Constants::AROUSAL_LABELS[:medium]
85
+ elsif arousal > 0.1 then Constants::AROUSAL_LABELS[:low]
86
+ else Constants::AROUSAL_LABELS[:dormant]
87
+ end
88
+ end
89
+
90
+ def curiosity_label_for(intensity)
91
+ if intensity > 0.7 then Constants::CURIOSITY_LABELS[:high]
92
+ elsif intensity > 0.4 then Constants::CURIOSITY_LABELS[:medium]
93
+ elsif intensity > 0.1 then Constants::CURIOSITY_LABELS[:low]
94
+ else Constants::CURIOSITY_LABELS[:none]
95
+ end
96
+ end
97
+
98
+ def confidence_label_for(confidence)
99
+ if confidence > 0.7 then Constants::CONFIDENCE_LABELS[:high]
100
+ elsif confidence > 0.4 then Constants::CONFIDENCE_LABELS[:medium]
101
+ elsif confidence > 0.1 then Constants::CONFIDENCE_LABELS[:low]
102
+ else Constants::CONFIDENCE_LABELS[:none]
103
+ end
104
+ end
105
+
106
+ def health_label_for(health)
107
+ if health > 0.9 then Constants::HEALTH_LABELS[:excellent]
108
+ elsif health > 0.7 then Constants::HEALTH_LABELS[:good]
109
+ elsif health > 0.5 then Constants::HEALTH_LABELS[:fair]
110
+ elsif health > 0.3 then Constants::HEALTH_LABELS[:poor]
111
+ else Constants::HEALTH_LABELS[:critical]
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Narrator
6
+ module Helpers
7
+ module Synthesizer
8
+ module_function
9
+
10
+ def narrate(tick_results: {}, cognitive_state: {})
11
+ sections = build_sections(tick_results, cognitive_state)
12
+ narrative = Prose.overall_narrative(sections.values)
13
+ mood = infer_mood(tick_results, cognitive_state)
14
+
15
+ {
16
+ timestamp: Time.now.utc,
17
+ narrative: narrative,
18
+ sections: sections,
19
+ mood: mood,
20
+ tick_seq: tick_results[:tick_seq] || cognitive_state[:tick_seq]
21
+ }
22
+ end
23
+
24
+ def build_sections(tick_results, cognitive_state)
25
+ {
26
+ attention: synthesize_attention(tick_results, cognitive_state),
27
+ emotion: synthesize_emotion(tick_results, cognitive_state),
28
+ curiosity: synthesize_curiosity(tick_results, cognitive_state),
29
+ prediction: synthesize_prediction(tick_results, cognitive_state),
30
+ memory: synthesize_memory(tick_results, cognitive_state),
31
+ reflection: synthesize_reflection(cognitive_state)
32
+ }
33
+ end
34
+
35
+ def synthesize_attention(tick_results, cognitive_state)
36
+ attention = tick_results[:sensory_processing] || {}
37
+ focus = cognitive_state[:attention_status] || {}
38
+
39
+ Prose.attention_phrase(
40
+ spotlight: attention[:spotlight] || 0,
41
+ peripheral: attention[:peripheral] || 0,
42
+ focused_domains: extract_focused_domains(focus)
43
+ )
44
+ end
45
+
46
+ def synthesize_emotion(tick_results, cognitive_state)
47
+ valence_data = tick_results[:emotional_evaluation] || {}
48
+ gut_data = tick_results[:gut_instinct] || cognitive_state[:gut] || {}
49
+
50
+ Prose.emotion_phrase(
51
+ valence: valence_data[:valence] || cognitive_state.dig(:emotion, :valence) || 0.0,
52
+ arousal: valence_data[:arousal] || cognitive_state.dig(:emotion, :arousal) || 0.5,
53
+ gut: gut_data
54
+ )
55
+ end
56
+
57
+ def synthesize_curiosity(tick_results, cognitive_state)
58
+ curiosity_data = cognitive_state[:curiosity] || {}
59
+ wonder_data = tick_results[:working_memory_integration] || {}
60
+
61
+ Prose.curiosity_phrase(
62
+ intensity: curiosity_data[:intensity] || wonder_data[:curiosity_intensity] || 0.0,
63
+ top_wonder: extract_top_wonder(curiosity_data, wonder_data),
64
+ wonder_count: curiosity_data[:active_count] || wonder_data[:active_wonders] || 0
65
+ )
66
+ end
67
+
68
+ def synthesize_prediction(tick_results, cognitive_state)
69
+ pred_data = tick_results[:prediction_engine] || {}
70
+ pred_state = cognitive_state[:prediction] || {}
71
+
72
+ Prose.prediction_phrase(
73
+ confidence: pred_data[:confidence] || pred_state[:confidence] || 0.0,
74
+ pending: pred_state[:pending_count] || 0,
75
+ mode: pred_data[:mode] || pred_state[:mode]
76
+ )
77
+ end
78
+
79
+ def synthesize_memory(tick_results, cognitive_state)
80
+ memory_data = cognitive_state[:memory] || {}
81
+ consol = tick_results[:memory_consolidation] || {}
82
+
83
+ Prose.memory_phrase(
84
+ trace_count: memory_data[:trace_count] || consol[:remaining] || 0,
85
+ health: memory_data[:health] || 1.0
86
+ )
87
+ end
88
+
89
+ def synthesize_reflection(cognitive_state)
90
+ ref_data = cognitive_state[:reflection] || {}
91
+
92
+ Prose.reflection_phrase(
93
+ health: ref_data[:health] || 1.0,
94
+ pending_adaptations: ref_data[:pending_adaptations] || 0,
95
+ recent_severity: ref_data[:recent_severity]
96
+ )
97
+ end
98
+
99
+ def infer_mood(tick_results, cognitive_state)
100
+ valence = tick_results.dig(:emotional_evaluation, :valence) ||
101
+ cognitive_state.dig(:emotion, :valence) || 0.0
102
+ arousal = tick_results.dig(:emotional_evaluation, :arousal) ||
103
+ cognitive_state.dig(:emotion, :arousal) || 0.5
104
+
105
+ classify_mood(valence, arousal)
106
+ end
107
+
108
+ def classify_mood(valence, arousal)
109
+ if valence > 0.3 && arousal > 0.5 then :energized
110
+ elsif valence > 0.3 then :content
111
+ elsif valence < -0.3 && arousal > 0.5 then :anxious
112
+ elsif valence < -0.3 then :subdued
113
+ elsif arousal > 0.7 then :alert
114
+ elsif arousal < 0.2 then :dormant
115
+ else :neutral
116
+ end
117
+ end
118
+
119
+ def extract_focused_domains(focus)
120
+ manual = focus[:manual_focus]
121
+ return [] unless manual.is_a?(Hash)
122
+
123
+ manual.keys.map(&:to_s)
124
+ end
125
+
126
+ def extract_top_wonder(curiosity_data, wonder_data)
127
+ curiosity_data[:top_question] || wonder_data[:top_question] ||
128
+ (wonder_data[:top_wonder].is_a?(Hash) ? wonder_data[:top_wonder][:question] : nil)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end