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,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
+ )
193
+ end
194
+
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
+ )
52
201
  end
202
+ def resume(...) = @briefing.resume(...)
53
203
 
54
- def commit_turn(session_id:, turn_events:)
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,9 @@ 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)
107
268
  started_at = monotonic_time
269
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
108
270
  recent_turns = event_store.recent_turns(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
109
271
  refs = event_store.recent_refs(session_id: session_id, limit: config.composition.fetch(:recent_turns_max, 8))
110
272
  request_id = SecureRandom.uuid
@@ -116,15 +278,23 @@ module SmartBrain
116
278
  recent_turns: recent_turns,
117
279
  refs: refs
118
280
  )
281
+ plan[:scope_context] = public_scope_context(resolved)
282
+ plan[:debug] = (plan[:debug] || {}).merge(
283
+ caller: { app: 'smart_brain', domain_id: resolved[:domain_id], session_id: session_id }
284
+ )
119
285
  Contracts::RetrievalPlan.validate!(plan)
120
286
 
121
287
  memory_evidence = memory_retriever.retrieve(
122
288
  query: user_message,
123
- memory_items: memory_store.active_items(session_id: session_id),
289
+ memory_store: memory_store,
290
+ session_id: session_id,
291
+ scope_ids: resolved[:scope_ids],
124
292
  recent_turns: recent_turns,
125
- entities: memory_store.entities(session_id: session_id),
293
+ entities: memory_store.entities(scope_ids: resolved[:scope_ids]),
126
294
  refs: refs
127
295
  )
296
+ conflict_result = conflict_resolver.resolve(memory_evidence)
297
+ memory_evidence = conflict_result[:selected]
128
298
 
129
299
  resource_pack = if plan.dig(:resource_retrieval, :enabled)
130
300
  smart_rag_client.retrieve(plan)
@@ -147,15 +317,23 @@ module SmartBrain
147
317
  resource_evidence: resource_pack.fetch(:evidences, [])
148
318
  )
149
319
  merged_bundle[:ignored_fields] = Array(merged_bundle[:ignored_fields]) + Array(resource_pack.dig(:explain, :ignored_fields))
320
+ merged_bundle[:scope_filter_warnings] = Array(resource_pack[:warnings]).select do |warning|
321
+ warning.to_s.include?('scope')
322
+ end
150
323
 
151
324
  context = composer.compose(
152
325
  session_id: session_id,
326
+ domain_id: resolved[:domain_id],
327
+ scope_context: public_scope_context(resolved),
153
328
  user_message: user_message,
154
329
  plan: plan,
155
330
  plan_id: resource_pack[:plan_id],
156
331
  summary: consolidator.latest_summary(session_id),
157
332
  recent_turns: recent_turns,
158
- evidence_bundle: merged_bundle
333
+ evidence_bundle: merged_bundle.merge(
334
+ shadowed: conflict_result[:shadowed], scope_stats: conflict_result[:scope_stats],
335
+ scope_budget: conflict_result[:scope_budget], scope_filter_warnings: merged_bundle[:scope_filter_warnings]
336
+ )
159
337
  )
160
338
  Contracts::ContextPackage.validate!(context)
161
339
 
@@ -170,24 +348,118 @@ module SmartBrain
170
348
  ignored_fields: context.dig(:debug, :ignored),
171
349
  token_used: token_budget[:used_estimate] || 0,
172
350
  token_limit: token_budget[:limit] || config.composition.fetch(:token_limit, 8192),
173
- took_ms: took_ms
351
+ took_ms: took_ms,
352
+ scope_stats: conflict_result[:scope_stats],
353
+ shadowed_count: conflict_result[:shadowed].size,
354
+ scope_filter_warnings: merged_bundle[:scope_filter_warnings]
174
355
  )
175
356
  context
176
357
  end
177
358
 
359
+ def search_memory(session_id:, query:, limit: nil, domain_id: nil, scope_context: nil)
360
+ limit ||= config.retrieval.fetch(:top_k, 30)
361
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
362
+ result = memory_store.search_memory(query: query, scope_ids: resolved[:scope_ids], limit: limit * resolved[:scope_ids].size)
363
+ conflict_resolver.resolve(result)[:selected].first(limit)
364
+ end
365
+
366
+ def memory_item(id:)
367
+ memory_store.find_item(id: id)
368
+ end
369
+
370
+ def storage_backend
371
+ config.storage_backend
372
+ end
373
+
178
374
  def diagnostics
