igniter 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +217 -0
  3. data/docs/APPLICATION_V1.md +253 -0
  4. data/docs/CAPABILITIES_V1.md +207 -0
  5. data/docs/CONSENSUS_V1.md +477 -0
  6. data/docs/CONTENT_ADDRESSING_V1.md +221 -0
  7. data/docs/DATAFLOW_V1.md +274 -0
  8. data/docs/MESH_V1.md +732 -0
  9. data/docs/NODE_CACHE_V1.md +324 -0
  10. data/docs/PROACTIVE_AGENTS_V1.md +293 -0
  11. data/docs/SERVER_V1.md +200 -1
  12. data/docs/SKILLS_V1.md +213 -0
  13. data/docs/STORE_ADAPTERS.md +41 -13
  14. data/docs/TEMPORAL_V1.md +174 -0
  15. data/docs/TOOLS_V1.md +347 -0
  16. data/docs/TRANSCRIPTION_V1.md +403 -0
  17. data/examples/README.md +37 -0
  18. data/examples/consensus.rb +239 -0
  19. data/examples/dataflow.rb +308 -0
  20. data/examples/elocal_webhook.rb +1 -0
  21. data/examples/incremental.rb +142 -0
  22. data/examples/llm_tools.rb +237 -0
  23. data/examples/mesh.rb +239 -0
  24. data/examples/mesh_discovery.rb +267 -0
  25. data/examples/mesh_gossip.rb +162 -0
  26. data/examples/ringcentral_routing.rb +1 -1
  27. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  28. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  29. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  30. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  31. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  32. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  33. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  34. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  35. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  36. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  37. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  38. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  39. data/lib/igniter/agents/proactive_agent.rb +208 -0
  40. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  41. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  42. data/lib/igniter/agents.rb +56 -0
  43. data/lib/igniter/application/app_config.rb +32 -0
  44. data/lib/igniter/application/autoloader.rb +18 -0
  45. data/lib/igniter/application/generator.rb +157 -0
  46. data/lib/igniter/application/scheduler.rb +109 -0
  47. data/lib/igniter/application/yml_loader.rb +39 -0
  48. data/lib/igniter/application.rb +174 -0
  49. data/lib/igniter/capabilities.rb +68 -0
  50. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  51. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  52. data/lib/igniter/consensus/cluster.rb +183 -0
  53. data/lib/igniter/consensus/errors.rb +14 -0
  54. data/lib/igniter/consensus/executors.rb +43 -0
  55. data/lib/igniter/consensus/node.rb +320 -0
  56. data/lib/igniter/consensus/read_query.rb +30 -0
  57. data/lib/igniter/consensus/state_machine.rb +58 -0
  58. data/lib/igniter/consensus.rb +58 -0
  59. data/lib/igniter/content_addressing.rb +133 -0
  60. data/lib/igniter/contract.rb +12 -0
  61. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  62. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  63. data/lib/igniter/dataflow/diff.rb +37 -0
  64. data/lib/igniter/dataflow/diff_state.rb +81 -0
  65. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  66. data/lib/igniter/dataflow/window_filter.rb +48 -0
  67. data/lib/igniter/dataflow.rb +65 -0
  68. data/lib/igniter/dsl/contract_builder.rb +71 -7
  69. data/lib/igniter/executor.rb +60 -0
  70. data/lib/igniter/extensions/capabilities.rb +39 -0
  71. data/lib/igniter/extensions/content_addressing.rb +5 -0
  72. data/lib/igniter/extensions/dataflow.rb +117 -0
  73. data/lib/igniter/extensions/incremental.rb +50 -0
  74. data/lib/igniter/extensions/mesh.rb +31 -0
  75. data/lib/igniter/fingerprint.rb +43 -0
  76. data/lib/igniter/incremental/formatter.rb +81 -0
  77. data/lib/igniter/incremental/result.rb +69 -0
  78. data/lib/igniter/incremental/tracker.rb +108 -0
  79. data/lib/igniter/incremental.rb +50 -0
  80. data/lib/igniter/integrations/llm/config.rb +48 -4
  81. data/lib/igniter/integrations/llm/executor.rb +221 -28
  82. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  83. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  84. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  85. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  86. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  87. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  88. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  89. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  90. data/lib/igniter/integrations/llm.rb +37 -1
  91. data/lib/igniter/memory/agent_memory.rb +104 -0
  92. data/lib/igniter/memory/episode.rb +29 -0
  93. data/lib/igniter/memory/fact.rb +27 -0
  94. data/lib/igniter/memory/memorable.rb +90 -0
  95. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  96. data/lib/igniter/memory/reflection_record.rb +28 -0
  97. data/lib/igniter/memory/store.rb +115 -0
  98. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  99. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  100. data/lib/igniter/memory.rb +80 -0
  101. data/lib/igniter/mesh/announcer.rb +55 -0
  102. data/lib/igniter/mesh/config.rb +45 -0
  103. data/lib/igniter/mesh/discovery.rb +39 -0
  104. data/lib/igniter/mesh/errors.rb +31 -0
  105. data/lib/igniter/mesh/gossip.rb +47 -0
  106. data/lib/igniter/mesh/peer.rb +21 -0
  107. data/lib/igniter/mesh/peer_registry.rb +51 -0
  108. data/lib/igniter/mesh/poller.rb +77 -0
  109. data/lib/igniter/mesh/router.rb +109 -0
  110. data/lib/igniter/mesh.rb +85 -0
  111. data/lib/igniter/metrics/collector.rb +131 -0
  112. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  113. data/lib/igniter/metrics/snapshot.rb +8 -0
  114. data/lib/igniter/metrics.rb +37 -0
  115. data/lib/igniter/model/aggregate_node.rb +34 -0
  116. data/lib/igniter/model/collection_node.rb +3 -2
  117. data/lib/igniter/model/compute_node.rb +13 -0
  118. data/lib/igniter/model/remote_node.rb +18 -2
  119. data/lib/igniter/node_cache.rb +231 -0
  120. data/lib/igniter/replication/bootstrapper.rb +61 -0
  121. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  122. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  123. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  124. data/lib/igniter/replication/expansion_plan.rb +38 -0
  125. data/lib/igniter/replication/expansion_planner.rb +142 -0
  126. data/lib/igniter/replication/manifest.rb +45 -0
  127. data/lib/igniter/replication/network_topology.rb +123 -0
  128. data/lib/igniter/replication/node_role.rb +42 -0
  129. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  130. data/lib/igniter/replication/replication_agent.rb +87 -0
  131. data/lib/igniter/replication/role_registry.rb +73 -0
  132. data/lib/igniter/replication/ssh_session.rb +77 -0
  133. data/lib/igniter/replication.rb +54 -0
  134. data/lib/igniter/runtime/cache.rb +35 -6
  135. data/lib/igniter/runtime/execution.rb +26 -2
  136. data/lib/igniter/runtime/input_validator.rb +6 -2
  137. data/lib/igniter/runtime/node_state.rb +7 -2
  138. data/lib/igniter/runtime/resolver.rb +323 -31
  139. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  140. data/lib/igniter/server/client.rb +44 -1
  141. data/lib/igniter/server/config.rb +13 -6
  142. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  143. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  144. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  145. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  146. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  147. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  148. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  149. data/lib/igniter/server/http_server.rb +54 -17
  150. data/lib/igniter/server/router.rb +54 -21
  151. data/lib/igniter/server/server_logger.rb +52 -0
  152. data/lib/igniter/server.rb +6 -0
  153. data/lib/igniter/skill/feedback.rb +116 -0
  154. data/lib/igniter/skill/output_schema.rb +110 -0
  155. data/lib/igniter/skill.rb +218 -0
  156. data/lib/igniter/temporal.rb +84 -0
  157. data/lib/igniter/tool/discoverable.rb +151 -0
  158. data/lib/igniter/tool.rb +52 -0
  159. data/lib/igniter/tool_registry.rb +144 -0
  160. data/lib/igniter/version.rb +1 -1
  161. data/lib/igniter.rb +17 -0
  162. metadata +128 -1
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LLM
5
+ module Transcription
6
+ module Providers
7
+ # OpenAI Whisper transcription provider.
8
+ #
9
+ # API: POST /v1/audio/transcriptions (multipart/form-data)
10
+ # Docs: https://platform.openai.com/docs/api-reference/audio
11
+ #
12
+ # Limitations vs other providers:
13
+ # - No speaker diarization (speakers will be nil in result)
14
+ # - 20 MB file size limit
15
+ # - Only word-level granularity (no sentence-level)
16
+ #
17
+ # Extra options passed through:
18
+ # prompt: String — context hint to guide spelling/vocabulary
19
+ # response_format: Symbol — :srt or :vtt stores raw subtitles in result.raw[:subtitle_text]
20
+ class OpenAI < Base
21
+ API_BASE = "https://api.openai.com"
22
+
23
+ def initialize(api_key: ENV["OPENAI_API_KEY"], base_url: API_BASE, timeout: 120)
24
+ super()
25
+ @api_key = api_key
26
+ @base_url = base_url.to_s.chomp("/")
27
+ @timeout = timeout
28
+ end
29
+
30
+ def transcribe(audio_source, model:, language: nil, _diarize: false, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
31
+ word_timestamps: true, **options)
32
+ validate_api_key!
33
+
34
+ audio = read_audio(audio_source)
35
+ fname = filename_from(audio_source)
36
+ ctype = audio_content_type(fname)
37
+
38
+ fields = {
39
+ "file" => { data: audio, filename: fname, content_type: ctype },
40
+ "model" => model,
41
+ "response_format" => "verbose_json"
42
+ }
43
+ fields["language"] = language if language
44
+ fields["prompt"] = options[:prompt] if options[:prompt]
45
+ # Request word-level timestamps
46
+ fields["timestamp_granularities[]"] = "word" if word_timestamps
47
+
48
+ body, boundary = build_multipart(fields)
49
+ raw = post_multipart("/v1/audio/transcriptions", body, boundary)
50
+ build_result(raw, model: model)
51
+ end
52
+
53
+ private
54
+
55
+ def build_result(raw, model:) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
56
+ words = (raw["words"] || []).map do |w|
57
+ TranscriptWord.new(
58
+ word: w["word"].to_s.strip,
59
+ start_time: w["start"].to_f,
60
+ end_time: w["end"].to_f,
61
+ confidence: w["confidence"]&.to_f,
62
+ speaker: nil # Whisper has no diarization
63
+ )
64
+ end
65
+
66
+ TranscriptResult.new(
67
+ text: raw["text"].to_s,
68
+ words: words,
69
+ speakers: nil,
70
+ language: raw["language"],
71
+ duration: raw["duration"]&.to_f,
72
+ provider: :openai,
73
+ model: model,
74
+ raw: raw
75
+ )
76
+ end
77
+
78
+ def post_multipart(path, body, boundary)
79
+ uri = URI.parse("#{@base_url}#{path}")
80
+ http = http_for(uri)
81
+
82
+ request = Net::HTTP::Post.new(uri.path)
83
+ request["Authorization"] = "Bearer #{@api_key}"
84
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
85
+ request.body = body
86
+
87
+ handle_response(http.request(request))
88
+ rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout => e
89
+ raise Igniter::LLM::ProviderError, "Cannot connect to OpenAI API: #{e.message}"
90
+ end
91
+
92
+ def validate_api_key!
93
+ return if @api_key && !@api_key.empty?
94
+
95
+ raise Igniter::LLM::ConfigurationError,
96
+ "OpenAI API key not configured. Set OPENAI_API_KEY or pass api_key: to the provider."
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LLM
5
+ # Base class for audio transcription executors.
6
+ #
7
+ # A Transcriber is a fully-fledged Igniter::Executor — it can be used as a
8
+ # compute node in a Contract graph, paired with cache_ttl:, and composed
9
+ # with downstream LLM::Executor skills:
10
+ #
11
+ # class CallTranscriber < Igniter::LLM::Transcriber
12
+ # transcription_provider :deepgram
13
+ # model "nova-3"
14
+ # language "ru"
15
+ # diarize true
16
+ #
17
+ # def call(audio_path:)
18
+ # transcribe(audio_path)
19
+ # end
20
+ # end
21
+ #
22
+ # # Standalone
23
+ # result = CallTranscriber.call(audio_path: "call_123.mp3")
24
+ # result.text # => "Добрый день, чем могу помочь?"
25
+ # result.speakers # => [#<SpeakerSegment speaker=0 ...>, ...]
26
+ #
27
+ # # Inside a Contract graph
28
+ # compute :transcript, call: CallTranscriber, with: :audio_path, cache_ttl: 3600
29
+ class Transcriber < Igniter::Executor
30
+ PROVIDERS = %i[openai deepgram assemblyai].freeze
31
+ DEFAULT_MODELS = { openai: "whisper-1", deepgram: "nova-3", assemblyai: "universal-2" }.freeze
32
+
33
+ class << self
34
+ def inherited(subclass)
35
+ super
36
+ TRANSCRIBER_IVARS.each do |ivar|
37
+ subclass.instance_variable_set(ivar, instance_variable_get(ivar))
38
+ end
39
+ end
40
+
41
+ # ── Provider DSL ───────────────────────────────────────────────────
42
+
43
+ def transcription_provider(name = nil)
44
+ return @transcription_provider if name.nil?
45
+
46
+ name = name.to_sym
47
+ unless PROVIDERS.include?(name)
48
+ raise ArgumentError, "Unknown transcription provider #{name.inspect}. Available: #{PROVIDERS.inspect}"
49
+ end
50
+
51
+ @transcription_provider = name
52
+ end
53
+
54
+ def model(name = nil)
55
+ return @transcription_model if name.nil?
56
+
57
+ @transcription_model = name.to_s
58
+ end
59
+
60
+ def language(lang = nil)
61
+ return @language if lang.nil?
62
+
63
+ @language = lang.to_s
64
+ end
65
+
66
+ def diarize(bool = nil)
67
+ return @diarize.nil? ? false : @diarize if bool.nil?
68
+
69
+ @diarize = bool
70
+ end
71
+ alias diarize? diarize
72
+
73
+ def word_timestamps(bool = nil)
74
+ return @word_timestamps.nil? || @word_timestamps if bool.nil?
75
+
76
+ @word_timestamps = bool
77
+ end
78
+
79
+ # ── Polling DSL (AssemblyAI) ───────────────────────────────────────
80
+
81
+ # Seconds between poll attempts (uses exponential backoff up to 30s).
82
+ def poll_interval(secs = nil)
83
+ return @poll_interval || 2 if secs.nil?
84
+
85
+ @poll_interval = secs.to_f
86
+ end
87
+
88
+ # Maximum total seconds to wait for async transcription.
89
+ def poll_timeout(secs = nil)
90
+ return @poll_timeout || 300 if secs.nil?
91
+
92
+ @poll_timeout = secs.to_f
93
+ end
94
+ end
95
+
96
+ # All instance variables the DSL manages — copied in inherited hook.
97
+ TRANSCRIBER_IVARS = %i[
98
+ @transcription_provider @transcription_model @language
99
+ @diarize @word_timestamps @poll_interval @poll_timeout
100
+ ].freeze
101
+ private_constant :TRANSCRIBER_IVARS
102
+
103
+ protected
104
+
105
+ # Transcribe audio and return a TranscriptResult.
106
+ # Call this from your #call implementation.
107
+ #
108
+ # @param audio_source [String, Pathname, IO] File path or IO object.
109
+ # @param options [Hash] Provider-specific extras forwarded verbatim.
110
+ # @return [Transcription::TranscriptResult]
111
+ def transcribe(audio_source, **options)
112
+ provider_instance.transcribe(
113
+ audio_source,
114
+ model: current_model,
115
+ language: self.class.language,
116
+ diarize: self.class.diarize?,
117
+ word_timestamps: self.class.word_timestamps,
118
+ poll_interval: self.class.poll_interval,
119
+ poll_timeout: self.class.poll_timeout,
120
+ **options
121
+ )
122
+ end
123
+
124
+ private
125
+
126
+ def provider_instance
127
+ pname = self.class.transcription_provider
128
+ unless pname
129
+ raise Igniter::LLM::ConfigurationError,
130
+ "#{self.class.name}: transcription_provider not configured. " \
131
+ "Call transcription_provider :openai, :deepgram, or :assemblyai in your class."
132
+ end
133
+
134
+ @provider_instance ||= Igniter::LLM.transcription_provider_instance(pname)
135
+ end
136
+
137
+ def current_model
138
+ self.class.model ||
139
+ Transcriber::DEFAULT_MODELS[self.class.transcription_provider] ||
140
+ raise(Igniter::LLM::ConfigurationError,
141
+ "No model configured for provider #{self.class.transcription_provider}")
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LLM
5
+ module Transcription
6
+ # Single word with timing and optional speaker label.
7
+ # end_time is used instead of `end` to avoid the Ruby keyword conflict.
8
+ TranscriptWord = Struct.new(:word, :start_time, :end_time, :confidence, :speaker, keyword_init: true)
9
+
10
+ # A contiguous block of speech from one speaker.
11
+ SpeakerSegment = Struct.new(:speaker, :start_time, :end_time, :text, keyword_init: true)
12
+
13
+ # Normalised result returned by every Transcription provider.
14
+ #
15
+ # @attr text [String] Full transcript text.
16
+ # @attr words [Array<TranscriptWord>] Word-level timestamps (empty Array if not requested).
17
+ # @attr speakers [Array<SpeakerSegment>, nil] Speaker segments or nil when diarization was not requested.
18
+ # @attr language [String, nil] BCP-47 language code detected/specified (e.g. "en", "ru").
19
+ # @attr duration [Float, nil] Audio duration in seconds.
20
+ # @attr provider [Symbol] Provider used (:openai, :deepgram, :assemblyai).
21
+ # @attr model [String] Model string (e.g. "whisper-1", "nova-3").
22
+ # @attr raw [Hash] Original provider response — provider-specific extras live here.
23
+ TranscriptResult = Struct.new(
24
+ :text, :words, :speakers, :language, :duration, :provider, :model, :raw,
25
+ keyword_init: true
26
+ )
27
+ end
28
+ end
29
+ end
@@ -8,6 +8,12 @@ require_relative "llm/providers/ollama"
8
8
  require_relative "llm/providers/anthropic"
