smart_brain 0.1.2 → 0.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/MEMPAL_GUIDE.md +1074 -0
  4. data/README.en.md +173 -173
  5. data/README.md +467 -173
  6. data/config/brain.yml +69 -1
  7. data/conversation_demo.rb +438 -438
  8. data/db/migrate/002_turn_events_payload.sql +9 -0
  9. data/db/migrate/003_tiers_and_lifecycle.sql +28 -0
  10. data/db/migrate/004_kg_edges.sql +30 -0
  11. data/db/migrate/005_domains_and_memory_scopes.sql +163 -0
  12. data/docs/coding_todo.md +139 -0
  13. data/docs/context_package.md +220 -0
  14. data/docs/evidence_pack.md +190 -0
  15. data/docs/gap_vs_mempal.md +161 -0
  16. data/docs/mcp.md +93 -0
  17. data/docs/memory_types.md +278 -0
  18. data/docs/multi_scope_memory_refactor_plan.md +483 -0
  19. data/docs/multi_scope_migration.md +65 -0
  20. data/docs/policies.md +308 -0
  21. data/docs/retrieval_plan.md +231 -0
  22. data/docs/smartbrain_design.md +299 -0
  23. data/docs/user_guide.md +546 -0
  24. data/example.rb +91 -91
  25. data/examples/01_memory_basic.rb +57 -0
  26. data/examples/02_governance.rb +63 -0
  27. data/examples/03_postgres_persistence.rb +63 -0
  28. data/examples/04_ollama_llm.rb +69 -0
  29. data/examples/05_smart_rag_integration.rb +79 -0
  30. data/examples/06_multi_scope_memory.rb +50 -0
  31. data/examples/README.md +49 -0
  32. data/exe/smart_brain +168 -0
  33. data/lib/smart_brain/adapters/smart_rag/direct_client.rb +16 -5
  34. data/lib/smart_brain/adapters/smart_rag/http_client.rb +16 -5
  35. data/lib/smart_brain/adapters/smart_rag/null_client.rb +7 -2
  36. data/lib/smart_brain/adapters/smart_rag/scope_filter.rb +60 -0
  37. data/lib/smart_brain/configuration.rb +57 -0
  38. data/lib/smart_brain/consolidator/working_summary.rb +80 -12
  39. data/lib/smart_brain/context_composer/composer.rb +40 -3
  40. data/lib/smart_brain/contracts/retrieval_plan.rb +10 -0
  41. data/lib/smart_brain/contracts/scope_context.rb +46 -0
  42. data/lib/smart_brain/contracts/scope_ref.rb +25 -0
  43. data/lib/smart_brain/db.rb +109 -0
  44. data/lib/smart_brain/event_store/in_memory.rb +6 -2
  45. data/lib/smart_brain/event_store/postgres.rb +199 -0
  46. data/lib/smart_brain/fusion/merger.rb +31 -2
  47. data/lib/smart_brain/governance/briefing.rb +146 -0
  48. data/lib/smart_brain/governance/fact_check.rb +110 -0
  49. data/lib/smart_brain/governance/knowledge_graph.rb +60 -0
  50. data/lib/smart_brain/governance/lifecycle.rb +225 -0
  51. data/lib/smart_brain/governance/tiers.rb +60 -0
  52. data/lib/smart_brain/memory_extractor/extractor.rb +25 -7
  53. data/lib/smart_brain/memory_store/in_memory.rb +202 -17
  54. data/lib/smart_brain/memory_store/postgres.rb +500 -0
  55. data/lib/smart_brain/model_provider/base.rb +87 -0
  56. data/lib/smart_brain/model_provider/factory.rb +49 -0
  57. data/lib/smart_brain/model_provider/ollama.rb +60 -0
  58. data/lib/smart_brain/model_provider/openai.rb +60 -0
  59. data/lib/smart_brain/model_provider/stub.rb +26 -0
  60. data/lib/smart_brain/model_provider.rb +7 -0
  61. data/lib/smart_brain/observability/tracker.rb +39 -1
  62. data/lib/smart_brain/retrievers/exact_retriever.rb +6 -0
  63. data/lib/smart_brain/retrievers/memory_retriever.rb +59 -5
  64. data/lib/smart_brain/runtime.rb +288 -16
  65. data/lib/smart_brain/scopes/conflict_resolver.rb +67 -0
  66. data/lib/smart_brain/scopes/registry.rb +133 -0
  67. data/lib/smart_brain/scopes/resolver.rb +32 -0
  68. data/lib/smart_brain/server/http_app.rb +143 -0
  69. data/lib/smart_brain/server/mcp_server.rb +385 -0
  70. data/lib/smart_brain/server/service.rb +129 -0
  71. data/lib/smart_brain/support/levenshtein.rb +35 -0
  72. data/lib/smart_brain/version.rb +5 -5
  73. data/lib/smart_brain.rb +80 -35
  74. metadata +88 -36
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require_relative '../governance/tiers'
5
+ require_relative '../retrievers/exact_retriever'
4
6
 