179
- tracker.snapshot.merge(turns: event_store.all_turns(session_id: nil))
375
+ tracker.snapshot.merge(
376
+ backend: storage_backend,
377
+ turns: event_store.all_turns(session_id: nil),
378
+ summaries: memory_store.all_summaries
379
+ )
180
380
  end
181
381
 
182
382
  private
183
383
 
184
384
  attr_reader :config, :clock, :event_store, :memory_store, :extractor, :consolidator,
185
- :planner, :memory_retriever, :smart_rag_client, :merger, :composer, :tracker
385
+ :scope_registry, :scope_resolver, :conflict_resolver, :planner, :memory_retriever,
386
+ :smart_rag_client, :merger, :composer, :tracker, :model_provider, :lifecycle, :knowledge_graph
387
+
388
+ def resolve_scopes(domain_id:, session_id:, scope_context:)
389
+ scope_registry.resolve(
390
+ domain_id: domain_id, session_id: session_id, scope_context: scope_context,
391
+ allowed_types: config.allowed_scope_types
392
+ )
393
+ rescue ArgumentError => e
394
+ tracker.log_scope_validation_failure(e)
395
+ raise
396
+ end
397
+
398
+ def public_scope_context(resolved)
399
+ { read: resolved[:read], write: resolved[:write], default_write: resolved[:default_write] }
400
+ end
401
+
402
+ def route_scoped_records(extracted, resolved, session_id)
403
+ scope_resolver.route(extracted: extracted, resolved_context: resolved, source_session_id: session_id)
404
+ rescue ArgumentError => e
405
+ tracker.log_scope_validation_failure(e)
406
+ raise
407
+ end
408
+
409
+ def normalize_scope_ref(ref)
410
+ Contracts::ScopeRef.normalize(ref, allowed_types: config.allowed_scope_types)
411
+ rescue ArgumentError => e
412
+ tracker.log_scope_validation_failure(e)
413
+ raise
414
+ end
415
+
416
+ def scope_validation_failure!(message)
417
+ error = ArgumentError.new(message)
418
+ tracker.log_scope_validation_failure(error)
419
+ raise error
420
+ end
421
+
422
+ def authorize_item!(memory_item_id, domain_id:, session_id:, scope_context:)
423
+ item = memory_store.find_item(id: memory_item_id)
424
+ raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
425
+ return item unless domain_id || session_id || scope_context
426
+ scope_validation_failure!('session_id is required to validate memory item access') if session_id.to_s.empty?
427
+
428
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
429
+ scope_validation_failure!("memory item is outside the requested domain/scopes: #{memory_item_id}") unless resolved[:scope_ids].include?(item[:scope_id])
430
+
431
+ item
432
+ end
433
+
434
+ def authorize_edge!(edge_id, domain_id:, session_id:, scope_context:)
435
+ edge = memory_store.find_edge(id: edge_id)
436
+ raise ArgumentError, "edge not found: #{edge_id}" unless edge
437
+ return edge unless domain_id || session_id || scope_context
438
+ scope_validation_failure!('session_id is required to validate edge access') if session_id.to_s.empty?
439
+
440
+ resolved = resolve_scopes(domain_id: domain_id, session_id: session_id, scope_context: scope_context)
441
+ scope_validation_failure!("edge is outside the requested domain/scopes: #{edge_id}") unless resolved[:scope_ids].include?(edge[:scope_id])
442
+
443
+ edge
444
+ end
186
445
 
187
446
  def monotonic_time
188
447
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
448
  end
190
449
 
450
+ def persist_edges(session_id:, turn_id:, edges:)
451
+ return [] unless edges&.any?
452
+
453
+ edges.map do |edge|
454
+ knowledge_graph.add(
455
+ session_id: session_id,
456
+ subject: edge[:subject], predicate: edge[:predicate], object: edge[:object],
457
+ source_turn_id: turn_id, confidence: edge[:confidence], scope_id: edge[:scope_id],
458
+ scope: edge[:scope], source_session_id: edge[:source_session_id]
459
+ )
460
+ end
461
+ end
462
+
191
463
  def elapsed_ms(start)
192
464
  ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
193
465
  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