smart_brain 0.1.2 → 0.3.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/MEMPAL_GUIDE.md +1074 -0
  4. data/README.en.md +206 -173
  5. data/README.md +593 -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/installation.md +198 -0
  17. data/docs/mcp.md +93 -0
  18. data/docs/media_memory_schema.md +271 -0
  19. data/docs/memory_types.md +278 -0
  20. data/docs/multi_scope_memory_refactor_plan.md +483 -0
  21. data/docs/multi_scope_migration.md +65 -0
  22. data/docs/policies.md +308 -0
  23. data/docs/retrieval_plan.md +233 -0
  24. data/docs/smartbrain_design.md +299 -0
  25. data/docs/user_guide.md +547 -0
  26. data/example.rb +91 -91
  27. data/examples/01_memory_basic.rb +57 -0
  28. data/examples/02_governance.rb +63 -0
  29. data/examples/03_postgres_persistence.rb +63 -0
  30. data/examples/04_ollama_llm.rb +69 -0
  31. data/examples/05_smart_rag_integration.rb +79 -0
  32. data/examples/06_multi_scope_memory.rb +50 -0
  33. data/examples/07_media_memory.rb +53 -0
  34. data/examples/README.md +49 -0
  35. data/exe/smart_brain +168 -0
  36. data/lib/smart_brain/adapters/smart_rag/direct_client.rb +57 -5
  37. data/lib/smart_brain/adapters/smart_rag/http_client.rb +118 -5
  38. data/lib/smart_brain/adapters/smart_rag/http_transport.rb +138 -0
  39. data/lib/smart_brain/adapters/smart_rag/media_metadata_extractor.rb +255 -0
  40. data/lib/smart_brain/adapters/smart_rag/null_client.rb +44 -2
  41. data/lib/smart_brain/adapters/smart_rag/scope_filter.rb +60 -0
  42. data/lib/smart_brain/configuration.rb +60 -0
  43. data/lib/smart_brain/consolidator/working_summary.rb +80 -12
  44. data/lib/smart_brain/context_composer/composer.rb +40 -3
  45. data/lib/smart_brain/contracts/retrieval_plan.rb +10 -0
  46. data/lib/smart_brain/contracts/scope_context.rb +46 -0
  47. data/lib/smart_brain/contracts/scope_ref.rb +25 -0
  48. data/lib/smart_brain/db.rb +109 -0
  49. data/lib/smart_brain/event_store/in_memory.rb +6 -2
  50. data/lib/smart_brain/event_store/postgres.rb +199 -0
  51. data/lib/smart_brain/fusion/merger.rb +31 -2
  52. data/lib/smart_brain/governance/briefing.rb +146 -0
  53. data/lib/smart_brain/governance/fact_check.rb +110 -0
  54. data/lib/smart_brain/governance/knowledge_graph.rb +60 -0
  55. data/lib/smart_brain/governance/lifecycle.rb +225 -0
  56. data/lib/smart_brain/governance/tiers.rb +60 -0
  57. data/lib/smart_brain/memory_extractor/extractor.rb +25 -7
  58. data/lib/smart_brain/memory_store/in_memory.rb +202 -17
  59. data/lib/smart_brain/memory_store/postgres.rb +500 -0
  60. data/lib/smart_brain/model_provider/base.rb +87 -0
  61. data/lib/smart_brain/model_provider/factory.rb +49 -0
  62. data/lib/smart_brain/model_provider/ollama.rb +60 -0
  63. data/lib/smart_brain/model_provider/openai.rb +60 -0
  64. data/lib/smart_brain/model_provider/stub.rb +26 -0
  65. data/lib/smart_brain/model_provider.rb +7 -0
  66. data/lib/smart_brain/observability/tracker.rb +39 -1
  67. data/lib/smart_brain/retrievers/exact_retriever.rb +6 -0
  68. data/lib/smart_brain/retrievers/memory_retriever.rb +59 -5
  69. data/lib/smart_brain/runtime.rb +306 -16
  70. data/lib/smart_brain/scopes/conflict_resolver.rb +67 -0
  71. data/lib/smart_brain/scopes/registry.rb +133 -0
  72. data/lib/smart_brain/scopes/resolver.rb +32 -0
  73. data/lib/smart_brain/server/http_app.rb +143 -0
  74. data/lib/smart_brain/server/mcp_server.rb +385 -0
  75. data/lib/smart_brain/server/service.rb +129 -0
  76. data/lib/smart_brain/support/levenshtein.rb +35 -0
  77. data/lib/smart_brain/version.rb +5 -5
  78. data/lib/smart_brain.rb +93 -35
  79. metadata +100 -54
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require_relative 'model_provider/stub'
5
+ require_relative 'governance/lifecycle'
6
+ require_relative 'governance/knowledge_graph'
7
+ require_relative 'governance/fact_check'
8
+ require_relative 'governance/briefing'
4
9
  require_relative 'contracts/retrieval_plan'