5
7
  module SmartBrain
6
8
  module MemoryStore
@@ -8,8 +10,12 @@ module SmartBrain
8
10
  OVERWRITE_TYPES = %w[preferences goals tasks].freeze
9
11
 
10
12
  def initialize
11
- @by_session = Hash.new { |h, k| h[k] = [] }
13
+ @by_scope = Hash.new { |h, k| h[k] = [] }
12
14
  @entities_index = Hash.new { |h, k| h[k] = [] }
15
+ @summaries = {}
16
+ @events = []
17
+ @edges = []
18
+ @exact = Retrievers::ExactRetriever.new
13
19
  end
14
20
 
15
21
  def upsert(extracted)
@@ -19,7 +25,9 @@ module SmartBrain
19
25
  conflicts = []
20
26
 
21
27
  items.each do |item|
22
- existing = active_item(session_id: session_id, type: item[:type], key: item[:key])
28
+ scope_id = item[:scope_id] || legacy_scope_id(session_id)
29
+ scope = item[:scope] || { type: 'session', id: session_id }
30
+ existing = active_item(scope_id: scope_id, type: item[:type], key: item[:key])
23
31
 
24
32
  if existing && item[:status] == 'retracted'
25
33
  existing[:status] = 'retracted'
@@ -32,47 +40,224 @@ module SmartBrain
32
40
  conflicts << { type: 'overwrite', key: item[:key], previous_memory_item_id: existing[:id] }
33
41
  end
34
42
 
35
- record = item.merge(id: SecureRandom.uuid, status: item[:status] || 'active')
36
- by_session[session_id] << record
37
- update_entities(session_id: session_id, record: record)
38
- written << record.slice(:id, :type, :key, :status, :confidence)
43
+ record = item.merge(
44
+ id: SecureRandom.uuid, status: item[:status] || 'active', session_id: session_id,
45
+ source_session_id: item[:source_session_id] || session_id, scope_id: scope_id, scope: scope
46
+ )
47
+ by_scope[scope_id] << record
48
+ update_entities(scope_id: scope_id, record: record)
49
+ written << record.slice(:id, :type, :key, :status, :confidence, :scope_id, :scope, :source_session_id)
39
50
  end
40
51
 
41
52
  { count: written.size, items: written, conflicts: conflicts }
42
53
  end
43
54
 
44
- def active_items(session_id:)
45
- by_session[session_id].select { |item| item[:status] == 'active' }
55
+ def active_items(session_id: nil, scope_ids: nil)
56
+ selected_scope_ids = normalize_scope_ids(session_id, scope_ids)
57
+ selected_scope_ids.flat_map { |scope_id| by_scope[scope_id] }
58
+ .select { |item| item[:status] == 'active' && included_lifecycle?(item) }
46
59
  end
47
60
 
