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,403 @@
1
+ # Igniter — LLM::Transcriber
2
+
3
+ `Igniter::LLM::Transcriber` is a first-class Executor for audio-to-text conversion.
4
+ It plugs into the Contract graph exactly like any other compute node, giving you
5
+ caching, dependency resolution, and parallel execution for free.
6
+
7
+ ```
8
+ audio file / URL
9
+
10
+
11
+ CallTranscriber ← LLM::Transcriber subclass
12
+ │ TranscriptResult
13
+ │ .text, .words, .speakers, .duration
14
+
15
+ CallExtractor ← LLM::Executor subclass
16
+ │ Hash (structured JSON)
17
+
18
+ CRM / DB
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Quick start
24
+
25
+ ```ruby
26
+ require "igniter/integrations/llm"
27
+
28
+ Igniter::LLM.configure do |c|
29
+ c.deepgram.api_key = ENV["DEEPGRAM_API_KEY"]
30
+ end
31
+
32
+ class MyTranscriber < Igniter::LLM::Transcriber
33
+ transcription_provider :deepgram
34
+ model "nova-3"
35
+ language "en"
36
+ diarize true
37
+
38
+ def call(audio_path:) = transcribe(audio_path)
39
+ end
40
+
41
+ result = MyTranscriber.call(audio_path: "meeting.mp3")
42
+ puts result.text
43
+ puts result.speakers.map { |s| "#{s.speaker}: #{s.text}" }.join("\n")
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Providers
49
+
50
+ ### OpenAI Whisper / gpt-4o-mini-transcribe
51
+
52
+ | | |
53
+ |---|---|
54
+ | **Sync** | Yes — single POST, immediate response |
55
+ | **Diarization** | **No** |
56
+ | **Word timestamps** | Yes |
57
+ | **Languages** | 99+ (auto-detect or `language:`) |
58
+ | **Max file size** | 20 MB |
59
+ | **Pricing** | whisper-1: `$0.006/min` · gpt-4o-mini-transcribe: `$0.003/min` |
60
+
61
+ ```ruby
62
+ class CallTranscriber < Igniter::LLM::Transcriber
63
+ transcription_provider :openai
64
+ model "gpt-4o-mini-transcribe" # or "whisper-1"
65
+ language "en"
66
+
67
+ def call(audio_url:) = transcribe(audio_url)
68
+ end
69
+ ```
70
+
71
+ **Extra options:** `prompt:` — context hint to improve spelling/vocabulary accuracy.
72
+
73
+ ---
74
+
75
+ ### Deepgram Nova-3
76
+
77
+ | | |
78
+ |---|---|
79
+ | **Sync** | Yes — binary POST, immediate response |
80
+ | **Diarization** | Yes (`diarize true`) |
81
+ | **Word timestamps** | Yes (always included) |
82
+ | **Languages** | 99+ Nova-3 Multilingual · single-language Nova-3 |
83
+ | **Pricing** | `$0.0077/min` (includes diarization, per-second billing) |
84
+
85
+ ```ruby
86
+ Igniter::LLM.configure do |c|
87
+ c.deepgram.api_key = ENV["DEEPGRAM_API_KEY"]
88
+ end
89
+
90
+ class CallTranscriber < Igniter::LLM::Transcriber
91
+ transcription_provider :deepgram
92
+ model "nova-3"
93
+ diarize true
94
+
95
+ def call(audio_url:) = transcribe(audio_url)
96
+ end
97
+ ```
98
+
99
+ **Extra options:**
100
+ ```ruby
101
+ transcribe(audio_url,
102
+ sentiment: true, # per-sentence sentiment scores
103
+ topics: true, # topic detection
104
+ intents: true, # intent recognition
105
+ summarize: true # extractive summary
106
+ )
107
+ ```
108
+
109
+ Results accessible via `result.raw["results"]["channels"][0]["alternatives"][0]`.
110
+
111
+ ---
112
+
113
+ ### AssemblyAI Universal-2
114
+
115
+ | | |
116
+ |---|---|
117
+ | **Sync** | **No** — async: upload → submit → poll |
118
+ | **Diarization** | Yes (`diarize true`) |
119
+ | **Word timestamps** | Yes (millisecond precision) |
120
+ | **Languages** | 99+ |
121
+ | **Pricing** | `$0.0025/min` base + `$0.0003/min` speaker labels ≈ `$0.0028/min` |
122
+ | **Free tier** | 333 hr/month |
123
+
124
+ ```ruby
125
+ Igniter::LLM.configure do |c|
126
+ c.assemblyai.api_key = ENV["ASSEMBLYAI_API_KEY"]
127
+ c.assemblyai.poll_interval = 3 # seconds between status checks
128
+ c.assemblyai.poll_timeout = 600 # fail-safe timeout in seconds
129
+ end
130
+
131
+ class CallTranscriber < Igniter::LLM::Transcriber
132
+ transcription_provider :assemblyai
133
+ diarize true
134
+ poll_interval 3
135
+ poll_timeout 600
136
+
137
+ def call(audio_url:) = transcribe(audio_url)
138
+ end
139
+ ```
140
+
141
+ **Extra options:**
142
+ ```ruby
143
+ transcribe(audio_url,
144
+ sentiment_analysis: true,
145
+ auto_chapters: true,
146
+ entity_detection: true,
147
+ pii_redact: [:phone_number, :name, :email],
148
+ custom_vocabulary: ["Acme", "thermidor"]
149
+ )
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Provider comparison
155
+
156
+ | Need | Recommended |
157
+ |------|-------------|
158
+ | Lowest cost, no speakers | `openai` + `gpt-4o-mini-transcribe` — $0.003/min |
159
+ | Lowest cost WITH speakers | `assemblyai` — $0.0028/min |
160
+ | Fastest response (sync) | `deepgram` — typical < 2 s |
161
+ | Richest features | `assemblyai` — chapters, PII, entity detection |
162
+ | Real-time / WebSocket | `deepgram` — same price as REST |
163
+
164
+ ---
165
+
166
+ ## DSL reference
167
+
168
+ ```ruby
169
+ class MyTranscriber < Igniter::LLM::Transcriber
170
+ # ── Required ──────────────────────────────────────────────────────────
171
+ transcription_provider :openai # :openai | :deepgram | :assemblyai
172
+
173
+ # ── Optional ──────────────────────────────────────────────────────────
174
+ model "nova-3" # defaults: openai→whisper-1, deepgram→nova-3,
175
+ # assemblyai→universal-2
176
+ language "en" # BCP-47; nil = auto-detect
177
+ diarize true # request speaker labels (not supported by OpenAI)
178
+ word_timestamps true # per-word start/end times (default: true)
179
+
180
+ # AssemblyAI async polling
181
+ poll_interval 3 # seconds between poll attempts (default: 2)
182
+ poll_timeout 600 # max seconds to wait (default: 300)
183
+
184
+ def call(**inputs)
185
+ transcribe(inputs[:audio_url])
186
+ # or with provider-specific extras:
187
+ transcribe(inputs[:audio_url], sentiment: true, topics: true)
188
+ end
189
+ end
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Result structure
195
+
196
+ ```ruby
197
+ result = MyTranscriber.call(audio_url: "call.mp3")
198
+
199
+ result.text # => "Hello, thank you for calling..."
200
+ result.language # => "en"
201
+ result.duration # => 243.5 (seconds)
202
+ result.provider # => :assemblyai
203
+ result.model # => "universal-2"
204
+ result.raw # => Hash — original provider response
205
+
206
+ # Word-level timestamps
207
+ result.words.first
208
+ # => #<TranscriptWord word="Hello" start_time=0.0 end_time=0.4 confidence=0.99 speaker="A">
209
+
210
+ # Speaker segments (when diarize: true)
211
+ result.speakers
212
+ # => [
213
+ # #<SpeakerSegment speaker="A" start_time=0.0 end_time=5.1 text="Hello, thank you...">,
214
+ # #<SpeakerSegment speaker="B" start_time=5.3 end_time=12.4 text="Hi, I need help...">,
215
+ # ...
216
+ # ]
217
+ ```
218
+
219
+ > **Note:** AssemblyAI uses letter labels (`"A"`, `"B"`, ...).
220
+ > Deepgram uses integers (`0`, `1`, ...).
221
+ > OpenAI has no speaker labels — `speakers` is `nil`.
222
+
223
+ ---
224
+
225
+ ## In a Contract graph
226
+
227
+ Transcriber is a full `Igniter::Executor`. Use it as a `compute:` node
228
+ with `cache_ttl:` to avoid re-transcribing the same audio on retries:
229
+
230
+ ```ruby
231
+ class CallAnalysisPipeline < Igniter::Contract
232
+ define do
233
+ input :audio_url
234
+ input :recorded_at, required: false
235
+
236
+ compute :transcript, call: CallTranscriber,
237
+ with: :audio_url,
238
+ cache_ttl: 86_400 # 24 h — same URL = free
239
+
240
+ compute :extraction, call: CallExtractor,
241
+ with: %i[transcript recorded_at]
242
+
243
+ output :transcript, from: :transcript
244
+ output :extraction, from: :extraction
245
+ end
246
+ end
247
+
248
+ result = CallAnalysisPipeline.call(
249
+ audio_url: "https://cdn.callrail.com/recordings/abc123.mp3",
250
+ recorded_at: call.created_at.iso8601
251
+ )
252
+ result[:transcript] # => TranscriptResult
253
+ result[:extraction] # => Hash from LLM analysis
254
+ ```
255
+
256
+ ---
257
+
258
+ ## Production: call-center CRM (Rails)
259
+
260
+ See `examples/llm/call_center_analysis.rb` for a complete runnable example.
261
+
262
+ ### Volume / cost estimate (27 000 min/month)
263
+
264
+ ```
265
+ AssemblyAI (transcript + diarization) ≈ $77/month
266
+ gpt-4o-mini (extraction, 14 000 calls) ≈ $4/month
267
+ ─────────────
268
+ Total ≈ $81/month
269
+ ```
270
+
271
+ ### Rails integration sketch
272
+
273
+ ```ruby
274
+ # app/models/call_recording.rb
275
+ class CallRecording < ApplicationRecord
276
+ after_create_commit :schedule_analysis, if: :audio_url?
277
+
278
+ private
279
+
280
+ def schedule_analysis = CallAnalysisJob.perform_later(id)
281
+ end
282
+
283
+ # app/jobs/call_analysis_job.rb
284
+ class CallAnalysisJob < ApplicationJob
285
+ queue_as :transcription
286
+ sidekiq_options retry: 3
287
+
288
+ def perform(id)
289
+ rec = CallRecording.find(id)
290
+ result = CallAnalysisPipeline.call(
291
+ audio_url: rec.audio_url,
292
+ recorded_at: rec.created_at.iso8601
293
+ )
294
+ t = result[:transcript]
295
+ e = result[:extraction]
296
+
297
+ rec.update!(
298
+ transcript_text: t.text,
299
+ duration_seconds: t.duration,
300
+ speakers_json: t.speakers&.map(&:to_h),
301
+ conversion: e["conversion"],
302
+ call_type: e["call_type"],
303
+ zip_codes: e["zip_codes"],
304
+ addresses: e["addresses"],
305
+ phones: e["phones"],
306
+ service_names: e["service_names"],
307
+ scheduled_at: e["scheduled_datetime"]&.then { Time.parse(_1) },
308
+ ai_confidence: e["confidence"],
309
+ ai_notes: e["notes"]
310
+ )
311
+ end
312
+ end
313
+ ```
314
+
315
+ ### Recommended PostgreSQL columns
316
+
317
+ ```ruby
318
+ add_column :call_recordings, :transcript_text, :text
319
+ add_column :call_recordings, :duration_seconds, :float
320
+ add_column :call_recordings, :speakers_json, :jsonb, default: []
321
+ add_column :call_recordings, :conversion, :boolean
322
+ add_column :call_recordings, :call_type, :string
323
+ add_column :call_recordings, :zip_codes, :string, array: true, default: []
324
+ add_column :call_recordings, :addresses, :string, array: true, default: []
325
+ add_column :call_recordings, :phones, :string, array: true, default: []
326
+ add_column :call_recordings, :service_names, :string, array: true, default: []
327
+ add_column :call_recordings, :scheduled_at, :datetime
328
+ add_column :call_recordings, :ai_confidence, :float
329
+ add_column :call_recordings, :ai_notes, :text
330
+
331
+ # Useful indexes
332
+ add_index :call_recordings, :conversion
333
+ add_index :call_recordings, :call_type
334
+ add_index :call_recordings, :zip_codes, using: :gin
335
+ add_index :call_recordings, :service_names, using: :gin
336
+ ```
337
+
338
+ ---
339
+
340
+ ## Roadmap: Real-time operator assistance (Asterisk)
341
+
342
+ Once you own the Asterisk PBX, the path to live analysis:
343
+
344
+ ```
345
+ Asterisk RTP stream
346
+ │ (audio chunks via AGI/AMI)
347
+
348
+ Deepgram WebSocket
349
+ │ partial + final transcript events
350
+
351
+ Igniter Actor (CallMonitorAgent)
352
+ │ detects key events in real-time
353
+
354
+ ActionCable → Operator UI
355
+ │ "ZIP 77001 detected — pull nearby techs"
356
+ │ "Customer says 'Samsung washer' — show checklist"
357
+ │ "Sentiment dropping — offer discount code"
358
+ ```
359
+
360
+ **Cost for real-time Deepgram:** same `$0.0077/min` (no surcharge vs REST).
361
+ Total with real-time: ~$212/month vs $81/month batch.
362
+ The premium pays for live operator assistance and reduced handle time.
363
+
364
+ ### Agent sketch
365
+
366
+ ```ruby
367
+ class CallMonitorAgent < Igniter::Agent
368
+ on :transcript_chunk do |payload|
369
+ text = payload[:text]
370
+ next if text.length < 40 # wait for meaningful chunks
371
+
372
+ hints = ContextExtractor.call(partial_text: text)
373
+ next if hints.empty?
374
+
375
+ broadcast(:operator_hint,
376
+ call_id: payload[:call_id],
377
+ operator_id: payload[:operator_id],
378
+ hints: hints)
379
+ end
380
+ end
381
+ ```
382
+
383
+ ---
384
+
385
+ ## Configuration reference
386
+
387
+ ```ruby
388
+ Igniter::LLM.configure do |c|
389
+ # OpenAI (used by both chat executors and openai transcription provider)
390
+ c.openai.api_key = ENV["OPENAI_API_KEY"]
391
+ c.openai.base_url = "https://api.openai.com" # override for Azure/proxy
392
+ c.openai.timeout = 120
393
+
394
+ # Deepgram
395
+ c.deepgram.api_key = ENV["DEEPGRAM_API_KEY"]
396
+ c.deepgram.timeout = 300 # pre-recorded audio; allow for large files
397
+
398
+ # AssemblyAI
399
+ c.assemblyai.api_key = ENV["ASSEMBLYAI_API_KEY"]
400
+ c.assemblyai.poll_interval = 3 # start polling after N seconds
401
+ c.assemblyai.poll_timeout = 600 # raise ProviderError if not done in time
402
+ end
403
+ ```
data/examples/README.md CHANGED
@@ -328,6 +328,43 @@ log_entries=1
328
328
  done=true