5
10
  require_relative 'contracts/evidence_pack'
6
11
  require_relative 'contracts/context_package'
@@ -14,33 +19,60 @@ require_relative 'retrievers/memory_retriever'
14
19
  require_relative 'adapters/smart_rag/null_client'
15
20
  require_relative 'fusion/merger'
16
21
  require_relative 'context_composer/composer'
22
+ require_relative 'scopes/registry'
23
+ require_relative 'scopes/resolver'
24
+ require_relative 'scopes/conflict_resolver'
17
25
 
18
26
  module SmartBrain
19
27
  class Runtime
20
28
  def self.build(config:, smart_rag_client: nil, clock:)
21
- event_store = EventStore::InMemory.new
22
- memory_store = MemoryStore::InMemory.new
29
+ require_relative 'model_provider'
30
+ event_store, memory_store, scope_registry = build_stores(config: config, clock: clock)
31
+ model_provider = ModelProvider.build(config)
23
32
  new(
24
33
  config: config,
25
34
  clock: clock,
26
35
  event_store: event_store,
27
36
  memory_store: memory_store,
37
+ scope_registry: scope_registry,
38
+ scope_resolver: Scopes::Resolver.new,
39
+ conflict_resolver: Scopes::ConflictResolver.new(config: config),
28
40
  extractor: MemoryExtractor::Extractor.new(config: config),
29
- consolidator: Consolidator::WorkingSummary.new(config: config, clock: clock),
41
+ consolidator: Consolidator::WorkingSummary.new(config: config, clock: clock, memory_store: memory_store, model_provider: model_provider),
30
42
  planner: RetrievalPlanner::Planner.new(config: config),
31
43
  memory_retriever: Retrievers::MemoryRetriever.new(config: config),
32
44
  smart_rag_client: smart_rag_client || Adapters::SmartRag::NullClient.new,
33
- merger: Fusion::Merger.new(config: config),
45
+ merger: Fusion::Merger.new(config: config, model_provider: model_provider),
34
46
  composer: ContextComposer::Composer.new(config: config, clock: clock),
35
- tracker: Observability::Tracker.new
47
+ tracker: Observability::Tracker.new,
48
+ model_provider: model_provider
36
49
  )
37
50
  end
38
51
 
39
- def initialize(config:, clock:, event_store:, memory_store:, extractor:, consolidator:, planner:, memory_retriever:, smart_rag_client:, merger:, composer:, tracker:)
52
+ def self.build_stores(config:, clock:)
53
+ if config.storage_backend == 'postgres'
54
+ require_relative 'db'
55
+ require_relative 'event_store/postgres'
56
+ require_relative 'memory_store/postgres'
57
+ db = SmartBrain::DB.connect(config)
58
+ SmartBrain::DB.migrate(db) # idempotent; self-provisions the schema
59
+ [EventStore::Postgres.new(db: db, clock: clock),
60
+ MemoryStore::Postgres.new(db: db, config: config),
61
+ Scopes::Registry.new(db: db, clock: clock)]
62
+ else
63
+ [EventStore::InMemory.new, MemoryStore::InMemory.new, Scopes::Registry.new(clock: clock)]
64
+ end
65
+ end
66
+
67
+ def initialize(config:, clock:, event_store:, memory_store:, scope_registry:, scope_resolver:, conflict_resolver:, extractor:, consolidator:, planner:, memory_retriever:, smart_rag_client:, merger:, composer:, tracker:, model_provider: SmartBrain::ModelProvider::Stub.new)
40
68
  @config = config
41
69
  @clock = clock
70
+ @model_provider = model_provider
42
71
  @event_store = event_store
43
72
  @memory_store = memory_store
73
+ @scope_registry = scope_registry
74
+ @scope_resolver = scope_resolver
75
+ @conflict_resolver = conflict_resolver
44
76
  @extractor = extractor
45
77
  @consolidator = consolidator
46
78
  @planner = planner
@@ -49,19 +81,142 @@ module SmartBrain
49
81
  @merger = merger
50
82
  @composer = composer
51
83
  @tracker = tracker
