smart_brain 0.1.1 → 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 +2 -2
  5. data/README.md +356 -62
  6. data/config/brain.yml +69 -1
  7. data/conversation_demo.rb +5 -5
  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 +2 -2
  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 +1 -1
  73. data/lib/smart_brain.rb +49 -4
  74. metadata +88 -36
@@ -37,5 +37,62 @@ module SmartBrain
37
37
  def observability
38
38
  policies.fetch(:observability, {})
39
39
  end
40
+
41
+ def scopes
42
+ policies.fetch(:scopes, {})
43
+ end
44
+
45
+ def allowed_scope_types
46
+ scopes.fetch(:allowed_types, %w[global project expert task session]).map(&:to_s)
47
+ end
48
+
49
+ def storage
50
+ raw.fetch(:storage, {})
51
+ end
52
+
53
+ def llm
54
+ raw.fetch(:model_provider, {})
55
+ end
56
+
57
+ def llm_provider
58
+ (ENV['SMARTBRAIN_LLM_PROVIDER'] || llm.fetch(:provider, 'stub')).to_s
59
+ end
60
+
61
+ def tiers
62
+ raw.fetch(:tiers, {})
63
+ end
64
+
65
+ def lifecycle
66
+ raw.fetch(:lifecycle, {})
67
+ end
68
+
69
+ def kg
70
+ raw.fetch(:kg, {})
71
+ end
72
+
73
+ def fact_check
74
+ raw.fetch(:fact_check, {})
75
+ end
76
+
77
+ def briefing
78
+ raw.fetch(:briefing, {})
79
+ end
80
+
81
+ def storage_backend
82
+ (ENV['SMARTBRAIN_BACKEND'] || storage.fetch(:backend, 'memory')).to_s
83
+ end
84
+
85
+ def database_config
86
+ cfg = (storage.fetch(:database, {}) || {}).dup
87
+ %i[host port database user password].each do |key|
88
+ env_key = "SMARTBRAIN_DB_#{key.to_s.upcase}"
89
+ cfg[key] = ENV[env_key] if ENV.key?(env_key)
90
+ end
91
+ cfg
92
+ end
93
+
94
+ def fts_config
95
+ storage.fetch(:fts_config, nil) || database_config.fetch(:fts_config, 'simple')
96
+ end
40
97
  end
41
98
  end
@@ -1,42 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../model_provider/stub'
4
+
3
5
  module SmartBrain
4
6
  module Consolidator
7
+ # WorkingSummary is store-backed: summaries persist via the MemoryStore
8
+ # (in-memory hash for MemoryStore::InMemory, the summaries table for
9
+ # MemoryStore::Postgres), so they survive runtime restarts on the PG path.
10
+ # The last-summary turn is derived from the persisted summary's source range.
11
+ #
12
+ # When a real LLM ModelProvider is wired, the summary text is generated by
13
+ # the model (qwen3 etc.); otherwise it falls back to the deterministic
14
+ # template (Stub default), so behavior is stable and offline-friendly.
5
15
  class WorkingSummary
6
- def initialize(config:, clock:)
16
+ SUMMARY_SYSTEM = 'You are a memory consolidator. Produce a concise rolling ' \
17
+ 'summary in the exact section structure given. Skip empty ' \
18
+ 'sections. No preamble, no markdown fences.'
19
+
20
+ def initialize(config:, clock:, memory_store:, model_provider: SmartBrain::ModelProvider::Stub.new)
7
21
  @config = config
8
22
  @clock = clock
9
- @summaries = {}
10
- @last_summary_turn = Hash.new(0)
23
+ @memory_store = memory_store
24
+ @model_provider = model_provider
11
25
  end
12
26
 
13
27
  def update(session_id:, turn_count:, recent_turns:, memory_items:, stage_event: false)
14
28
  reason = trigger_reason(session_id: session_id, turn_count: turn_count, recent_turns: recent_turns, stage_event: stage_event)
15
- return latest_summary(session_id).merge(triggered: false, trigger_reason: 'not_triggered') unless reason
29
+ unless reason
30
+ return latest_summary(session_id).merge(triggered: false, trigger_reason: 'not_triggered')
31
+ end
16
32
 