48
- def entities(session_id:)
49
- entities_index[session_id]
61
+ def entities(session_id: nil, scope_ids: nil)
62
+ normalize_scope_ids(session_id, scope_ids).flat_map { |scope_id| entities_index[scope_id] }
63
+ end
64
+
65
+ # Substring/exact search over active memory_items. Mirrors the FTS path of
66
+ # MemoryStore::Postgres#search_memory so MemoryRetriever is backend-agnostic.
67
+ def search_memory(query:, session_id: nil, scope_ids: nil, limit:)
68
+ @exact.retrieve(
69
+ query: query, memory_items: active_items(session_id: session_id, scope_ids: scope_ids), recent_turns: [], limit: limit
70
+ )
71
+ end
72
+
73
+ def save_summary(session_id:, summary:)
74
+ summaries[session_id] = summary
75
+ summary
76
+ end
77
+
78
+ def latest_summary(session_id:)
79
+ summaries[session_id]
80
+ end
81
+
82
+ def all_summaries
83
+ summaries.each_with_object({}) { |(sid, s), h| h[sid] = s }
84
+ end
85
+
86
+ # --- knowledge lifecycle -------------------------------------------------
87
+ def create_item(session_id:, item:, scope_id: nil, scope: nil)
88
+ resolved_scope_id = scope_id || item[:scope_id] || legacy_scope_id(session_id)
89
+ record = item.merge(
90
+ id: SecureRandom.uuid, session_id: session_id, source_session_id: item[:source_session_id] || session_id,
91
+ scope_id: resolved_scope_id, scope: scope || item[:scope] || { type: 'session', id: session_id }
92
+ )
93
+ by_scope[resolved_scope_id] << record
94
+ update_entities(scope_id: resolved_scope_id, record: record)
95
+ record
96
+ end
97
+
98
+ def find_item(id:)
99
+ by_scope.values.flatten.find { |i| i[:id] == id }
100
+ end
101
+
102
+ def set_lifecycle(id:, lifecycle_status:, merge_value: nil)
103
+ item = find_item(id: id)
104
+ return nil unless item
105
+
106
+ item[:lifecycle_status] = lifecycle_status
107
+ item[:value_json] = (item[:value_json] || {}).merge(merge_value || {})
108
+ item[:updated_at] = Time.now.utc.iso8601
109
+ item
110
+ end
111
+
112
+ def set_status(id:, status:, merge_value: nil)
113
+ item = find_item(id: id)
114
+ return nil unless item
115
+
116
+ item[:status] = status
117
+ item[:value_json] = (item[:value_json] || {}).merge(merge_value || {})
118
+ item[:updated_at] = Time.now.utc.iso8601
119
+ item
120
+ end
121
+
122
+ def record_event(memory_item_id:, event_type:, from_lifecycle: nil, to_lifecycle: nil,
123
+ reason: nil, reason_type: nil, reviewer: nil, evidence_refs: [])
124
+ event = {
125
+ id: SecureRandom.uuid,
126
+ memory_item_id: memory_item_id,
127
+ event_type: event_type,
128
+ from_lifecycle: from_lifecycle,
129
+ to_lifecycle: to_lifecycle,
130
+ reason: reason,
131
+ reason_type: reason_type,
132
+ reviewer: reviewer,
133
+ evidence_refs: Array(evidence_refs),
134
+ created_at: Time.now.utc.iso8601
135
+ }
136
+ events << event
137
+ event
138
+ end
139
+
140
+ def events_for(memory_item_id:)
141
+ events.select { |e| e[:memory_item_id] == memory_item_id }
142
+ end
143
+
144
+ # --- knowledge graph -----------------------------------------------------
145
+ def add_edge(session_id:, edge:, scope_id: nil, scope: nil, source_session_id: nil)
146
+ resolved_scope_id = scope_id || edge[:scope_id] || legacy_scope_id(session_id)
147
+ record = {
148
+ id: SecureRandom.uuid,
149
+ session_id: session_id,
150
+ source_session_id: source_session_id || edge[:source_session_id] || session_id,
151
+ scope_id: resolved_scope_id,
152
+ scope: scope || edge[:scope] || { type: 'session', id: session_id },
153
+ subject: edge[:subject].to_s,
154
+ predicate: edge[:predicate].to_s,
155
+ object: edge[:object].to_s,
156
+ subject_entity_id: resolve_entity_id(nil, edge[:subject], scope_ids: [resolved_scope_id]),
157
+ object_entity_id: resolve_entity_id(nil, edge[:object], scope_ids: [resolved_scope_id]),
158
+ valid_from: edge[:valid_from] || Time.now.utc.iso8601,
159
+ valid_to: edge[:valid_to],
160
+ source_turn_id: edge[:source_turn_id],
161
+ source_memory_item_id: edge[:source_memory_item_id],
162
+ confidence: edge[:confidence] || 0.6,
163
+ status: edge[:status] || 'active',
164
+ meta: edge[:meta] || {},
165
+ created_at: Time.now.utc.iso8601
166
+ }
167
+ edges << record
168
+ record
169
+ end
170
+
171
+ def query_edges(session_id: nil, scope_ids: nil, subject: nil, predicate: nil, object: nil, include_invalid: false)
172
+ selected_scope_ids = normalize_scope_ids(session_id, scope_ids)
173
+ edges.select do |e|
174
+ next false unless selected_scope_ids.include?(e[:scope_id])
175
+ next false if !include_invalid && e[:status] != 'active'
176
+ next false if subject && e[:subject].downcase != subject.to_s.downcase
177
+ next false if predicate && e[:predicate].downcase != predicate.to_s.downcase
178
+ next false if object && e[:object].downcase != object.to_s.downcase
179
+
180
+ true
181
+ end.sort_by { |e| e[:valid_from].to_s }
182
+ end
183
+
184
+ def edges_for_subject(session_id: nil, scope_ids: nil, subject:)
185
+ query_edges(session_id: session_id, scope_ids: scope_ids, subject: subject, include_invalid: true)
186
+ end
187
+
188
+ def invalidate_edge(id:, reason: nil)
189
+ edge = edges.find { |e| e[:id] == id }
190
+ return nil unless edge
191
+
192
+ edge[:valid_to] = Time.now.utc.iso8601
193
+ edge[:status] = 'invalidated'
194
+ edge[:meta] = (edge[:meta] || {}).merge(invalidated_reason: reason)
195
+ edge
196
+ end
197
+
198
+ def find_edge(id:)
199
+ edges.find { |edge| edge[:id] == id }
200
+ end
201
+
202
+ def edge_stats(session_id: nil, scope_ids: nil)
203
+ selected_scope_ids = normalize_scope_ids(session_id, scope_ids)
204
+ scoped = edges.select { |e| selected_scope_ids.include?(e[:scope_id]) }
205
+ { total: scoped.size,
206
+ active: scoped.count { |e| e[:status] == 'active' },
207
+ invalidated: scoped.count { |e| e[:status] == 'invalidated' } }
208
+ end
209
+
210
+ def resolve_entity_id(session_id, name, scope_ids: nil)
211
+ return nil if name.nil? || name.to_s.empty?
212
+
213
+ match = entities(session_id: session_id, scope_ids: scope_ids).find do |e|
214
+ e[:canonical].to_s.downcase == name.to_s.downcase ||
215
+ e[:name].to_s.downcase == name.to_s.downcase
216
+ end
217
+ match && match[:id]
50
218
  end