84
+ @lifecycle = Governance::Lifecycle.new(memory_store: memory_store, config: config, model_provider: model_provider)
85
+ @knowledge_graph = Governance::KnowledgeGraph.new(memory_store: memory_store, config: config)
86
+ @fact_check = Governance::FactCheck.new(memory_store: memory_store, config: config)
87
+ @briefing = Governance::Briefing.new(memory_store: memory_store, event_store: event_store, config: config)
88
+ end
89
+
90
+ def distill(session_id:, domain_id: nil, scope_context: nil, **options)
91
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
92
+ target = resolved[:default_write_scope]
93
+ lifecycle.distill(
94
+ session_id: session_id, scope_id: target[:scope_id], scope: target.slice(:type, :id), **options
95
+ )
96
+ end
97
+
98
+ def gate(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil)
99
+ authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
100
+ lifecycle.gate(memory_item_id: memory_item_id)
101
+ end
102
+
103
+ def promote(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil, **options)
104
+ authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
105
+ lifecycle.promote(memory_item_id: memory_item_id, **options)
106
+ end
107
+
108
+ def demote(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil, **options)
109
+ authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
110
+ lifecycle.demote(memory_item_id: memory_item_id, **options)
111
+ end
112
+
113
+ def retract(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil, **options)
114
+ authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
115
+ lifecycle.retract(memory_item_id: memory_item_id, **options)
116
+ end
117
+
118
+ def promote_to_scope(memory_item_id:, target_scope:, domain_id:, session_id:, scope_context:,
119
+ verification_refs:, reason:, reviewer: nil)
120
+ source = authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
121
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
122
+ normalized_target = normalize_scope_ref(target_scope)
123
+ target = resolved[:scopes].find do |scope|
124
+ scope[:type] == normalized_target[:type] && scope[:id] == normalized_target[:id]
125
+ end
126
+ writable = resolved[:write].any? { |scope| scope == normalized_target }
127
+ scope_validation_failure!("target scope is not writable: #{normalized_target[:type]}:#{normalized_target[:id]}") unless target && writable
128
+
129
+ result = lifecycle.promote_to_scope(
130
+ source_item: source, target_scope: target, session_id: session_id,
131
+ verification_refs: verification_refs, reason: reason, reviewer: reviewer
132
+ )
133
+ tracker.log_scope_promotion(result)
134
+ result
135
+ end
136
+
137
+ def demote_from_scope(...) = demote(...)
138
+
139
+ def promotion_lineage(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil)
140
+ authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
141
+ lifecycle.lineage(memory_item_id: memory_item_id)
142
+ end
143
+
144
+ def knowledge_events(memory_item_id:, domain_id: nil, session_id: nil, scope_context: nil)
145
+ authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
146
+ lifecycle.events(memory_item_id: memory_item_id)
147
+ end
148
+
149
+ def kg_add(session_id:, domain_id: nil, scope_context: nil, scope_ref: nil, **options)
150
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
151
+ target_ref = normalize_scope_ref(scope_ref || resolved[:default_write])
152
+ target = resolved[:scopes].find { |scope| scope[:type] == target_ref[:type] && scope[:id] == target_ref[:id] }
153
+ writable = resolved[:write].include?(target_ref)
154
+ scope_validation_failure!("target scope is not writable: #{target_ref[:type]}:#{target_ref[:id]}") unless target && writable
155
+
156
+ knowledge_graph.add(
157
+ session_id: session_id, scope_id: target[:scope_id], scope: target_ref,
158
+ source_session_id: session_id, **options
159
+ )
160
+ end
161
+
162
+ def kg_query(session_id:, domain_id: nil, scope_context: nil, **options)
163
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
164
+ knowledge_graph.query(scope_ids: resolved[:scope_ids], **options)
165
+ end
166
+
167
+ def kg_timeline(session_id:, domain_id: nil, scope_context: nil, **options)
168
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
169
+ knowledge_graph.timeline(scope_ids: resolved[:scope_ids], **options)
170
+ end
171
+
172
+ def kg_invalidate(edge_id:, reason: nil, domain_id: nil, session_id: nil, scope_context: nil)
173
+ authorize_edge!(edge_id, domain_id:, session_id:, scope_context:)
174
+ knowledge_graph.invalidate(edge_id: edge_id, reason: reason)
175
+ end
176
+
177
+ def kg_stats(session_id:, domain_id: nil, scope_context: nil)
178
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
179
+ knowledge_graph.stats(scope_ids: resolved[:scope_ids])
180
+ end
181
+
182
+ def fact_check(session_id:, text:, domain_id: nil, scope_context: nil, **options)
183
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
184
+ @fact_check.check(session_id: session_id, text: text, scope_ids: resolved[:scope_ids], **options)
185
+ end
186
+
187
+ def brief(session_id:, query: nil, domain_id: nil, scope_context: nil)
188
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
189
+ @briefing.brief(
190
+ session_id: session_id, query: query, scope_ids: resolved[:scope_ids], domain_id: resolved[:domain_id],
191
+ scope_context: public_scope_context(resolved)
192
+ )
52
193
  end