33
+ text, method = build_summary_text(memory_items: memory_items, recent_turns: recent_turns)
17
34
  summary = {
18
35
  summary_version: next_version(session_id),
19
36
  summary_source_turn_range: source_turn_range(turn_count),
20
37
  summary_generated_at: clock.call.iso8601,
21
- text: build_text(memory_items),
38
+ text: text,
39
+ summary_method: method,
22
40
  triggered: true,
23
41
  trigger_reason: reason
24
42
  }
25
- summaries[session_id] = summary
26
- last_summary_turn[session_id] = turn_count
43
+ memory_store.save_summary(session_id: session_id, summary: summary)
27
44
  summary
28
45
  end
29
46
 
30
47
  def latest_summary(session_id)
31
- summaries[session_id] || default_summary
48
+ memory_store.latest_summary(session_id: session_id) || default_summary
32
49
  end
33
50
 
34
51
  private
35
52
 
36
- attr_reader :config, :clock, :summaries, :last_summary_turn
53
+ attr_reader :config, :clock, :memory_store, :model_provider
54
+
55
+ def build_summary_text(memory_items:, recent_turns:)
56
+ if model_provider&.llm?
57
+ text = llm_summary(memory_items: memory_items, recent_turns: recent_turns)
58
+ return [text, 'llm'] unless text.nil? || text.strip.empty?
59
+ end
60
+
61
+ [build_text(memory_items), 'template']
62
+ end
63
+
64
+ def llm_summary(memory_items:, recent_turns:)
65
+ result = model_provider.complete(
66
+ prompt: summary_prompt(memory_items: memory_items, recent_turns: recent_turns),
67
+ system: SUMMARY_SYSTEM,
68
+ temperature: config.llm.fetch(:temperature, 0.2),
69
+ max_tokens: config.llm.fetch(:summary_max_tokens, 900)
70
+ )
71
+ result[:error] ? nil : result[:text]
72
+ rescue StandardError
73
+ nil
74
+ end
75
+
76
+ def summary_prompt(memory_items:, recent_turns:)
77
+ convo = Array(recent_turns).map { |t| "#{t[:role]}: #{t[:content]}" }.join("\n")
78
+ mem = memory_items.group_by { |i| i[:type] }
79
+ .map { |type, items| "#{type}: #{items.first(8).map { |i| i[:key] }.join(', ')}" }
80
+ .join("\n")
81
+ <<~PROMPT
82
+ <conversation>
83
+ #{convo}
84
+ </conversation>
85
+
86
+ <memory>
87
+ #{mem}
88
+ </memory>
89
+
90
+ Write a concise rolling summary of the conversation above using ONLY these sections (omit a section if empty, terse bullets, cite memory keys). Do not echo the input or add preamble:
91
+ Goals:
92
+ Decisions:
93
+ Tasks:
94
+ Key References:
95
+ Open Questions:
96
+ PROMPT
97
+ end
37
98
 
38
99
  def trigger_reason(session_id:, turn_count:, recent_turns:, stage_event:)
39
- turns_since_last = turn_count - last_summary_turn[session_id]
100
+ turns_since_last = turn_count - last_summary_turn(session_id)
40
101
  threshold = config.retention.fetch(:summarize_after_turns, 12)
41
102
  return 'turn_threshold' if turns_since_last >= threshold
42
103
 
@@ -48,13 +109,20 @@ module SmartBrain
48
109
  nil
49
110
  end
50
111
 
112
+ def last_summary_turn(session_id)
113
+ summary = memory_store.latest_summary(session_id: session_id)
114
+ return 0 unless summary
115
+
116
+ (summary[:summary_source_turn_range] || {})[:to].to_i
117
+ end
118
+
51
119
  def estimate_tokens(recent_turns)
52
120
  recent_turns.sum { |t| t[:content].to_s.length / 4 }
53
121
  end
54
122
 
55
123
  def next_version(session_id)
56
- previous = summaries[session_id]
57
- previous ? previous[:summary_version] + 1 : 1
124
+ previous = memory_store.latest_summary(session_id: session_id)
125
+ previous ? previous[:summary_version].to_i + 1 : 1
58
126
  end
59
127
 
60
128
  def source_turn_range(turn_count)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require_relative '../governance/tiers'
4
5
 
5
6
  module SmartBrain
6
7
  module ContextComposer
@@ -10,9 +11,10 @@ module SmartBrain
10
11
  @clock = clock
11
12
  end
12
13
 