9
9
  require_relative "llm/providers/openai"
10
10
  require_relative "llm/executor"
11
+ require_relative "llm/transcription/transcript_result"
12
+ require_relative "llm/transcription/providers/base"
13
+ require_relative "llm/transcription/providers/openai"
14
+ require_relative "llm/transcription/providers/deepgram"
15
+ require_relative "llm/transcription/providers/assemblyai"
16
+ require_relative "llm/transcription/transcriber"
11
17
 
12
18
  module Igniter
13
19
  module LLM
@@ -32,14 +38,21 @@ module Igniter
32
38
  @provider_instances[name.to_sym] ||= build_provider(name.to_sym)
33
39
  end
34
40
 
41
+ # Returns a memoized transcription provider instance.
42
+ def transcription_provider_instance(name)
43
+ @transcription_provider_instances ||= {}
44
+ @transcription_provider_instances[name.to_sym] ||= build_transcription_provider(name.to_sym)
45
+ end
46
+
35
47
  # Reset cached provider instances (useful after reconfiguration).
36
48
  def reset_providers!
37
49
  @provider_instances = nil
50
+ @transcription_provider_instances = nil
38
51
  end
39
52
 
40
53
  private
41
54
 
42
- def build_provider(name)
55
+ def build_provider(name) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
43
56
  case name