51
219
 
52
220
  private
53
221
 
54
- attr_reader :by_session, :entities_index
222
+ attr_reader :by_scope, :entities_index, :summaries, :events, :edges
55
223
 
56
- def active_item(session_id:, type:, key:)
57
- by_session[session_id].find { |row| row[:type] == type && row[:key] == key && row[:status] == 'active' }
224
+ def active_item(scope_id:, type:, key:)
225
+ by_scope[scope_id].find { |row| row[:type] == type && row[:key] == key && row[:status] == 'active' }
58
226
  end
59
227
 
60
- def update_entities(session_id:, record:)
228
+ def included_lifecycle?(item)
229
+ Governance::Tiers::CONTEXT_LIFECYCLE.include?(item[:lifecycle_status] || 'raw')
230
+ end
231
+
232
+ def update_entities(scope_id:, record:)
61
233
  return unless record[:type] == 'entities'
62
234
 
63
235
  value = record[:value_json]
64
236
  canonical = value[:canonical] || value[:name]
65
- existing = entities_index[session_id].find { |e| e[:canonical] == canonical && e[:kind] == value[:kind] }
237
+ existing = entities_index[scope_id].find { |e| e[:canonical] == canonical && e[:kind] == value[:kind] }
66
238
  return if existing
67
239
 
68
- entities_index[session_id] << {
240
+ entities_index[scope_id] << {
69
241
  id: SecureRandom.uuid,
70
242
  name: value[:name] || canonical,
71
243
  kind: value[:kind] || 'other',
72
244
  canonical: canonical,
73
- memory_item_id: record[:id]
245
+ memory_item_id: record[:id],
246
+ scope_id: record[:scope_id],
247
+ scope: record[:scope],
248
+ source_session_id: record[:source_session_id]
74
249
  }
75
250
  end
251
+
252
+ def normalize_scope_ids(session_id, scope_ids)
253
+ ids = Array(scope_ids).compact
254
+ ids = [legacy_scope_id(session_id)] if ids.empty? && session_id
255
+ ids
256
+ end
257
+
258
+ def legacy_scope_id(session_id)
259
+ "legacy:session:#{session_id}"
260
+ end
76
261
  end
77
262
  end
78
263
  end