13
- def compose(session_id:, user_message:, plan:, plan_id:, summary:, recent_turns:, evidence_bundle:)
14
+ def compose(session_id:, user_message:, plan:, plan_id:, summary:, recent_turns:, evidence_bundle:,
15
+ domain_id: 'legacy', scope_context: nil)
14
16
  context_id = SecureRandom.uuid
15
- evidence = evidence_bundle.fetch(:selected, [])
17
+ evidence = apply_tier_order(evidence_bundle.fetch(:selected, []))
16
18
  used_estimate = estimate_tokens(summary: summary[:text], recent_turns: recent_turns, evidence: evidence, user_message: user_message)
17
19
  token_limit = config.composition.fetch(:token_limit, 8192)
18
20
 
@@ -20,6 +22,8 @@ module SmartBrain
20
22
  version: '0.1',
21
23
  context_id: context_id,
22
24
  session_id: session_id,
25
+ domain_id: domain_id,
26
+ scope_context: scope_context,
23
27
  created_at: clock.call.iso8601,
24
28
  system_blocks: [],
25
29
  developer_blocks: [],
@@ -54,7 +58,14 @@ module SmartBrain
54
58
  },
55
59
  why_selected: evidence.map { |e| "#{e[:id]} score=#{e[:score]} source=#{e[:source]}" },
56
60
  ignored: evidence_bundle[:ignored_fields] || [],
57
- dropped: (evidence_bundle[:dropped] || []).map { |e| { id: e[:id], reason: e[:drop_reason] } }
61
+ dropped: (evidence_bundle[:dropped] || []).map { |e| { id: e[:id], reason: e[:drop_reason] } },
62
+ scopes_read: Array(scope_context && scope_context[:read]),
63
+ scope_stats: evidence_bundle[:scope_stats] || {},
64
+ shadowed: (evidence_bundle[:shadowed] || []).map do |e|
65
+ e.slice(:id, :memory_type, :memory_key, :scope, :shadow_reason, :shadowed_by)
66
+ end,
67
+ scope_budget: evidence_bundle[:scope_budget] || {},
68
+ scope_filter_warnings: evidence_bundle[:scope_filter_warnings] || []
58
69
  }
59
70
  }
60
71
  end
@@ -70,6 +81,32 @@ module SmartBrain
70
81
  text_size += user_message.to_s.length
71
82
  (text_size / 4.0).ceil
72
83
  end
84
+
85
+ # Order evidence by mind-model tier (dao_tian → dao_ren → shu → qi →
86
+ # evidence), preserving score order within a tier, and cap immutable
87
+ # dao_tian principles at dao_tian_limit.
88
+ def apply_tier_order(evidence)
89
+ dao_tian_limit = begin
90
+ Governance::Tiers.dao_tian_limit(config)
91
+ rescue StandardError
92
+ 1
93
+ end
94
+ stable = evidence.each_with_index
95
+ sorted = stable.sort_by do |e, idx|
96
+ [Governance::Tiers.priority(e[:tier] || 'evidence'), -(e[:score] || 0.0), idx]
97
+ end
98
+ result = []
99
+ dao_tian_count = 0
100
+ sorted.each do |e, _idx|
101
+ if (e[:tier] || 'evidence') == 'dao_tian'
102
+ next if dao_tian_count >= dao_tian_limit
103
+
104
+ dao_tian_count += 1
105
+ end
106
+ result << e
107
+ end
108
+ result
109
+ end
73
110
  end
74
111
  end
75
112
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'scope_context'
4
+
3
5
  module SmartBrain
4
6
  module Contracts
5
7
  class RetrievalPlan
@@ -10,6 +12,14 @@ module SmartBrain
10
12
  raise ArgumentError, "invalid retrieval plan: missing #{missing.join(', ')}" unless missing.empty?
11
13
  raise ArgumentError, 'invalid retrieval plan: queries must not be empty' if Array(plan[:queries]).empty?
12
14
 
15
+ if plan[:scope_context]
16
+ ScopeContext.normalize(
17
+ domain_id: plan.dig(:debug, :caller, :domain_id),
18
+ session_id: plan.dig(:debug, :caller, :session_id) || plan[:session_id],
19
+ scope_context: plan[:scope_context]
20
+ )
21
+ end
22
+
13
23
  true
14
24
  end
