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,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Memory
5
+ # Immutable value object representing the output of a reflection cycle.
6
+ #
7
+ # A ReflectionRecord captures the summary and optional system-prompt patch
8
+ # produced by a ReflectionCycle run. It can be applied to update an agent's
9
+ # system prompt or used as an audit trail.
10
+ #
11
+ # @!attribute [r] id
12
+ # @return [Integer] unique identifier within the store
13
+ # @!attribute [r] agent_id
14
+ # @return [String] identifier of the agent this reflection belongs to
15
+ # @!attribute [r] ts
16
+ # @return [Integer] Unix timestamp when the reflection was recorded
17
+ # @!attribute [r] summary
18
+ # @return [String] human-readable summary of findings
19
+ # @!attribute [r] system_patch
20
+ # @return [String, nil] optional suggested replacement/patch for system prompt
21
+ # @!attribute [r] applied
22
+ # @return [Boolean] whether this reflection has been applied to the agent
23
+ ReflectionRecord = Struct.new(
24
+ :id, :agent_id, :ts, :summary, :system_patch, :applied,
25
+ keyword_init: true
26
+ )
27
+ end
28
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Memory
5
+ # Abstract base class defining the Store interface for episodic memory.
6
+ #
7
+ # Concrete adapters must subclass Store and implement all public methods.
8
+ # All methods raise +NotImplementedError+ by default.
9
+ #
10
+ # == Interface
11
+ #
12
+ # store.record(agent_id:, type:, content:) # => Episode
13
+ # store.episodes(agent_id:, last: 50) # => Array<Episode>
14
+ # store.retrieve(agent_id:, query: "keyword") # => Array<Episode>
15
+ # store.store_fact(agent_id:, key:, value:)
16
+ # store.facts(agent_id:) # => Hash{key => Fact}
17
+ # store.record_reflection(agent_id:, summary:) # => ReflectionRecord
18
+ # store.reflections(agent_id:) # => Array<ReflectionRecord>
19
+ # store.apply_reflection(id:) # => true/false
20
+ # store.clear(agent_id:)
21
+ class Store
22
+ # Record an episode. Returns the persisted Episode.
23
+ #
24
+ # @param agent_id [String] identifier of the owning agent
25
+ # @param type [String, Symbol] category tag for the episode
26
+ # @param content [String] textual description of the event
27
+ # @param session_id [String, nil] optional session grouping key
28
+ # @param outcome [String, nil] result label, e.g. "success"/"failure"
29
+ # @param importance [Float] relevance weight 0.0-1.0 (default 0.5)
30
+ # @return [Episode]
31
+ def record(agent_id:, type:, content:, session_id: nil, outcome: nil, importance: 0.5) # rubocop:disable Metrics/ParameterLists
32
+ raise NotImplementedError, "#{self.class}#record not implemented"
33
+ end
34
+
35
+ # Return episodes for an agent, newest last.
36
+ #
37
+ # @param agent_id [String] identifier of the agent
38
+ # @param last [Integer] maximum number of episodes to return
39
+ # @param type [String, Symbol, nil] optional type filter
40
+ # @return [Array<Episode>]
41
+ def episodes(agent_id:, last: 50, type: nil)
42
+ raise NotImplementedError, "#{self.class}#episodes not implemented"
43
+ end
44
+
45
+ # Keyword-search episodes. Returns Array<Episode>.
46
+ #
47
+ # When +query+ is nil, returns the last +limit+ episodes.
48
+ # When +query+ is provided, filters by case-insensitive substring or FTS match.
49
+ #
50
+ # @param agent_id [String]
51
+ # @param query [String, nil] search term
52
+ # @param limit [Integer] maximum results (default 10)
53
+ # @param type [String, Symbol, nil] optional type filter
54
+ # @return [Array<Episode>]
55
+ def retrieve(agent_id:, query: nil, limit: 10, type: nil)
56
+ raise NotImplementedError, "#{self.class}#retrieve not implemented"
57
+ end
58
+
59
+ # Upsert a fact for an agent.
60
+ #
61
+ # @param agent_id [String] identifier of the owning agent
62
+ # @param key [String] fact name
63
+ # @param value [Object] fact value
64
+ # @param confidence [Float] confidence score 0.0-1.0 (default 1.0)
65
+ # @return [Fact]
66
+ def store_fact(agent_id:, key:, value:, confidence: 1.0)
67
+ raise NotImplementedError, "#{self.class}#store_fact not implemented"
68
+ end
69
+
70
+ # Returns all facts for an agent as a Hash keyed by string key.
71
+ #
72
+ # @param agent_id [String]
73
+ # @return [Hash{String => Fact}]
74
+ def facts(agent_id:)
75
+ raise NotImplementedError, "#{self.class}#facts not implemented"
76
+ end
77
+
78
+ # Store a reflection record. Returns the persisted ReflectionRecord.
79
+ #
80
+ # @param agent_id [String] owning agent identifier
81
+ # @param summary [String] human-readable reflection summary
82
+ # @param system_patch [String, nil] optional suggested system prompt patch
83
+ # @param applied [Boolean] whether already applied (default false)
84
+ # @return [ReflectionRecord]
85
+ def record_reflection(agent_id:, summary:, system_patch: nil, applied: false)
86
+ raise NotImplementedError, "#{self.class}#record_reflection not implemented"
87
+ end
88
+
89
+ # Returns reflection records for an agent.
90
+ #
91
+ # @param agent_id [String]
92
+ # @param applied [Boolean, nil] nil returns all; true/false filters by applied flag
93
+ # @return [Array<ReflectionRecord>]
94
+ def reflections(agent_id:, applied: nil)
95
+ raise NotImplementedError, "#{self.class}#reflections not implemented"
96
+ end
97
+
98
+ # Mark a reflection as applied.
99
+ #
100
+ # @param id [Integer] reflection identifier
101
+ # @return [Boolean] true if found and updated, false if not found
102
+ def apply_reflection(id:)
103
+ raise NotImplementedError, "#{self.class}#apply_reflection not implemented"
104
+ end
105
+
106
+ # Clear all stored data for an agent.
107
+ #
108
+ # @param agent_id [String]
109
+ # @return [void]
110
+ def clear(agent_id:)
111
+ raise NotImplementedError, "#{self.class}#clear not implemented"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Memory
5
+ module Stores
6
+ # Thread-safe in-memory implementation of the Store interface.
7
+ #
8
+ # Stores all data in process memory. Suitable for testing and
9
+ # single-process applications where persistence is not required.
10
+ # All operations are protected by a single Mutex for thread safety.
11
+ #
12
+ # @example
13
+ # store = Igniter::Memory::Stores::InMemory.new
14
+ # ep = store.record(agent_id: "bot:1", type: :tool_call, content: "searched web")
15
+ # store.episodes(agent_id: "bot:1") # => [ep]
16
+ class InMemory < Store
17
+ def initialize # rubocop:disable Lint/MissingSuper
18
+ @episodes = []
19
+ @facts = {}
20
+ @reflections = []
21
+ @seq = 0
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+ # @see Store#record
26
+ def record(agent_id:, type:, content:, session_id: nil, outcome: nil, importance: 0.5) # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
27
+ @mutex.synchronize do
28
+ ep = Episode.new(
29
+ id: next_id,
30
+ agent_id: agent_id,
31
+ session_id: session_id,
32
+ ts: Time.now.to_i,
33
+ type: type,
34
+ content: content,
35
+ outcome: outcome,
36
+ importance: importance
37
+ )
38
+ @episodes << ep
39
+ ep
40
+ end
41
+ end
42
+
43
+ # @see Store#episodes
44
+ def episodes(agent_id:, last: 50, type: nil)
45
+ @mutex.synchronize do
46
+ result = @episodes.select { |e| e.agent_id == agent_id }
47
+ result = result.select { |e| e.type == type } if type
48
+ result.last(last)
49
+ end
50
+ end
51
+
52
+ # @see Store#retrieve
53
+ def retrieve(agent_id:, query: nil, limit: 10, type: nil)
54
+ eps = episodes(agent_id: agent_id, last: 1000, type: type)
55
+ return eps.last(limit) unless query
56
+
57
+ q = query.to_s.downcase
58
+ eps.select { |e| e.content.to_s.downcase.include?(q) }.last(limit)
59
+ end
60
+
61
+ # @see Store#store_fact
62
+ def store_fact(agent_id:, key:, value:, confidence: 1.0) # rubocop:disable Metrics/MethodLength
63
+ @mutex.synchronize do
64
+ @facts[agent_id] ||= {}
65
+ fact = Fact.new(
66
+ id: next_id,
67
+ agent_id: agent_id,
68
+ key: key.to_s,
69
+ value: value,
70
+ confidence: confidence,
71
+ updated_at: Time.now.to_i
72
+ )
73
+ @facts[agent_id][key.to_s] = fact
74
+ fact
75
+ end
76
+ end
77
+
78
+ # @see Store#facts
79
+ def facts(agent_id:)
80
+ @mutex.synchronize { (@facts[agent_id] || {}).dup }
81
+ end
82
+
83
+ # @see Store#record_reflection
84
+ def record_reflection(agent_id:, summary:, system_patch: nil, applied: false) # rubocop:disable Metrics/MethodLength
85
+ @mutex.synchronize do
86
+ rec = ReflectionRecord.new(
87
+ id: next_id,
88
+ agent_id: agent_id,
89
+ ts: Time.now.to_i,
90
+ summary: summary,
91
+ system_patch: system_patch,
92
+ applied: applied
93
+ )
94
+ @reflections << rec
95
+ rec
96
+ end
97
+ end
98
+
99
+ # @see Store#reflections
100
+ def reflections(agent_id:, applied: nil)
101
+ @mutex.synchronize do
102
+ result = @reflections.select { |r| r.agent_id == agent_id }
103
+ applied.nil? ? result : result.select { |r| r.applied == applied }
104
+ end
105
+ end
106
+
107
+ # @see Store#apply_reflection
108
+ def apply_reflection(id:)
109
+ @mutex.synchronize do
110
+ rec = @reflections.find { |r| r.id == id }
111
+ return false unless rec
112
+
113
+ idx = @reflections.index(rec)
114
+ @reflections[idx] = ReflectionRecord.new(**rec.to_h.merge(applied: true))
115
+ true
116
+ end
117
+ end
118
+
119
+ # @see Store#clear
120
+ def clear(agent_id:)
121
+ @mutex.synchronize do
122
+ @episodes.reject! { |e| e.agent_id == agent_id }
123
+ @facts.delete(agent_id)
124
+ @reflections.reject! { |r| r.agent_id == agent_id }
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def next_id
131
+ @seq += 1
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Memory
5
+ module Stores
6
+ # SQLite-backed persistent Store implementation.
7
+ #
8
+ # Uses SQLite3 with FTS5 for fast full-text search on episode content.
9
+ # Requires the +sqlite3+ gem (soft dependency — not declared in the gemspec).
10
+ # Raises +Igniter::Memory::ConfigurationError+ if the gem is not available.
11
+ #
12
+ # @example In-memory SQLite (for tests)
13
+ # store = Igniter::Memory::Stores::SQLite.new(path: ":memory:")
14
+ #
15
+ # @example Persistent file
16
+ # store = Igniter::Memory::Stores::SQLite.new(path: "/tmp/agent_memory.db")
17
+ class SQLite < Store # rubocop:disable Metrics/ClassLength
18
+ # @param path [String] file path for the database, or ":memory:" for transient storage
19
+ def initialize(path:) # rubocop:disable Lint/MissingSuper
20
+ require "sqlite3"
21
+ rescue LoadError
22
+ raise ConfigurationError,
23
+ "SQLite store requires the 'sqlite3' gem. Add it to your Gemfile: gem 'sqlite3'"
24
+ else
25
+ @mutex = Mutex.new
26
+ @db = ::SQLite3::Database.new(path)
27
+ @db.results_as_hash = true
28
+ create_schema!
29
+ end
30
+
31
+ # @see Store#record
32
+ def record(agent_id:, type:, content:, session_id: nil, outcome: nil, importance: 0.5) # rubocop:disable Metrics/ParameterLists
33
+ @mutex.synchronize do
34
+ @db.execute(
35
+ "INSERT INTO episodes (agent_id, session_id, ts, type, content, outcome, importance) " \
36
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
37
+ [agent_id, session_id, Time.now.to_i, type.to_s, content.to_s, outcome, importance.to_f]
38
+ )
39
+ row_to_episode(
40
+ @db.get_first_row("SELECT * FROM episodes WHERE id = last_insert_rowid()")
41
+ )
42
+ end
43
+ end
44
+
45
+ # @see Store#episodes
46
+ def episodes(agent_id:, last: 50, type: nil) # rubocop:disable Metrics/MethodLength
47
+ @mutex.synchronize do
48
+ rows = if type
49
+ @db.execute(
50
+ "SELECT * FROM episodes WHERE agent_id = ? AND type = ? ORDER BY ts ASC LIMIT ?",
51
+ [agent_id, type.to_s, last]
52
+ )
53
+ else
54
+ @db.execute(
55
+ "SELECT * FROM episodes WHERE agent_id = ? ORDER BY ts ASC LIMIT ?",
56
+ [agent_id, last]
57
+ )
58
+ end
59
+ rows.map { |r| row_to_episode(r) }
60
+ end
61
+ end
62
+
63
+ # @see Store#retrieve
64
+ def retrieve(agent_id:, query: nil, limit: 10, type: nil) # rubocop:disable Metrics/MethodLength
65
+ @mutex.synchronize do
66
+ if query
67
+ fts_retrieve(agent_id, query, limit, type)
68
+ else
69
+ rows = if type
70
+ @db.execute(
71
+ "SELECT * FROM episodes WHERE agent_id = ? AND type = ? ORDER BY ts ASC LIMIT ?",
72
+ [agent_id, type.to_s, limit]
73
+ )
74
+ else
75
+ @db.execute(
76
+ "SELECT * FROM episodes WHERE agent_id = ? ORDER BY ts ASC LIMIT ?",
77
+ [agent_id, limit]
78
+ )
79
+ end
80
+ rows.map { |r| row_to_episode(r) }
81
+ end
82
+ end
83
+ end
84
+
85
+ # @see Store#store_fact
86
+ def store_fact(agent_id:, key:, value:, confidence: 1.0) # rubocop:disable Metrics/MethodLength
87
+ @mutex.synchronize do
88
+ serialized = value.to_s
89
+ @db.execute(
90
+ "INSERT OR REPLACE INTO facts (agent_id, key, value, confidence, updated_at) " \
91
+ "VALUES (?, ?, ?, ?, ?)",
92
+ [agent_id, key.to_s, serialized, confidence.to_f, Time.now.to_i]
93
+ )
94
+ row_to_fact(
95
+ @db.get_first_row("SELECT * FROM facts WHERE agent_id = ? AND key = ?", [agent_id, key.to_s])
96
+ )
97
+ end
98
+ end
99
+
100
+ # @see Store#facts
101
+ def facts(agent_id:)
102
+ @mutex.synchronize do
103
+ rows = @db.execute("SELECT * FROM facts WHERE agent_id = ?", [agent_id])
104
+ rows.each_with_object({}) do |row, hash|
105
+ fact = row_to_fact(row)
106
+ hash[fact.key] = fact
107
+ end
108
+ end
109
+ end
110
+
111
+ # @see Store#record_reflection
112
+ def record_reflection(agent_id:, summary:, system_patch: nil, applied: false)
113
+ @mutex.synchronize do
114
+ @db.execute(
115
+ "INSERT INTO reflections (agent_id, ts, summary, system_patch, applied) " \
116
+ "VALUES (?, ?, ?, ?, ?)",
117
+ [agent_id, Time.now.to_i, summary, system_patch, applied ? 1 : 0]
118
+ )
119
+ row_to_reflection(
120
+ @db.get_first_row("SELECT * FROM reflections WHERE id = last_insert_rowid()")
121
+ )
122
+ end
123
+ end
124
+
125
+ # @see Store#reflections
126
+ def reflections(agent_id:, applied: nil) # rubocop:disable Metrics/MethodLength
127
+ @mutex.synchronize do
128
+ rows = if applied.nil?
129
+ @db.execute("SELECT * FROM reflections WHERE agent_id = ? ORDER BY ts ASC", [agent_id])
130
+ else
131
+ @db.execute(
132
+ "SELECT * FROM reflections WHERE agent_id = ? AND applied = ? ORDER BY ts ASC",
133
+ [agent_id, applied ? 1 : 0]
134
+ )
135
+ end
136
+ rows.map { |r| row_to_reflection(r) }
137
+ end
138
+ end
139
+
140
+ # @see Store#apply_reflection
141
+ def apply_reflection(id:)
142
+ @mutex.synchronize do
143
+ changes_before = @db.changes
144
+ @db.execute("UPDATE reflections SET applied = 1 WHERE id = ?", [id])
145
+ @db.changes > changes_before || @db.changes == 1
146
+ end
147
+ end
148
+
149
+ # @see Store#clear
150
+ def clear(agent_id:)
151
+ @mutex.synchronize do
152
+ @db.execute("DELETE FROM episodes WHERE agent_id = ?", [agent_id])
153
+ @db.execute("DELETE FROM facts WHERE agent_id = ?", [agent_id])
154
+ @db.execute("DELETE FROM reflections WHERE agent_id = ?", [agent_id])
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def fts_retrieve(agent_id, query, limit, type) # rubocop:disable Metrics/MethodLength
161
+ rows = if type
162
+ @db.execute(
163
+ "SELECT e.* FROM episodes e " \
164
+ "JOIN episodes_fts fts ON fts.rowid = e.id " \
165
+ "WHERE fts.content MATCH ? AND e.agent_id = ? AND e.type = ? " \
166
+ "ORDER BY e.ts ASC LIMIT ?",
167
+ [query.to_s, agent_id, type.to_s, limit]
168
+ )
169
+ else
170
+ @db.execute(
171
+ "SELECT e.* FROM episodes e " \
172
+ "JOIN episodes_fts fts ON fts.rowid = e.id " \
173
+ "WHERE fts.content MATCH ? AND e.agent_id = ? " \
174
+ "ORDER BY e.ts ASC LIMIT ?",
175
+ [query.to_s, agent_id, limit]
176
+ )
177
+ end
178
+ rows.map { |r| row_to_episode(r) }
179
+ rescue ::SQLite3::Exception
180
+ # FTS5 match error (e.g. invalid query syntax) — fall back to LIKE
181
+ fallback_retrieve(agent_id, query, limit, type)
182
+ end
183
+
184
+ def fallback_retrieve(agent_id, query, limit, type) # rubocop:disable Metrics/MethodLength
185
+ q = "%#{query}%"
186
+ rows = if type
187
+ @db.execute(
188
+ "SELECT * FROM episodes WHERE agent_id = ? AND type = ? AND content LIKE ? " \
189
+ "ORDER BY ts ASC LIMIT ?",
190
+ [agent_id, type.to_s, q, limit]
191
+ )
192
+ else
193
+ @db.execute(
194
+ "SELECT * FROM episodes WHERE agent_id = ? AND content LIKE ? " \
195
+ "ORDER BY ts ASC LIMIT ?",
196
+ [agent_id, q, limit]
197
+ )
198
+ end
199
+ rows.map { |r| row_to_episode(r) }
200
+ end
201
+
202
+ def row_to_episode(row) # rubocop:disable Metrics/MethodLength
203
+ return nil unless row
204
+
205
+ Episode.new(
206
+ id: row["id"],
207
+ agent_id: row["agent_id"],
208
+ session_id: row["session_id"],
209
+ ts: row["ts"],
210
+ type: row["type"],
211
+ content: row["content"],
212
+ outcome: row["outcome"],
213
+ importance: row["importance"]
214
+ )
215
+ end
216
+
217
+ def row_to_fact(row)
218
+ return nil unless row
219
+
220
+ Fact.new(
221
+ id: row["id"],
222
+ agent_id: row["agent_id"],
223
+ key: row["key"],
224
+ value: row["value"],
225
+ confidence: row["confidence"],
226
+ updated_at: row["updated_at"]
227
+ )
228
+ end
229
+
230
+ def row_to_reflection(row)
231
+ return nil unless row
232
+
233
+ ReflectionRecord.new(
234
+ id: row["id"],
235
+ agent_id: row["agent_id"],
236
+ ts: row["ts"],
237
+ summary: row["summary"],
238
+ system_patch: row["system_patch"],
239
+ applied: row["applied"] == 1
240
+ )
241
+ end
242
+
243
+ def create_schema! # rubocop:disable Metrics/MethodLength
244
+ @db.execute_batch(<<~SQL)
245
+ CREATE TABLE IF NOT EXISTS episodes (
246
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
247
+ agent_id TEXT NOT NULL,
248
+ session_id TEXT,
249
+ ts INTEGER NOT NULL,
250
+ type TEXT NOT NULL,
251
+ content TEXT NOT NULL,
252
+ outcome TEXT,
253
+ importance REAL NOT NULL DEFAULT 0.5
254
+ );
255
+ CREATE INDEX IF NOT EXISTS idx_ep_agent ON episodes(agent_id, ts);
256
+ CREATE VIRTUAL TABLE IF NOT EXISTS episodes_fts USING fts5(
257
+ content, content='episodes', content_rowid='id'
258
+ );
259
+ CREATE TRIGGER IF NOT EXISTS ep_ai AFTER INSERT ON episodes BEGIN
260
+ INSERT INTO episodes_fts(rowid, content) VALUES (new.id, new.content);
261
+ END;
262
+ CREATE TABLE IF NOT EXISTS facts (
263
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
264
+ agent_id TEXT NOT NULL,
265
+ key TEXT NOT NULL,
266
+ value TEXT,
267
+ confidence REAL DEFAULT 1.0,
268
+ updated_at INTEGER,
269
+ UNIQUE(agent_id, key)
270
+ );
271
+ CREATE TABLE IF NOT EXISTS reflections (
272
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
273
+ agent_id TEXT NOT NULL,
274
+ ts INTEGER NOT NULL,
275
+ summary TEXT,
276
+ system_patch TEXT,
277
+ applied INTEGER DEFAULT 0
278
+ );
279
+ SQL
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter/errors"
4
+ require "igniter/memory/episode"
5
+ require "igniter/memory/fact"
6
+ require "igniter/memory/reflection_record"
7
+ require "igniter/memory/store"
8
+ require "igniter/memory/stores/in_memory"
9
+ require "igniter/memory/stores/sqlite"
10
+ require "igniter/memory/reflection_cycle"
11
+ require "igniter/memory/agent_memory"
12
+ require "igniter/memory/memorable"
13
+
14
+ module Igniter
15
+ # Pluggable episodic memory system for Agents and Skills.
16
+ #
17
+ # Agents and Skills can record what happened (episodes), store learned facts,
18
+ # and trigger reflection cycles that analyse past behaviour.
19
+ #
20
+ # == Quick start
21
+ #
22
+ # require "igniter/memory"
23
+ #
24
+ # class MyAgent < Igniter::Agent
25
+ # include Igniter::Memory::Memorable
26
+ # enable_memory # uses the global default InMemory store
27
+ # end
28
+ #
29
+ # == Custom store
30
+ #
31
+ # Igniter::Memory.default_store = Igniter::Memory::Stores::SQLite.new(path: "/tmp/mem.db")
32
+ #
33
+ # class MyAgent < Igniter::Agent
34
+ # include Igniter::Memory::Memorable
35
+ # enable_memory store: Igniter::Memory.default_store
36
+ # end
37
+ #
38
+ # == Global configuration
39
+ #
40
+ # Igniter::Memory.configure do |m|
41
+ # m.default_store = Igniter::Memory::Stores::SQLite.new(path: "/var/app/memory.db")
42
+ # end
43
+ module Memory
44
+ # Raised when a required dependency (e.g. sqlite3 gem) is missing or when
45
+ # Memory is misconfigured.
46
+ ConfigurationError = Class.new(Igniter::Error)
47
+
48
+ class << self
49
+ # Returns the global default store, creating an InMemory store on first access.
50
+ #
51
+ # @return [Store]
52
+ def default_store
53
+ @default_store ||= Stores::InMemory.new
54
+ end
55
+
56
+ # Override the global default store.
57
+ #
58
+ # @param store [Store]
59
+ # @return [Store]
60
+ attr_writer :default_store
61
+
62
+ # Yield self for block-style configuration.
63
+ #
64
+ # @example
65
+ # Igniter::Memory.configure do |m|
66
+ # m.default_store = Igniter::Memory::Stores::SQLite.new(path: "/tmp/mem.db")
67
+ # end
68
+ def configure
69
+ yield self
70
+ end
71
+
72
+ # Reset module-level state (primarily useful in tests).
73
+ #
74
+ # @return [void]
75
+ def reset!
76
+ @default_store = nil
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Mesh
5
+ # Announces this node's identity to seed nodes at startup and withdraws
6
+ # the registration on graceful shutdown.
7
+ #
8
+ # All network errors are swallowed — a seed being temporarily down must
9
+ # not prevent the local node from starting. The background Poller will
10
+ # re-register once the seed recovers.
11
+ class Announcer
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ # POST self-manifest to every configured seed. No-op if peer_name or
17
+ # local_url are not configured.
18
+ def announce_all
19
+ return unless announceable?
20
+
21
+ @config.seeds.each { |url| announce_to(url) }
22
+ end
23
+
24
+ # DELETE self from every configured seed. Best-effort — errors are ignored.
25
+ def deannounce_all
26
+ return unless @config.peer_name
27
+
28
+ @config.seeds.each { |url| deannounce_from(url) }
29
+ end
30
+
31
+ private
32
+
33
+ def announceable?
34
+ @config.peer_name && !@config.peer_name.to_s.strip.empty? &&
35
+ @config.local_url && !@config.local_url.to_s.strip.empty?
36
+ end
37
+
38
+ def announce_to(seed_url)
39
+ Igniter::Server::Client.new(seed_url, timeout: 5).register_peer(
40
+ name: @config.peer_name,
41
+ url: @config.local_url,
42
+ capabilities: @config.local_capabilities
43
+ )
44
+ rescue Igniter::Server::Client::ConnectionError
45
+ nil
46
+ end
47
+
48
+ def deannounce_from(seed_url)
49
+ Igniter::Server::Client.new(seed_url, timeout: 5).unregister_peer(@config.peer_name)
50
+ rescue Igniter::Server::Client::ConnectionError
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end