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
data/exe/smart_brain ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # SmartBrain CLI — drive the memory runtime from the shell or as an agent tool.
5
+ #
6
+ # smart_brain status
7
+ # smart_brain migrate (postgres backend only)
8
+ # smart_brain commit --data '{"session_id":..,"turn_events":{..}}'
9
+ # smart_brain compose --data '{"session_id":..,"user_message":..}'
10
+ # smart_brain search --data '{"session_id":..,"query":..}'
11
+ # smart_brain serve [--host 0.0.0.0] [--port 9292] [--config PATH]
12
+ # smart_brain mcp [--config PATH] (stdio JSON-RPC, for Claude Code / Cursor)
13
+
14
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
15
+
16
+ require 'optparse'
17
+ require 'json'
18
+ require 'smart_brain'
19
+ require 'smart_brain/server/service'
20
+ require 'smart_brain/server/http_app'
21
+ require 'smart_brain/server/mcp_server'
22
+
23
+ module SmartBrain
24
+ module CLI
25
+ module_function
26
+
27
+ def run(argv)
28
+ config_path = ENV['SMARTBRAIN_CONFIG']
29
+ global = OptionParser.new do |opts|
30
+ opts.banner = 'Usage: smart_brain <command> [options]'
31
+ opts.on('--config PATH', 'Path to brain.yml') { |p| config_path = p }
32
+ opts.on('--version', 'Print version and exit') { puts SmartBrain::VERSION; exit 0 }
33
+ opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
34
+ end
35
+ global.order!(argv)
36
+
37
+ command = argv.shift
38
+ abort(global.to_s) unless command
39
+
40
+ case command
41
+ when 'status' then print_json(Server::Service.build(config_path: config_path).status)
42
+ when 'migrate' then print_json(ok: Server::Service.build(config_path: config_path).migrate)
43
+ when 'commit', 'compose', 'search', 'fact-check', 'brief', 'wake-up'
44
+ data_cmd(command, argv, config_path)
45
+ when 'knowledge' then knowledge_cmd(argv, config_path)
46
+ when 'kg' then kg_cmd(argv, config_path)
47
+ when 'serve' then serve(argv, config_path)
48
+ when 'mcp' then mcp(config_path)
49
+ else
50
+ warn "unknown command: #{command}"
51
+ warn global.to_s
52
+ exit 1
53
+ end
54
+ end
55
+
56
+ def data_cmd(command, argv, config_path)
57
+ options = {}
58
+ parser = OptionParser.new do |opts|
59
+ opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
60
+ opts.on('-h', '--help') { puts opts; exit 0 }
61
+ end
62
+ parser.parse!(argv)
63
+
64
+ payload = read_payload(options[:data])
65
+ service = Server::Service.build(config_path: config_path)
66
+ result =
67
+ case command
68
+ when 'commit' then service.commit(**payload.slice(:domain_id, :session_id, :scope_context, :turn_events).merge(turn_events: payload[:turn_events] || {}))
69
+ when 'compose' then service.compose(**payload.slice(:domain_id, :session_id, :scope_context, :user_message, :agent_state).merge(agent_state: payload[:agent_state] || {}))
70
+ when 'search' then service.search(**payload.slice(:domain_id, :session_id, :scope_context, :query, :limit))
71
+ when 'fact-check' then service.fact_check(**payload.slice(:domain_id, :session_id, :scope_context, :text))
72
+ when 'brief' then service.brief(**payload.slice(:domain_id, :session_id, :scope_context, :query))
73
+ when 'wake-up' then service.wake_up(**payload.slice(:domain_id, :session_id, :scope_context))
74
+ end
75
+ print_json(result)
76
+ end
77
+
78
+ def knowledge_cmd(argv, config_path)
79
+ options = {}
80
+ OptionParser.new do |opts|
81
+ opts.banner = 'usage: smart_brain knowledge <distill|gate|promote|demote|retract|promote-to-scope|lineage|events> --data JSON'
82
+ opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
83
+ opts.on('-h', '--help') { puts opts; exit 0 }
84
+ end.parse!(argv)
85
+
86
+ action = argv.shift
87
+ abort 'usage: smart_brain knowledge <distill|gate|promote|demote|retract|promote-to-scope|lineage|events> --data JSON' unless action
88
+
89
+ payload = read_payload(options[:data])
90
+ service = Server::Service.build(config_path: config_path)
91
+ result =
92
+ case action
93
+ when 'distill' then service.distill(**payload.slice(:domain_id, :session_id, :scope_context, :statement, :content, :tier, :supporting_refs, :domain, :field, :reviewer, :key))
94
+ when 'gate' then service.gate(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
95
+ when 'promote' then service.promote(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :verification_refs, :reason, :reviewer, :force))
96
+ when 'demote' then service.demote(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :evidence_refs, :reason, :reason_type, :reviewer))
97
+ when 'retract' then service.retract(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :evidence_refs, :reason, :reviewer))
98
+ when 'promote-to-scope' then service.promote_to_scope(**payload.slice(:memory_item_id, :target_scope, :domain_id, :session_id, :scope_context, :verification_refs, :reason, :reviewer))
99
+ when 'lineage' then service.promotion_lineage(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
100
+ when 'events' then service.knowledge_events(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
101
+ else abort "unknown knowledge action: #{action}"
102
+ end
103
+ print_json(result)
104
+ rescue SmartBrain::Governance::LifecycleGateError, ArgumentError => e
105
+ warn "#{e.class}: #{e.message}"
106
+ exit 1
107
+ end
108
+
109
+ def kg_cmd(argv, config_path)
110
+ options = {}
111
+ OptionParser.new do |opts|
112
+ opts.banner = 'usage: smart_brain kg <add|query|timeline|invalidate|stats> --data JSON'
113
+ opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
114
+ opts.on('-h', '--help') { puts opts; exit 0 }
115
+ end.parse!(argv)
116
+ action = argv.shift
117
+ abort 'usage: smart_brain kg <add|query|timeline|invalidate|stats> --data JSON' unless action
118
+
119
+ payload = read_payload(options[:data])
120
+ service = Server::Service.build(config_path: config_path)
121
+ result =
122
+ case action
123
+ when 'add' then service.kg_add(**payload.slice(:domain_id, :session_id, :scope_context, :scope_ref, :subject, :predicate, :object, :confidence, :source_turn_id))
124
+ when 'query' then service.kg_query(**payload.slice(:domain_id, :session_id, :scope_context, :subject, :predicate, :object, :include_invalid))
125
+ when 'timeline' then service.kg_timeline(**payload.slice(:domain_id, :session_id, :scope_context, :subject))
126
+ when 'invalidate' then service.kg_invalidate(**payload.slice(:edge_id, :reason, :domain_id, :session_id, :scope_context))
127
+ when 'stats' then service.kg_stats(**payload.slice(:domain_id, :session_id, :scope_context))
128
+ else abort "unknown kg action: #{action}"
129
+ end
130
+ print_json(result)
131
+ end
132
+
133
+ def serve(argv, config_path)
134
+ options = { host: '127.0.0.1', port: 9292 }
135
+ OptionParser.new do |opts|
136
+ opts.on('--host HOST') { |h| options[:host] = h }
137
+ opts.on('--port PORT', Integer) { |p| options[:port] = p }
138
+ opts.on('-h', '--help') { puts opts; exit 0 }
139
+ end.parse!(argv)
140
+
141
+ service = Server::Service.build(config_path: config_path)
142
+ Server::HttpApp.service = service
143
+ warn "[smart_brain] HTTP on http://#{options[:host]}:#{options[:port]} (backend: #{service.backend})"
144
+ Server::HttpApp.run!(host: options[:host], port: options[:port], server: :puma) do |server|
145
+ # Puma-specific tuning can go here.
146
+ end
147
+ end
148
+
149
+ def mcp(config_path)
150
+ service = Server::Service.build(config_path: config_path)
151
+ Server::McpServer.new(service: service).run
152
+ end
153
+
154
+ def read_payload(raw)
155
+ return {} if raw.nil? && $stdin.tty?
156
+ raw ||= $stdin.read
157
+ raw.to_s.strip.empty? ? {} : JSON.parse(raw, symbolize_names: true)
158
+ rescue JSON::ParserError => e
159
+ abort "invalid JSON payload: #{e.message}"
160
+ end
161
+
162
+ def print_json(payload)
163
+ puts JSON.generate(payload)
164
+ end
165
+ end
166
+ end
167
+
168
+ SmartBrain::CLI.run(ARGV)
@@ -1,16 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'scope_filter'
4
+
3
5
  module SmartBrain
4
6
  module Adapters
5
7
  module SmartRag
6
8
  class DirectClient
7
- def initialize(rag:)
9
+ include ScopeFilter
10
+
11
+ def initialize(rag:, scope_mapper: nil, fail_closed: true)
8
12
  @rag = rag
13
+ @scope_mapper = scope_mapper
14
+ @fail_closed = fail_closed
9
15
  end
10
16
 
11
17
  def retrieve(plan)
12
- response = rag.retrieve(plan: plan)
13
- normalize_pack(response, request_id: plan[:request_id])
18
+ scoped_plan, required, ignored, warnings = prepare_scoped_plan(plan)
19
+ return scope_failure_pack(plan, ignored, warnings, prefix: 'scope-blocked') unless scoped_plan
20
+
21
+ response = rag.retrieve(plan: scoped_plan)
22
+ pack = normalize_pack(response, request_id: plan[:request_id])
23
+ enforce_scope_confirmation(pack, required: required, ignored: ignored, warnings: warnings)
14
24
  rescue StandardError => e
15
25
  {
16
26
  version: '0.1',
@@ -26,7 +36,7 @@ module SmartBrain
26
36
 
27
37
  private
28
38
 
29
- attr_reader :rag
39
+ attr_reader :rag, :scope_mapper, :fail_closed
30
40
 
31
41
  def normalize_pack(response, request_id:)
32
42
  pack = response.is_a?(Hash) ? response : {}
@@ -38,7 +48,8 @@ module SmartBrain
38
48
  evidences: Array(pack[:evidences]),
39
49
  stats: pack[:stats] || { candidates: Array(pack[:evidences]).size, returned: Array(pack[:evidences]).size, took_ms: 0 },
40
50
  explain: pack[:explain] || { ignored_fields: [] },
41
- warnings: Array(pack[:warnings])
51
+ warnings: Array(pack[:warnings]),
52
+ scope_filter_applied: pack[:scope_filter_applied] == true
42
53
  }
43
54
  end
44
55
  end
@@ -1,18 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'scope_filter'
4
+
3
5
  module SmartBrain
4
6
  module Adapters
5
7
  module SmartRag
6
8
  class HttpClient
7
- def initialize(transport:, timeout_seconds: 2)
9
+ include ScopeFilter
10
+
11
+ def initialize(transport:, timeout_seconds: 2, scope_mapper: nil, fail_closed: true)
8
12
  @transport = transport
9
13
  @timeout_seconds = timeout_seconds
14
+ @scope_mapper = scope_mapper
15
+ @fail_closed = fail_closed
10
16
  end
11
17
 
12
18
  def retrieve(plan)
13
19
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
- raw = transport.call(plan, timeout_seconds: timeout_seconds)
15
- build_pack(raw: raw, request_id: plan[:request_id], took_ms: elapsed_ms(started_at))
20
+ scoped_plan, required, ignored, warnings = prepare_scoped_plan(plan)
21
+ return scope_failure_pack(plan, ignored, warnings, prefix: 'scope-blocked') unless scoped_plan
22
+
23
+ raw = transport.call(scoped_plan, timeout_seconds: timeout_seconds)
24
+ pack = build_pack(raw: raw, request_id: plan[:request_id], took_ms: elapsed_ms(started_at))
25
+ enforce_scope_confirmation(pack, required: required, ignored: ignored, warnings: warnings)
16
26
  rescue Timeout::Error
17
27
  {
18
28
  version: '0.1',
@@ -28,7 +38,7 @@ module SmartBrain
28
38
 
29
39
  private
30
40
 
31
- attr_reader :transport, :timeout_seconds
41
+ attr_reader :transport, :timeout_seconds, :scope_mapper, :fail_closed
32
42
 
33
43
  def build_pack(raw:, request_id:, took_ms:)
34
44
  ignored = []
@@ -48,7 +58,8 @@ module SmartBrain
48
58
  explain: {
49
59
  ignored_fields: ignored + Array(raw.dig(:explain, :ignored_fields))
50
60
  },
51
- warnings: Array(raw[:warnings])
61
+ warnings: Array(raw[:warnings]),
62
+ scope_filter_applied: raw[:scope_filter_applied] == true
52
63
  }
53
64
  end
54
65
 
@@ -5,6 +5,9 @@ module SmartBrain
5
5
  module SmartRag
6
6
  class NullClient
7
7
  def retrieve(plan)
8
+ business_scoped = Array(plan.dig(:scope_context, :read)).any? do |ref|
9
+ (ref[:type] || ref['type']).to_s != 'session'
10
+ end
8
11
  {
9
12
  version: '0.1',
10
13
  plan_id: "local-#{plan[:request_id]}",
@@ -12,8 +15,10 @@ module SmartBrain
12
15
  generated_at: Time.now.utc.iso8601,
13
16
  evidences: [],
14
17
  stats: { candidates: 0, returned: 0, took_ms: 0 },
15
- explain: { ignored_fields: [] },
16
- warnings: ['smart_rag client not configured; returned empty evidences']
18
+ explain: { ignored_fields: business_scoped ? ['scope_context.read'] : [] },
19
+ warnings: [business_scoped ? 'smart_rag scope filter unavailable: client not configured; failed closed' :
20
+ 'smart_rag client not configured; returned empty evidences'],
21
+ scope_filter_applied: !business_scoped
17
22
  }
18
23
  end
19
24
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartBrain
4
+ module Adapters
5
+ module SmartRag
6
+ module ScopeFilter
7
+ private
8
+
9
+ def prepare_scoped_plan(plan)
10
+ refs = Array(plan.dig(:scope_context, :read)).reject { |ref| (ref[:type] || ref['type']).to_s == 'session' }
11
+ return [plan, false, [], []] if refs.empty?
12
+
13
+ unless scope_mapper
14
+ warning = 'smart_rag scope filter unavailable: no scope mapper configured'
15
+ return [nil, true, ['scope_context.read'], [warning]] if fail_closed
16
+ return [plan, true, ['scope_context.read'], [warning]]
17
+ end
18
+
19
+ mapped = scope_mapper.call(
20
+ domain_id: plan.dig(:debug, :caller, :domain_id),
21
+ scopes: refs
22
+ )
23
+ applied = mapped.fetch(:applied, true)
24
+ filters = mapped[:filters] || mapped.reject { |key, _value| %i[applied warnings ignored_fields].include?(key) }
25
+ warnings = Array(mapped[:warnings])
26
+ ignored = Array(mapped[:ignored_fields])
27
+ return [nil, true, ignored + ['scope_context.read'], warnings + ['smart_rag scope mapper did not apply filters']] if !applied && fail_closed
28
+
29
+ [plan.merge(scope_filters: filters), true, ignored, warnings]
30
+ rescue StandardError => e
31
+ warning = "smart_rag scope mapping failed: #{e.message}"
32
+ fail_closed ? [nil, true, ['scope_context.read'], [warning]] : [plan, true, ['scope_context.read'], [warning]]
33
+ end
34
+
35
+ def scope_failure_pack(plan, ignored, warnings, prefix:)
36
+ {
37
+ version: '0.2', request_id: plan[:request_id], plan_id: "#{prefix}-#{plan[:request_id]}",
38
+ generated_at: Time.now.utc.iso8601, evidences: [],
39
+ stats: { candidates: 0, returned: 0, took_ms: 0 },
40
+ explain: { ignored_fields: ignored }, warnings: warnings,
41
+ scope_filter_applied: false
42
+ }
43
+ end
44
+
45
+ def enforce_scope_confirmation(pack, required:, ignored:, warnings:)
46
+ confirmed = pack[:scope_filter_applied] == true
47
+ pack[:explain] ||= { ignored_fields: [] }
48
+ pack[:explain][:ignored_fields] = Array(pack.dig(:explain, :ignored_fields)) + ignored
49
+ pack[:warnings] = Array(pack[:warnings]) + warnings
50
+ return pack unless required && !confirmed
51
+
52
+ pack[:explain][:ignored_fields] << 'scope_context.read'
53
+ pack[:warnings] << 'smart_rag did not confirm scope filter application'
54
+ pack[:evidences] = [] if fail_closed
55
+ pack
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -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