53
194
 
54
- def commit_turn(session_id:, turn_events:)
195
+ def wake_up(session_id:, domain_id: nil, scope_context: nil)
196
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
197
+ @briefing.wake_up(
198
+ session_id: session_id, scope_ids: resolved[:scope_ids], domain_id: resolved[:domain_id],
199
+ scope_context: public_scope_context(resolved)
200
+ )
201
+ end
202
+ def resume(...) = @briefing.resume(...)
203
+
204
+ def commit_turn(session_id:, turn_events:, domain_id: nil, scope_context: nil)
55
205
  started_at = monotonic_time
56
206
  now = clock.call
57
- turn = event_store.append_turn(session_id: session_id, turn_events: turn_events, created_at: now)
207
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
208
+ turn = event_store.append_turn(
209
+ session_id: session_id, turn_events: turn_events, created_at: now, domain_id: resolved[:domain_id]
210
+ )
58
211
  entity_frequencies = event_store.entity_frequencies(
59
212
  session_id: session_id,
60
213
  window_turns: config.retention.dig(:entity_gate, :window_turns) || 20
61
214
  )
62
215
 
63
216
  extracted = extractor.extract(session_id: session_id, turn: turn, entity_frequencies: entity_frequencies)
217
+ extracted = route_scoped_records(extracted, resolved, session_id)
64
218
  write_result = memory_store.upsert(extracted)
219
+ edges_written = persist_edges(session_id: session_id, turn_id: turn[:id], edges: extracted[:edges])
65
220
  turn_count = event_store.turns_count(session_id: session_id)
66
221
  recent_turns = event_store.recent_turns(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
67
222
  stage_event = extracted[:items].any? { |item| item[:type] == 'decisions' || (item[:type] == 'tasks' && item.dig(:value_json, :status) == 'done') }
@@ -69,7 +224,7 @@ module SmartBrain
69
224
  session_id: session_id,
70
225
  turn_count: turn_count,
71
226
  recent_turns: recent_turns,
72
- memory_items: memory_store.active_items(session_id: session_id),
227
+ memory_items: memory_store.active_items(scope_ids: [resolved[:default_write_scope][:scope_id]]),
73
228
  stage_event: stage_event
74
229
  )
75
230
 
@@ -77,12 +232,18 @@ module SmartBrain
77
232
  ok: true,
78
233
  commit_id: SecureRandom.uuid,
79
234
  session_id: session_id,
235
+ domain_id: resolved[:domain_id],
236
+ scope_context: public_scope_context(resolved),
80
237
  turn_id: turn[:id],
81
238
  memory_written: write_result,
82
239
  summary: summary,