329
329
  ```
330
330
 
331
+ ### `companion/demo.rb`
332
+
333
+ Run:
334
+
335
+ ```bash
336
+ ruby examples/companion/demo.rb
337
+ ```
338
+
339
+ Shows:
340
+
341
+ - `Igniter::Application` — unified entry point with `config_file`, `configure`, `register`, `schedule`
342
+ - `compose` + `export` — four-stage pipeline (ASR → Intent → Chat → TTS) wired as one graph
343
+ - Mock executors — runs end-to-end without hardware or API keys
344
+ - Turn-by-turn interactive loop with session history
345
+
346
+ For real Ollama inference:
347
+
348
+ ```bash
349
+ COMPANION_REAL_LLM=1 ruby examples/companion/demo.rb
350
+ ```
351
+
352
+ Expected output per turn:
353
+
354
+ ```text
355
+ ── Turn 1 ────────────────────────────────────────────
356
+ [ASR mock] → "Hello, are you there?"
357
+ [Intent mock] → question
358
+ [Chat mock] → "I'd need a moment to look that up..."
359
+ [TTS mock] → synthesising 76 chars
360
+ Heard: "Hello, are you there?"
361
+ Intent: question (92%)
362
+ Response: "I'd need a moment to look that up..."
363
+ Audio: 4328 chars (Base64 WAV)
364
+ ```
365
+
366
+ See [`companion/README.md`](companion/README.md) for distributed deployment (k3s), ESP32 setup, and real hardware instructions.
367
+
331
368
  ## Validation
332
369
 
333
370
  These scripts are exercised by [example_scripts_spec.rb](/Users/alex/dev/hotfix/igniter/spec/igniter/example_scripts_spec.rb), so the documented commands and outputs stay aligned with the code.
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ # examples/consensus.rb
4
+ #
5
+ # Demonstrates Igniter::Consensus — a Raft-inspired consensus cluster.
6
+ #
7
+ # Two APIs are shown:
8
+ #
9
+ # High-level Cluster.start / write / read / read_contract
10
+ # Low-level Raw Igniter::Contract with Consensus executors (BidAuction)
11
+ #
12
+ # The Raft protocol (leader election, log replication, quorum commits) is
13
+ # fully encapsulated inside Igniter::Consensus::Node. Users only interact
14
+ # with Cluster and, optionally, a custom StateMachine subclass.
15
+ #
16
+ # Run: bundle exec ruby examples/consensus.rb
17
+
18
+ require "igniter/consensus"
19
+
20
+ puts "=" * 62
21
+ puts " Igniter::Consensus Demo (5-node Raft cluster)"
22
+ puts "=" * 62
23
+
24
+ NODES = %i[n1 n2 n3 n4 n5].freeze
25
+
26
+ # ─────────────────────────────────────────────────────────────────────────────
27
+ # [1] Start cluster with the default KV state machine
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+ puts "\n[1] Starting #{NODES.size}-node cluster"
30
+
31
+ cluster = Igniter::Consensus::Cluster.start(nodes: NODES)
32
+ puts " Nodes: #{NODES.join(", ")}"
33
+ puts " Quorum needed: #{cluster.quorum_size}/#{NODES.size}"
34
+
35
+ # ─────────────────────────────────────────────────────────────────────────────
36
+ # [2] Wait for a leader — Cluster#wait_for_leader polls until election completes
37
+ # ─────────────────────────────────────────────────────────────────────────────
38
+ puts "\n[2] Waiting for leader election..."
39
+
40
+ leader_ref = cluster.wait_for_leader
41
+ puts " Leader: #{leader_ref.state[:node_id]} term=#{leader_ref.state[:term]}"
42
+
43
+ # ─────────────────────────────────────────────────────────────────────────────
44
+ # [3] Writes — Cluster#write dispatches to the leader
45
+ # ─────────────────────────────────────────────────────────────────────────────
46
+ puts "\n[3] Writing to consensus log"
47
+
48
+ cluster.write(key: :price, value: 99)
49
+ cluster.write(key: :available, value: true)
50
+ sleep 0.5 # allow replication
51
+
52
+ # ─────────────────────────────────────────────────────────────────────────────
53
+ # [4] Cluster status via Cluster#status
54
+ # ─────────────────────────────────────────────────────────────────────────────
55
+ puts "\n[4] Cluster state after replication"
56
+ puts " #{"node".ljust(5)} #{"role".ljust(10)} #{"term".ljust(5)} " \
57
+ "#{"log".ljust(4)} #{"ci".ljust(4)} state_machine"
58
+ puts " " + "-" * 58
59
+
60
+ cluster.status.each do |s|
61
+ puts " #{s[:node_id].to_s.ljust(5)} #{s[:role].to_s.ljust(10)} " \
62
+ "#{s[:term].to_s.ljust(5)} #{s[:log_size].to_s.ljust(4)} " \
63
+ "#{s[:commit_index].to_s.ljust(4)} #{s[:state_machine].inspect}"
64
+ end
65
+
66
+ # ─────────────────────────────────────────────────────────────────────────────
67
+ # [5] ReadQuery contract — declarative graph: find_leader → read_value
68
+ # ─────────────────────────────────────────────────────────────────────────────
69
+ puts "\n[5] ReadQuery contract: reading :price"
70
+
71
+ q = cluster.read_contract(key: :price)
72
+ q.resolve_all
73
+ puts " :price=#{q.result.value}"
74
+
75
+ # ─────────────────────────────────────────────────────────────────────────────
76
+ # [6] Leader crash → automatic failover
77
+ # ─────────────────────────────────────────────────────────────────────────────
78
+ old_leader_id = leader_ref.state[:node_id]
79
+ puts "\n[6] Crashing leader #{old_leader_id}..."
80
+
81
+ Igniter::Registry.find(old_leader_id)&.kill
82
+ Igniter::Registry.unregister(old_leader_id)
83
+
84
+ surviving_ids = NODES.reject { |n| n == old_leader_id }
85
+ surviving = Igniter::Consensus::Cluster.new(nodes: surviving_ids)
86
+
87
+ puts " Waiting for new election..."
88
+ new_leader_ref = surviving.wait_for_leader
89
+ puts " New leader: #{new_leader_ref.state[:node_id]} term=#{new_leader_ref.state[:term]}"
90
+
91
+ # ─────────────────────────────────────────────────────────────────────────────
92
+ # [7] Post-failover write + read via ReadQuery
93
+ # ─────────────────────────────────────────────────────────────────────────────
94
+ puts "\n[7] Write after failover"
95
+
96
+ surviving.write(key: :price, value: 150)
97
+ sleep 0.4
98
+
99
+ q2 = surviving.read_contract(key: :price)
100
+ q2.resolve_all
101
+ puts " :price after failover = #{q2.result.value}"
102
+
103
+ # ─────────────────────────────────────────────────────────────────────────────
104
+ # [8] Custom state machine — counter with typed commands
105
+ # ─────────────────────────────────────────────────────────────────────────────
106
+ puts "\n[8] Custom state machine (counter)"
107
+
108
+ class CounterMachine < Igniter::Consensus::StateMachine
109
+ apply :increment do |state, cmd|
110
+ state.merge(cmd[:key] => (state[cmd[:key]] || 0) + cmd[:by])
111
+ end
112
+ apply :reset do |state, cmd|
113
+ state.merge(cmd[:key] => 0)
114
+ end
115
+ end
116
+
117
+ counter_cluster = Igniter::Consensus::Cluster.start(
118
+ nodes: %i[cx1 cx2 cx3],
119
+ state_machine: CounterMachine,
120
+ )
121
+ counter_cluster.wait_for_leader
122
+ counter_cluster.write(type: :increment, key: :page_views, by: 100)
123
+ counter_cluster.write(type: :increment, key: :page_views, by: 250)
124
+ sleep 0.4
125
+ puts " page_views = #{counter_cluster.read(:page_views)}"
126
+ counter_cluster.stop!
127
+
128
+ # ─────────────────────────────────────────────────────────────────────────────
129
+ # [9] BidAuction — parallel bid submission with durable consensus log
130
+ #
131
+ # Key Igniter properties:
132
+ # 1. bid1/bid2/bid3 have no mutual deps → thread_pool submits them concurrently
133
+ # 2. winner depends on all three → runs only after every bid is logged
134
+ # 3. Same SubmitBid executor reused for all three bids (captures dep name via **)
135
+ # ─────────────────────────────────────────────────────────────────────────────
136
+ puts "\n[9] BidAuction — three vendors submit bids in parallel"
137
+
138
+ class SubmitBid < Igniter::Executor
139
+ # Called with: cluster: + one named bid dep (vendor1_bid / vendor2_bid / …).
140
+ # The dep name differs per compute node; ** captures whichever is passed.
141
+ def call(cluster:, **bid_kwarg)
142
+ bid = bid_kwarg.values.first # { vendor_id:, price: }
143
+ ref = cluster.leader
144
+ raise Igniter::ResolutionError, "No leader — cannot submit bid" unless ref
145
+ ref.send(:client_write, command: { key: :"bid_#{bid[:vendor_id]}", value: bid[:price] })
146
+ bid
147
+ end
148
+ end
149
+
150
+ class SelectWinner < Igniter::Executor
151
+ def call(bid1:, bid2:, bid3:)
152
+ [bid1, bid2, bid3].min_by { |b| b[:price] }
153
+ end
154
+ end
155
+
156
+ class BidAuction < Igniter::Contract
157
+ runner :thread_pool, pool_size: 3 # bid1, bid2, bid3 run concurrently
158
+
159
+ define do
160
+ input :cluster
161
+ input :vendor1_bid # { vendor_id: String, price: Float }
162
+ input :vendor2_bid
163
+ input :vendor3_bid
164
+
165
+ compute :bid1, with: [:cluster, :vendor1_bid], call: SubmitBid
166
+ compute :bid2, with: [:cluster, :vendor2_bid], call: SubmitBid
167
+ compute :bid3, with: [:cluster, :vendor3_bid], call: SubmitBid
168
+
169
+ compute :winner, with: [:bid1, :bid2, :bid3], call: SelectWinner
170
+
171
+ output :winner
172
+ end
173
+ end
174
+
175
+ auction = BidAuction.new(
176
+ cluster: surviving,
177
+ vendor1_bid: { vendor_id: "alpha", price: 45.00 },
178
+ vendor2_bid: { vendor_id: "betacor", price: 38.50 },
179
+ vendor3_bid: { vendor_id: "gamma", price: 52.00 },
180
+ )
181
+ auction.resolve_all
182
+ winner = auction.result.winner
183
+ puts " Winner: vendor=#{winner[:vendor_id]} price=$#{"%.2f" % winner[:price]}"
184
+
185
+ sleep 0.4
186
+ puts " Bids in consensus log:"
187
+ surviving.status.each do |s|
188
+ bids = s[:state_machine].select { |k, _| k.to_s.start_with?("bid_") }
189
+ .map { |k, v| "#{k}=$#{"%.2f" % v}" }
190
+ .join(" ")
191
+ puts " #{s[:node_id].to_s.ljust(5)} #{s[:role].to_s.ljust(10)} #{bids}"
192
+ end
193
+
194
+ # ─────────────────────────────────────────────────────────────────────────────
195
+ # [10] Quorum failure — Raft's CP safety guarantee
196
+ #
197
+ # With only 2/5 nodes alive (< quorum 3), no leader can be elected.
198
+ # The cluster becomes unavailable rather than returning stale/inconsistent data.
199
+ # ─────────────────────────────────────────────────────────────────────────────
200
+ puts "\n[10] Quorum failure: Raft's CP guarantee"
201
+
202
+ # Identify the current leader among surviving nodes, then kill it plus one follower
203
+ minority_ids = surviving_ids.reject { |n|
204
+ ref = Igniter::Registry.find(n)
205
+ ref&.alive? && ref.state[:role] == :leader
206
+ }.first(2)
207
+
208
+ (surviving_ids - minority_ids).each do |nid|
209
+ Igniter::Registry.find(nid)&.kill rescue nil
210
+ Igniter::Registry.unregister(nid) rescue nil
211
+ end
212
+
213
+ puts " Surviving: #{minority_ids.join(", ")} (quorum needs #{cluster.quorum_size}/#{NODES.size})"
214
+ puts " Waiting — no leader should be elected..."
215
+ sleep Igniter::Consensus::ELECTION_TIMEOUT_BASE +
216
+ Igniter::Consensus::ELECTION_TIMEOUT_JITTER + 0.3
217
+
218
+ minority_roles = minority_ids.map { |n|
219
+ ref = Igniter::Registry.find(n)
220
+ ref&.alive? ? "#{n}:#{ref.state[:role]}" : "#{n}:dead"
221
+ }
222
+ puts " States: #{minority_roles.join(" ")}"
223
+
224
+ minority_cluster = Igniter::Consensus::Cluster.new(nodes: minority_ids)
225
+ puts " ConsensusQuery with #{minority_ids.size}/#{NODES.size} nodes:"
226
+ begin
227
+ minority_cluster.read_contract(key: :price).resolve_all
228
+ puts " UNEXPECTED: query succeeded"
229
+ rescue Igniter::Error => e
230
+ puts " → #{e.class.name.split("::").last}: #{e.message}"
231
+ puts " (correct — cluster is unavailable, not returning stale data)"
232
+ end
233
+
234
+ # ─────────────────────────────────────────────────────────────────────────────
235
+ # Cleanup
236
+ # ─────────────────────────────────────────────────────────────────────────────
237
+ minority_ids.each { |n| Igniter::Registry.find(n)&.stop(timeout: 2) rescue nil }
238
+ Igniter::Registry.clear
239
+ puts "\nDone."