15
25
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'scope_ref'
4
+
5
+ module SmartBrain
6
+ module Contracts
7
+ class ScopeContext
8
+ LEGACY_DOMAIN_ID = 'legacy'
9
+
10
+ def self.normalize(domain_id:, session_id:, scope_context:, allowed_types: ScopeRef::DEFAULT_ALLOWED_TYPES)
11
+ normalized_domain = domain_id.to_s.strip
12
+ if normalized_domain.empty? && scope_context
13
+ raise ArgumentError, 'domain_id is required when scope_context is provided'
14
+ end
15
+ normalized_domain = LEGACY_DOMAIN_ID if normalized_domain.empty?
16
+ raise ArgumentError, 'session_id is required' if session_id.to_s.strip.empty?
17
+
18
+ raw = scope_context || legacy_context(session_id)
19
+ read = Array(raw[:read] || raw['read']).map { |ref| ScopeRef.normalize(ref, allowed_types: allowed_types) }
20
+ raise ArgumentError, 'scope_context.read must not be empty' if read.empty?
21
+
22
+ keys = read.map { |ref| "#{ref[:type]}:#{ref[:id]}" }
23
+ duplicates = keys.tally.select { |_key, count| count > 1 }.keys
24
+ raise ArgumentError, "duplicate scope refs: #{duplicates.join(', ')}" unless duplicates.empty?
25
+
26
+ default_write = ScopeRef.normalize(raw[:default_write] || raw['default_write'], allowed_types: allowed_types)
27
+ write_raw = raw.key?(:write) ? raw[:write] : raw['write']
28
+ write = Array(write_raw || [default_write]).map { |ref| ScopeRef.normalize(ref, allowed_types: allowed_types) }
29
+ write_keys = write.map { |ref| "#{ref[:type]}:#{ref[:id]}" }
30
+ write_duplicates = write_keys.tally.select { |_key, count| count > 1 }.keys
31
+ raise ArgumentError, "duplicate writable scope refs: #{write_duplicates.join(', ')}" unless write_duplicates.empty?
32
+ raise ArgumentError, 'scope_context.write must be included in read' unless write_keys.all? { |key| keys.include?(key) }
33
+ unless write_keys.include?("#{default_write[:type]}:#{default_write[:id]}")
34
+ raise ArgumentError, 'scope_context.default_write must be included in write'
35
+ end
36
+
37
+ { domain_id: normalized_domain, read: read, write: write, default_write: default_write }
38
+ end
39
+
40
+ def self.legacy_context(session_id)
41
+ ref = { type: 'session', id: session_id.to_s }
42
+ { read: [ref], write: [ref], default_write: ref }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartBrain
4
+ module Contracts
5
+ class ScopeRef
6
+ DEFAULT_ALLOWED_TYPES = %w[global project expert task session].freeze
7
+
8
+ def self.normalize(value, allowed_types: DEFAULT_ALLOWED_TYPES)
9
+ raise ArgumentError, 'scope ref must be an object' unless value.respond_to?(:[])
10
+
11
+ type = (value[:type] || value['type']).to_s
12
+ external_id = (value[:id] || value['id']).to_s
13
+ raise ArgumentError, "invalid scope type: #{type}" unless Array(allowed_types).map(&:to_s).include?(type)
14
+ raise ArgumentError, 'scope id is required' if external_id.strip.empty?
15
+
16
+ { type: type, id: external_id }
17
+ end
18
+
19
+ def self.key(value)
20
+ ref = normalize(value)
21
+ "#{ref[:type]}:#{ref[:id]}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+ require 'json'
5
+ Sequel.extension :pg_json
6
+
7
+ module SmartBrain
8
+ # SmartBrain::DB owns the Sequel/Postgres connection and schema migration.
9
+ #
10
+ # Backed by config/brain.yml `storage.database` (overridable via
11
+ # SMARTBRAIN_DB_{HOST,PORT,NAME,USER,PASSWORD} env). Connections are memoized
12
+ # per connection signature so the runtime, server, and CLI share one pool.
13
+ module DB
14
+ SCHEMA_SQL_PATH = File.expand_path('../../db/migrate/001_init.sql', __dir__)
15
+ MIGRATIONS_DIR = File.expand_path('../../db/migrate', __dir__)
16
+ DEFAULT_ADAPTER = 'postgres'
17
+
18
+ class << self
19
+ # Returns a memoized Sequel::Database for the given config (or config hash).
20
+ def connect(config)
21
+ opts = connection_opts(config)
22
+ signature = opts.hash
23
+ return pools[signature] if pools.key?(signature)
24
+
25
+ db = Sequel.connect(opts)
26
+ db.sql_log_level = :error if db.respond_to?(:sql_log_level=)
27
+ db.extension(:pg_json) rescue nil
28
+ pools[signature] = db
29
+ end
30
+
31
+ # Run all bundled migration SQL files in order (each is idempotent:
32
+ # CREATE TABLE/INDEX IF NOT EXISTS, ADD COLUMN IF NOT EXISTS).
33
+ def migrate(db)
34
+ Dir.glob(File.join(MIGRATIONS_DIR, '*.sql')).sort.each do |path|
35
+ db.run(File.read(path))
36
+ end
37
+ true
38
+ end
39
+
40
+ # True if a Postgres connection can be established for the given config.
41
+ def reachable?(config)
42
+ db = Sequel.connect(connection_opts(config))
43
+ begin
44
+ db.test_connection
45
+ rescue StandardError
46
+ false
47
+ else
48
+ true
49
+ ensure
50
+ db.disconnect if db
51
+ end
52
+ end
53
+
54
+ # Drop all memoized connections (used by tests / reconfigure).
55
+ def disconnect!
56
+ pools.each_value(&:disconnect)
57
+ pools.clear
58
+ end
59
+
60
+ # Recursively symbolize a JSONB value read from Postgres. Robust whether
61
+ # the adapter returns a parsed Hash/Array, a Sequel JSONBHash/JSONBArray
62
+ # wrapper (NOT a Hash subclass in recent Sequel), or a raw JSON String.
63
+ def symbolize_jsonb(obj)
64
+ case obj
65
+ when String
66
+ begin
67
+ parsed = JSON.parse(obj)
68
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? symbolize_jsonb(parsed) : obj
69
+ rescue JSON::ParserError, TypeError
70
+ obj
71
+ end
72
+ when ->(o) { o.respond_to?(:to_hash) }
73
+ obj.to_hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize_jsonb(v) }
74
+ when ->(o) { !o.nil? && !o.is_a?(String) && o.respond_to?(:to_a) }
75
+ obj.to_a.map { |v| symbolize_jsonb(v) }
76
+ else
77
+ obj
78
+ end
79
+ end
80
+
81
+ # Stringify keys for storage into a JSONB column (Sequel pg_json stores
82
+ # string keys; symbol keys round-trip fine but strings are canonical).
83
+ def stringify_keys(obj)
84
+ return obj unless obj.is_a?(Hash)
85
+
86
+ obj.transform_keys(&:to_s)
87
+ end
88
+
89
+ private
90
+
91
+ def pools
92
+ @pools ||= {}
93
+ end
94
+
95
+ def connection_opts(config)
96
+ cfg = config.respond_to?(:database_config) ? config.database_config : config.fetch(:database, {})
97
+ adapter = (cfg[:adapter] || DEFAULT_ADAPTER).to_sym
98
+ opts = { adapter: adapter }
99
+ opts[:host] = cfg[:host].to_s if cfg[:host]
100
+ opts[:port] = cfg[:port].to_i if cfg[:port]
101
+ opts[:database] = cfg[:database].to_s if cfg[:database]
102
+ opts[:user] = cfg[:user].to_s if cfg[:user]
103
+ opts[:password] = cfg[:password].to_s if cfg[:password]
104
+ opts[:max_connections] = (cfg[:max_connections] || 4).to_i
105
+ opts
106
+ end
107
+ end
108
+ end
109
+ end
@@ -9,8 +9,12 @@ module SmartBrain
9
9
  @sessions = {}
10
10
  end
11
11
 
12
- def append_turn(session_id:, turn_events:, created_at:)
13
- session = sessions[session_id] ||= { seq: 0, turns: [] }
12
+ def append_turn(session_id:, turn_events:, created_at:, domain_id: 'legacy')
13
+ session = sessions[session_id]
14
+ if session && session[:domain_id] != domain_id
15
+ raise ArgumentError, "session #{session_id} belongs to domain #{session[:domain_id]}"
16
+ end
17
+ session ||= sessions[session_id] = { domain_id: domain_id, seq: 0, turns: [] }
14
18
  session[:seq] += 1
15
19
  turn_id = SecureRandom.uuid
16
20
  messages = normalize_messages(turn_id: turn_id, messages: turn_events[:messages] || [], created_at: created_at)