83
240
  explain: {
84
241
  retention: extracted.fetch(:explain, []),
85
242
  conflicts: write_result.fetch(:conflicts, []),
243
+ scope_routing: write_result.fetch(:items, []).map do |item|
244
+ item.slice(:id, :type, :key, :scope, :source_session_id)
245
+ end,
246
+ edges: edges_written,
86
247
  summary: {
87
248
  triggered: summary[:triggered],
88
249
  reason: summary[:trigger_reason]
@@ -103,8 +264,10 @@ module SmartBrain
103
264
  result
104
265
  end
105
266
 
106
- def compose_context(session_id:, user_message:, agent_state: {})
267
+ def compose_context(session_id:, user_message:, agent_state: {}, domain_id: nil, scope_context: nil,
268
+ resource_filters: {})
107
269
  started_at = monotonic_time
270
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
108
271
  recent_turns = event_store.recent_turns(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
109
272
  refs = event_store.recent_refs(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
110
273
  request_id = SecureRandom.uuid
@@ -116,15 +279,24 @@ module SmartBrain
116
279
  recent_turns: recent_turns,
117
280
  refs: refs
118
281
  )
282
+ plan[:scope_context] = public_scope_context(resolved)
283
+ plan[:global_filters] = plan.fetch(:global_filters, {}).merge(symbolize_keys(resource_filters))
284
+ plan[:debug] = (plan[:debug] || {}).merge(
285
+ caller: { app: 'smart_brain', domain_id: resolved[:domain_id], session_id: session_id }
286
+ )
119
287
  Contracts::RetrievalPlan.validate!(plan)
120
288
 
121
289
  memory_evidence = memory_retriever.retrieve(
122
290
  query: user_message,
123
- memory_items: memory_store.active_items(session_id: session_id),
291
+ memory_store: memory_store,
292
+ session_id: session_id,
293
+ scope_ids: resolved[:scope_ids],
124
294
  recent_turns: recent_turns,
125
- entities: memory_store.entities(session_id: session_id),
295
+ entities: memory_store.entities(scope_ids: resolved[:scope_ids]),
126
296
  refs: refs
127
297
  )
298
+ conflict_result = conflict_resolver.resolve(memory_evidence)
299
+ memory_evidence = conflict_result[:selected]
128
300
 
129
301
  resource_pack = if plan.dig(:resource_retrieval, :enabled)
130
302
  smart_rag_client.retrieve(plan)
@@ -147,15 +319,23 @@ module SmartBrain
147
319
  resource_evidence: resource_pack.fetch(:evidences, [])
148
320
  )
149
321
  merged_bundle[:ignored_fields] = Array(merged_bundle[:ignored_fields]) + Array(resource_pack.dig(:explain, :ignored_fields))
322
+ merged_bundle[:scope_filter_warnings] = Array(resource_pack[:warnings]).select do |warning|
323
+ warning.to_s.include?('scope')
324
+ end
150
325
 
151
326
  context = composer.compose(
152
327
  session_id: session_id,
328
+ domain_id: resolved[:domain_id],
329
+ scope_context: public_scope_context(resolved),
153
330
  user_message: user_message,
154
331
  plan: plan,
155
332
  plan_id: resource_pack[:plan_id],
156
333
  summary: consolidator.latest_summary(session_id),
157
334
  recent_turns: recent_turns,
158
- evidence_bundle: merged_bundle
335
+ evidence_bundle: merged_bundle.merge(
336
+ shadowed: conflict_result[:shadowed], scope_stats: conflict_result[:scope_stats],
337
+ scope_budget: conflict_result[:scope_budget], scope_filter_warnings: merged_bundle[:scope_filter_warnings]
338
+ )
159
339
  )
160
340
  Contracts::ContextPackage.validate!(context)
161
341
 
@@ -170,24 +350,134 @@ module SmartBrain
170
350
  ignored_fields: context.dig(:debug, :ignored),
171
351
  token_used: token_budget[:used_estimate] || 0,
172
352
  token_limit: token_budget[:limit] || config.composition.fetch(:token_limit, 8192),
173
- took_ms: took_ms
353
+ took_ms: took_ms,
354
+ scope_stats: conflict_result[:scope_stats],
355
+ shadowed_count: conflict_result[:shadowed].size,
356
+ scope_filter_warnings: merged_bundle[:scope_filter_warnings]
174
357
  )
175
358
  context
176
359
  end
177
360
 
361
+ def search_memory(session_id:, query:, limit: nil, domain_id: nil, scope_context: nil)
362
+ limit ||= config.retrieval.fetch(:top_k, 30)
363
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
364
+ result = memory_store.search_memory(query: query, scope_ids: resolved[:scope_ids], limit: limit * resolved[:scope_ids].size)
365
+ conflict_resolver.resolve(result)[:selected].first(limit)
366
+ end
367
+
368
+ def add_document(source:, options: {}) = smart_rag_client.add_document(source, options)
369
+ def add_media(source:, options: {}) = smart_rag_client.add_media(source, options)
370
+ def add_image(source:, options: {}) = smart_rag_client.add_image(source, options)
371
+ def add_audio(source:, options: {}) = smart_rag_client.add_audio(source, options)
372
+ def add_video(source:, options: {}) = smart_rag_client.add_video(source, options)
373
+ def enqueue_media(source:, options: {}) = smart_rag_client.enqueue_media(source, options)
374
+ def media_job(job_id:) = smart_rag_client.media_job(job_id)
375
+ def media_jobs(status: nil, limit: 20, offset: 0) = smart_rag_client.media_jobs(status: status, limit: limit, offset: offset)
376
+ def cancel_media_job(job_id:) = smart_rag_client.cancel_media_job(job_id)
377
+ def retry_media_job(job_id:) = smart_rag_client.retry_media_job(job_id)
378
+ def media_job_statistics = smart_rag_client.media_job_statistics
379
+
380
+ def memory_item(id:)
381
+ memory_store.find_item(id: id)
382
+ end
383
+
384
+ def storage_backend
385
+ config.storage_backend
386
+ end
387
+
178
388
  def diagnostics
179
- tracker.snapshot.merge(turns: event_store.all_turns(session_id: nil))
389
+ tracker.snapshot.merge(
390
+ backend: storage_backend,
391
+ turns: event_store.all_turns(session_id: nil),
392
+ summaries: memory_store.all_summaries
393
+ )
180
394
  end
181
395
 
182
396
  private
183
397
 
184
398
  attr_reader :config, :clock, :event_store, :memory_store, :extractor, :consolidator,
185
- :planner, :memory_retriever, :smart_rag_client, :merger, :composer, :tracker
399
+ :scope_registry, :scope_resolver, :conflict_resolver, :planner, :memory_retriever,
400
+ :smart_rag_client, :merger, :composer, :tracker, :model_provider, :lifecycle, :knowledge_graph
401
+
402
+ def resolve_scopes(domain_id:, session_id:, scope_context:)
403
+ scope_registry.resolve(
404
+ domain_id: domain_id, session_id: session_id, scope_context: scope_context,
405
+ allowed_types: config.allowed_scope_types
406
+ )
407
+ rescue ArgumentError => e
408
+ tracker.log_scope_validation_failure(e)
409
+ raise
410
+ end
411
+
412
+ def public_scope_context(resolved)
413
+ { read: resolved[:read], write: resolved[:write], default_write: resolved[:default_write] }
414
+ end
415
+
416
+ def route_scoped_records(extracted, resolved, session_id)
417
+ scope_resolver.route(extracted: extracted, resolved_context: resolved, source_session_id: session_id)
418
+ rescue ArgumentError => e
419
+ tracker.log_scope_validation_failure(e)
420
+ raise
421
+ end
422
+
423
+ def normalize_scope_ref(ref)
424
+ Contracts::ScopeRef.normalize(ref, allowed_types: config.allowed_scope_types)
425
+ rescue ArgumentError => e
426
+ tracker.log_scope_validation_failure(e)
427
+ raise
428
+ end
429
+
430
+ def symbolize_keys(hash)
431
+ hash.each_with_object({}) { |(key, value), result| result[key.to_sym] = value }
432
+ end
433
+
434
+ def scope_validation_failure!(message)
435
+ error = ArgumentError.new(message)
436
+ tracker.log_scope_validation_failure(error)
437
+ raise error
438
+ end
439
+
440
+ def authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
441
+ item = memory_store.find_item(id: memory_item_id)
442
+ raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
443
+ return item unless domain_id || session_id || scope_context
444
+ scope_validation_failure!('session_id is required to validate memory item access') if session_id.to_s.empty?
445
+
446
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
447
+ scope_validation_failure!("memory item is outside the requested domain/scopes: #{memory_item_id}") unless resolved[:scope_ids].include?(item[:scope_id])
448
+
449
+ item
450
+ end
451
+
452
+ def authorize_edge!(edge_id, domain_id:, session_id:, scope_context:)
453
+ edge = memory_store.find_edge(id: edge_id)
454
+ raise ArgumentError, "edge not found: #{edge_id}" unless edge
455
+ return edge unless domain_id || session_id || scope_context
456
+ scope_validation_failure!('session_id is required to validate edge access') if session_id.to_s.empty?
457
+
458
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
459
+ scope_validation_failure!("edge is outside the requested domain/scopes: #{edge_id}") unless resolved[:scope_ids].include?(edge[:scope_id])
460
+
461
+ edge
462
+ end
186
463
 
187
464
  def monotonic_time
188
465
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
466
  end
190
467
 
468
+ def persist_edges(session_id:, turn_id:, edges:)
469
+ return [] unless edges&.any?
470
+
471
+ edges.map do |edge|
472
+ knowledge_graph.add(
473
+ session_id: session_id,
474
+ subject: edge[:subject], predicate: edge[:predicate], object: edge[:object],
475
+ source_turn_id: turn_id, confidence: edge[:confidence], scope_id: edge[:scope_id],
476
+ scope: edge[:scope], source_session_id: edge[:source_session_id]
477
+ )
478
+ end
479
+ end
480
+
191
481
  def elapsed_ms(start)
192
482
  ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
193
483
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartBrain
4
+ module Scopes
5
+ class ConflictResolver
6
+ DEFAULT_PRIORITY = %w[task project expert global session].freeze
7
+
8
+ def initialize(config:)
9
+ @priority = Array(config.scopes[:conflict_priority] || DEFAULT_PRIORITY).map(&:to_s)
10
+ @max_items_per_scope = Integer(config.scopes.fetch(:max_items_per_scope, 10))
11
+ end
12
+
13
+ def resolve(evidence)
14
+ selected = []
15
+ shadowed = []
16
+ keyed, unkeyed = evidence.partition { |item| item[:source] == 'memory' && item[:memory_type] && item[:memory_key] }
17
+
18
+ keyed.group_by { |item| [item[:memory_type], item[:memory_key]] }.each_value do |candidates|
19
+ winner = candidates.min_by { |item| winner_rank(item) }
20
+ selected << winner
21
+ candidates.each do |candidate|
22
+ next if candidate.equal?(winner)
23
+
24
+ shadowed << candidate.merge(
25
+ shadow_reason: shadow_reason(winner, candidate),
26
+ shadowed_by: winner[:id]
27
+ )
28
+ end
29
+ end
30
+ selected.concat(unkeyed)
31
+
32
+ budgeted = []
33
+ counts = Hash.new(0)
34
+ selected.sort_by { |item| -item.fetch(:score, 0.0) }.each do |item|
35
+ scope_key = item[:scope] && "#{item.dig(:scope, :type)}:#{item.dig(:scope, :id)}"
36
+ if scope_key && counts[scope_key] >= @max_items_per_scope
37
+ shadowed << item.merge(shadow_reason: 'scope_budget')
38
+ else
39
+ counts[scope_key] += 1 if scope_key
40
+ budgeted << item
41
+ end
42
+ end
43
+
44
+ {
45
+ selected: budgeted,
46
+ shadowed: shadowed,
47
+ scope_stats: counts,
48
+ scope_budget: { max_items_per_scope: @max_items_per_scope }
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ def winner_rank(item)
55
+ pinned = item[:tier].to_s == 'dao_tian' ? 0 : 1
56
+ scope_priority = @priority.index(item.dig(:scope, :type).to_s) || @priority.length
57
+ [pinned, scope_priority, -item.fetch(:score, 0.0)]
58
+ end
59
+
60
+ def shadow_reason(winner, candidate)
61
+ return 'dao_tian_pinned' if winner[:tier].to_s == 'dao_tian' && candidate[:tier].to_s != 'dao_tian'
62
+
63
+ "more_specific_scope:#{winner.dig(:scope, :type)}"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative '../contracts/scope_context'
5
+
6
+ module SmartBrain
7
+ module Scopes
8
+ class Registry
9
+ def initialize(db: nil, clock: -> { Time.now.utc })
10
+ @db = db
11
+ @clock = clock
12
+ @domains = {}
13
+ @sessions = {}
14
+ @scopes = {}
15
+ @links = {}
16
+ end
17
+
18
+ def resolve(domain_id:, session_id:, scope_context:, allowed_types: Contracts::ScopeRef::DEFAULT_ALLOWED_TYPES)
19
+ context = Contracts::ScopeContext.normalize(
20
+ domain_id: domain_id, session_id: session_id, scope_context: scope_context, allowed_types: allowed_types
21
+ )
22
+ records = db ? resolve_postgres(context, session_id.to_s) : resolve_memory(context, session_id.to_s)
23
+ default_key = scope_key(context[:default_write])
24
+
25
+ context.merge(
26
+ scopes: records,
27
+ scope_ids: records.map { |record| record[:scope_id] },
28
+ default_write_scope: records.find { |record| scope_key(record) == default_key }
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :db, :clock, :domains, :sessions, :scopes, :links
35
+
36
+ def resolve_memory(context, session_id)
37
+ domain_id = context[:domain_id]
38
+ domains[domain_id] ||= { id: domain_id, metadata: {}, created_at: clock.call.iso8601 }
39
+ existing_domain = sessions[session_id]
40
+ if existing_domain && existing_domain != domain_id
41
+ raise ArgumentError, "session #{session_id} belongs to domain #{existing_domain}"
42
+ end
43
+ sessions[session_id] = domain_id
44
+
45
+ context[:read].each_with_index.map do |ref, priority|
46
+ key = [domain_id, ref[:type], ref[:id]]
47
+ scope = scopes[key] ||= scope_record(domain_id, ref)
48
+ links[[session_id, scope[:scope_id]]] = {
49
+ access_mode: writable?(context, ref) ? 'read_write' : 'read',
50
+ priority: priority
51
+ }
52
+ scope
53
+ end
54
+ end
55
+
56
+ def resolve_postgres(context, session_id)
57
+ domain_id = context[:domain_id]
58
+ records = []
59
+ db.transaction do
60
+ ensure_domain(domain_id)
61
+ ensure_session(session_id, domain_id)
62
+ records = context[:read].each_with_index.map do |ref, priority|
63
+ scope = ensure_scope(domain_id, ref)
64
+ ensure_link(session_id, scope[:id], priority, writable?(context, ref) ? 'read_write' : 'read')
65
+ scope_record_from_row(scope)
66
+ end
67
+ end
68
+ records
69
+ end
70
+
71
+ def ensure_domain(domain_id)
72
+ return if db[:domains].where(id: domain_id).first
73
+
74
+ now = clock.call
75
+ db[:domains].insert(id: domain_id, metadata_json: Sequel.pg_jsonb({}), created_at: now, updated_at: now)
76
+ end
77
+
78
+ def ensure_session(session_id, domain_id)
79
+ row = db[:sessions].where(id: session_id).first
80
+ if row && row[:domain_id] != domain_id
81
+ raise ArgumentError, "session #{session_id} belongs to domain #{row[:domain_id]}"
82
+ end
83
+ return if row
84
+
85
+ db[:sessions].insert(id: session_id, domain_id: domain_id, created_at: clock.call, metadata_json: Sequel.pg_jsonb({}))
86
+ end
87
+
88
+ def ensure_scope(domain_id, ref)
89
+ dataset = db[:memory_scopes].where(domain_id: domain_id, scope_type: ref[:type], external_id: ref[:id])
90
+ dataset.first || begin
91
+ now = clock.call
92
+ id = SecureRandom.uuid
93
+ db[:memory_scopes].insert(
94
+ id: id, domain_id: domain_id, scope_type: ref[:type], external_id: ref[:id],
95
+ metadata_json: Sequel.pg_jsonb({}), created_at: now, updated_at: now
96
+ )
97
+ db[:memory_scopes].where(id: id).first
98
+ end
99
+ end
100
+
101
+ def ensure_link(session_id, scope_id, priority, access_mode)
102
+ db[:session_scope_links].insert_conflict(
103
+ target: %i[session_id scope_id], update: { access_mode: access_mode, priority: priority }
104
+ ).insert(session_id: session_id, scope_id: scope_id, access_mode: access_mode, priority: priority, created_at: clock.call)
105
+ end
106
+
107
+ def scope_record(domain_id, ref)
108
+ {
109
+ scope_id: legacy_scope?(domain_id, ref) ? "legacy:session:#{ref[:id]}" : SecureRandom.uuid,
110
+ domain_id: domain_id,
111
+ type: ref[:type],
112
+ id: ref[:id]
113
+ }
114
+ end
115
+
116
+ def scope_record_from_row(row)
117
+ { scope_id: row[:id], domain_id: row[:domain_id], type: row[:scope_type], id: row[:external_id] }
118
+ end
119
+
120
+ def scope_key(ref)
121
+ "#{ref[:type]}:#{ref[:id]}"
122
+ end
123
+
124
+ def writable?(context, ref)
125
+ context[:write].any? { |candidate| scope_key(candidate) == scope_key(ref) }
126
+ end
127
+
128
+ def legacy_scope?(domain_id, ref)
129
+ domain_id == Contracts::ScopeContext::LEGACY_DOMAIN_ID && ref[:type] == 'session'
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartBrain
4
+ module Scopes
5
+ class Resolver
6
+ def route(extracted:, resolved_context:, source_session_id:)
7
+ extracted.merge(
8
+ domain_id: resolved_context[:domain_id],
9
+ items: Array(extracted[:items]).map { |item| route_record(item, resolved_context, source_session_id) },
10
+ edges: Array(extracted[:edges]).map { |edge| route_record(edge, resolved_context, source_session_id) }
11
+ )
12
+ end
13
+
14
+ private
15
+
16
+ def route_record(record, context, source_session_id)
17
+ requested = record[:scope_ref] || context[:default_write]
18
+ normalized = Contracts::ScopeRef.normalize(requested)
19
+ writable = context[:write].any? { |candidate| candidate[:type] == normalized[:type] && candidate[:id] == normalized[:id] }
20
+ raise ArgumentError, "scope is not authorized by scope_context.write: #{normalized[:type]}:#{normalized[:id]}" unless writable
21
+
22
+ scope = context[:scopes].find { |candidate| candidate[:type] == normalized[:type] && candidate[:id] == normalized[:id] }
23
+
24
+ record.merge(
25
+ scope_id: scope[:scope_id],
26
+ scope: { type: scope[:type], id: scope[:id] },
27
+ source_session_id: source_session_id
28
+ ).tap { |routed| routed.delete(:scope_ref) }
29
+ end
30
+ end
31
+ end
32
+ end