44
57
  when :ollama
45
58
  cfg = config.ollama
@@ -54,6 +67,29 @@ module Igniter
54
67
  raise ConfigurationError, "Unknown LLM provider: #{name}. Available: #{AVAILABLE_PROVIDERS.inspect}"
55
68
  end
56
69
  end
70
+
71
+ def build_transcription_provider(name) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
72
+ case name
73
+ when :openai
74
+ cfg = config.openai
75
+ Transcription::Providers::OpenAI.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
76
+ when :deepgram
77
+ cfg = config.deepgram
78
+ Transcription::Providers::Deepgram.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
79
+ when :assemblyai
80
+ cfg = config.assemblyai
81
+ Transcription::Providers::AssemblyAI.new(
82
+ api_key: cfg.api_key,
83
+ base_url: cfg.base_url,
84
+ timeout: cfg.timeout,
85
+ poll_interval: cfg.poll_interval,
86
+ poll_timeout: cfg.poll_timeout
87
+ )
88
+ else
89
+ raise ConfigurationError,
90
+ "Unknown transcription provider: #{name}. Available: #{Config::TRANSCRIPTION_PROVIDERS.inspect}"
91
+ end
92
+ end
57
93
  end
58
94
  end
59
95
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Memory
5
+ # Facade that pre-fills +agent_id+ on all Store operations.
6
+ #
7
+ # Callers interact with AgentMemory instead of the raw Store so they never
8
+ # have to pass +agent_id:+ explicitly. An optional +session_id+ groups
9
+ # episodes within a single interaction session.
10
+ #
11
+ # @example
12
+ # store = Igniter::Memory::Stores::InMemory.new
13
+ # memory = Igniter::Memory::AgentMemory.new(store: store, agent_id: "MyAgent:1")
14
+ #
15
+ # memory.record(type: :tool_call, content: "searched web for Ruby docs")
16
+ # memory.recall(query: "Ruby") # => [Episode, ...]
17
+ # memory.remember(:user_tz, "UTC")
18
+ # memory.facts # => { "user_tz" => Fact }
19
+ class AgentMemory
20
+ # @param store [Store] backing store implementation
21
+ # @param agent_id [String] identifier for this agent
22
+ # @param session_id [String, nil] optional session grouping key
23
+ def initialize(store:, agent_id:, session_id: nil)
24
+ @store = store
25
+ @agent_id = agent_id
26
+ @session_id = session_id
27
+ end
28
+
29
+ # Record a new episode.
30
+ #
31
+ # @param type [String, Symbol] category tag
32
+ # @param content [String] textual description of the event
33
+ # @param outcome [String, nil] result label, e.g. "success"/"failure"
34
+ # @param importance [Float] relevance weight 0.0–1.0
35
+ # @return [Episode]
36
+ def record(type:, content:, outcome: nil, importance: 0.5)
37
+ @store.record(
38
+ agent_id: @agent_id,
39
+ type: type,
40
+ content: content,
41
+ session_id: @session_id,
42
+ outcome: outcome,
43
+ importance: importance
44
+ )
45
+ end
46
+
47
+ # Keyword-search episodes for this agent.
48
+ #
49
+ # @param query [String, nil] search term; nil returns most recent
50
+ # @param limit [Integer] maximum results (default 10)
51
+ # @param type [String, Symbol, nil] optional type filter
52
+ # @return [Array<Episode>]
53
+ def recall(query: nil, limit: 10, type: nil)
54
+ @store.retrieve(agent_id: @agent_id, query: query, limit: limit, type: type)
55
+ end
56
+
57
+ # Store or update a named fact.
58
+ #
59
+ # @param key [String, Symbol] fact name
60
+ # @param value [Object] fact value
61
+ # @param confidence [Float] confidence score 0.0–1.0
62
+ # @return [Fact]
63
+ def remember(key, value, confidence: 1.0)
64
+ @store.store_fact(agent_id: @agent_id, key: key, value: value, confidence: confidence)
65
+ end
66
+
67
+ # Returns all stored facts for this agent.
68
+ #
69
+ # @return [Hash{String => Fact}]
70
+ def facts
71
+ @store.facts(agent_id: @agent_id)
72
+ end
73
+
74
+ # Returns recent episodes.
75
+ #
76
+ # @param last [Integer] maximum episodes to return
77
+ # @param type [String, Symbol, nil] optional type filter
78
+ # @return [Array<Episode>]
79
+ def recent(last: 20, type: nil)
80
+ @store.episodes(agent_id: @agent_id, last: last, type: type)
81
+ end
82
+
83
+ # Run a reflection cycle and record the result.
84
+ #
85
+ # @param current_system_prompt [String, nil] current prompt to compare against
86
+ # @return [ReflectionRecord]
87
+ def reflect(current_system_prompt: nil)
88
+ ReflectionCycle.new(store: @store).reflect(
89
+ agent_id: @agent_id,
90
+ current_system_prompt: current_system_prompt
91
+ )
92
+ end
93
+
94
+ # Check whether the agent should run a reflection cycle.
95
+ #
96
+ # Returns true when the recent failure rate exceeds the threshold.
97
+ #
98
+ # @return [Boolean]
99
+ def should_reflect?
100
+ ReflectionCycle.new(store: @store).should_reflect?(agent_id: @agent_id)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Memory
5
+ # Immutable value object representing a single recorded event in agent history.
6
+ #
7
+ # @!attribute [r] id
8
+ # @return [Integer] unique identifier within the store
9
+ # @!attribute [r] agent_id
10
+ # @return [String] identifier of the agent that recorded this episode
11
+ # @!attribute [r] session_id
12
+ # @return [String, nil] optional session grouping identifier
13
+ # @!attribute [r] ts
14
+ # @return [Integer] Unix timestamp of when the episode was recorded
15
+ # @!attribute [r] type
16
+ # @return [String, Symbol] category of the episode (e.g. :tool_call, :response)
17
+ # @!attribute [r] content
18
+ # @return [String] textual description of what happened
19
+ # @!attribute [r] outcome
20
+ # @return [String, nil] result descriptor, e.g. "success" or "failure"
21
+ # @!attribute [r] importance
22
+ # @return [Float] relevance weight in [0.0, 1.0], default 0.5
23
+ Episode = Struct.new(
24
+ :id, :agent_id, :session_id, :ts,
25
+ :type, :content, :outcome, :importance,
26
+ keyword_init: true
27
+ )
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Memory
5
+ # Immutable value object representing a persisted learned fact for an agent.
6
+ #
7
+ # Facts represent stable knowledge extracted from experience, keyed by a
8
+ # string name and associated with a confidence score.
9
+ #
10
+ # @!attribute [r] id
11
+ # @return [Integer] unique identifier within the store
12
+ # @!attribute [r] agent_id
13
+ # @return [String] identifier of the agent that owns this fact
14
+ # @!attribute [r] key
15
+ # @return [String] fact name (e.g. "user_timezone", "preferred_format")
16
+ # @!attribute [r] value
17
+ # @return [Object] the fact's value
18
+ # @!attribute [r] confidence
19
+ # @return [Float] confidence score in [0.0, 1.0], default 1.0
20
+ # @!attribute [r] updated_at
21
+ # @return [Integer] Unix timestamp of last upsert
22
+ Fact = Struct.new(
23
+ :id, :agent_id, :key, :value, :confidence, :updated_at,
24
+ keyword_init: true
25
+ )
26
+ end
27
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Memory
5
+ # Mixin that adds episodic memory capabilities to Igniter::Agent and
6
+ # Igniter::LLM::Executor subclasses (or any class that includes it).
7
+ #
8
+ # Including +Memorable+ adds:
9
+ # * A class-level +enable_memory+ DSL method for opt-in activation
10
+ # * A +memory+ instance accessor returning a bound AgentMemory facade
11
+ # * Automatic agent_id derived from the class name and object_id
12
+ #
13
+ # Memory is NOT enabled by default — call +enable_memory+ in the class body.
14
+ #
15
+ # == Example
16
+ #
17
+ # class MyAgent < Igniter::Agent
18
+ # include Igniter::Memory::Memorable
19
+ #
20
+ # enable_memory store: Igniter::Memory::Stores::InMemory.new
21
+ #
22
+ # on :task do |state:, payload:, **|
23
+ # memory.record(type: :task, content: payload[:description])
24
+ # # ... handle task
25
+ # state
26
+ # end
27
+ # end
28
+ #
29
+ # == Inheritance
30
+ #
31
+ # Subclasses inherit +memory_enabled?+ and +memory_store+ from their parent.
32
+ # Each subclass can override them independently.
33
+ module Memorable
34
+ def self.included(base)
35
+ base.extend(ClassMethods)
36
+ base.instance_variable_set(:@memory_store, nil)
37
+ base.instance_variable_set(:@memory_enabled, false)
38
+ end
39
+
40
+ module ClassMethods
41
+ # Enable episodic memory for this class.
42
+ #
43
+ # @param store [Store, nil] backing store; falls back to Igniter::Memory.default_store
44
+ # @return [void]
45
+ def enable_memory(store: nil)
46
+ @memory_enabled = true
47
+ @memory_store = store || Igniter::Memory.default_store
48
+ end
49
+
50
+ # Returns the configured memory store, walking up the inheritance chain
51
+ # if none is set on this class.
52
+ #
53
+ # @return [Store]
54
+ def memory_store
55
+ @memory_store ||
56
+ (superclass.respond_to?(:memory_store) ? superclass.memory_store : Igniter::Memory.default_store)
57
+ end
58
+
59
+ # Returns whether memory has been enabled for this class or any ancestor.
60
+ #
61
+ # @return [Boolean]
62
+ def memory_enabled?
63
+ @memory_enabled ||
64
+ (superclass.respond_to?(:memory_enabled?) && superclass.memory_enabled?)
65
+ end
66
+ end
67
+
68
+ # Returns the AgentMemory facade bound to this instance.
69
+ #
70
+ # Lazily initialised on first access. The agent_id incorporates the class
71
+ # name and object_id to keep instances isolated.
72
+ #
73
+ # @return [AgentMemory]
74
+ def memory
75
+ @memory ||= AgentMemory.new(
76
+ store: self.class.memory_store,
77
+ agent_id: memory_agent_id,
78
+ session_id: respond_to?(:session_id, true) ? session_id : nil
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ def memory_agent_id
85
+ cls_name = self.class.name || "anonymous"
86
+ "#{cls_name}:#{object_id}"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Memory
5
+ # Evaluates recent episodes and produces a ReflectionRecord.
6
+ #
7
+ # ReflectionCycle can operate in two modes:
8
+ #
9
+ # * **Rule-based** (default) — analyses failure rates using simple heuristics,
10
+ # requiring no LLM. Always available.
11
+ # * **LLM-assisted** — delegates to an LLM executor for a richer summary and
12
+ # an optional system-prompt patch. Enabled by passing an +llm:+ object.
13
+ #
14
+ # @example Rule-based reflection
15
+ # cycle = Igniter::Memory::ReflectionCycle.new(store: store)
16
+ # rec = cycle.reflect(agent_id: "MyAgent:1")
17
+ # # => ReflectionRecord (summary describes failure breakdown)
18
+ #
19
+ # @example With failure threshold check
20
+ # cycle.should_reflect?(agent_id: "MyAgent:1")
21
+ # # => true when recent failures >= DEFAULT_FAILURE_THRESHOLD
22
+ class ReflectionCycle
23
+ DEFAULT_FAILURE_THRESHOLD = 5
24
+ DEFAULT_WINDOW = 50
25
+
26
+ # @param store [Store] backing episode store
27
+ # @param failure_threshold [Integer] minimum failures to trigger reflection
28
+ # @param window [Integer] how many recent episodes to inspect
29
+ # @param llm [#call, nil] optional LLM executor for smart reflection
30
+ def initialize(store:, failure_threshold: DEFAULT_FAILURE_THRESHOLD,
31
+ window: DEFAULT_WINDOW, llm: nil)
32
+ @store = store
33
+ @failure_threshold = failure_threshold
34
+ @window = window
35
+ @llm = llm
36
+ end
37
+
38
+ # Check whether a reflection cycle should be triggered.
39
+ #
40
+ # Returns true when the number of recent "failure" outcomes meets or
41
+ # exceeds the configured +failure_threshold+.
42
+ #
43
+ # @param agent_id [String]
44
+ # @return [Boolean]
45
+ def should_reflect?(agent_id:)
46
+ recent = @store.episodes(agent_id: agent_id, last: @window)
47
+ failures = recent.count { |e| e.outcome == "failure" }
48
+ failures >= @failure_threshold
49
+ end
50
+
51
+ # Run a reflection cycle and persist the result.
52
+ #
53
+ # Analyses the most recent +window+ episodes and produces a
54
+ # ReflectionRecord with a summary (and optional system-prompt patch when
55
+ # an LLM is configured).
56
+ #
57
+ # @param agent_id [String]
58
+ # @param current_system_prompt [String, nil] passed to the LLM if available
59
+ # @return [ReflectionRecord]
60
+ def reflect(agent_id:, current_system_prompt: nil) # rubocop:disable Metrics/MethodLength
61
+ recent = @store.episodes(agent_id: agent_id, last: @window)
62
+ summary, patch = if @llm
63
+ smart_reflect(recent, current_system_prompt)
64
+ else
65
+ rule_based_reflect(recent)
66
+ end
67
+
68
+ @store.record_reflection(
69
+ agent_id: agent_id,
70
+ summary: summary,
71
+ system_patch: patch
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def rule_based_reflect(episodes) # rubocop:disable Metrics/CyclomaticComplexity
78
+ failures = episodes.select { |e| e.outcome == "failure" }
79
+ failure_types = failures.group_by(&:type).transform_values(&:count)
80
+ top = failure_types.max_by { |_, v| v }
81
+
82
+ summary = "#{failures.size}/#{episodes.size} failures. " \
83
+ "Top failure type: #{top&.first || "none"} (#{top&.last || 0}\xc3\x97)"
84
+ [summary, nil]
85
+ end
86
+
87
+ def smart_reflect(episodes, current_system_prompt)
88
+ result = @llm.call(
89
+ episodes: episodes.map { |e| { type: e.type, content: e.content, outcome: e.outcome } },
90
+ current_system_prompt: current_system_prompt
91
+ )
92
+ [result[:summary], result[:system_patch]]
93
+ end
94
+ end
95
+ end
96
+ end