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
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module SmartBrain
7
+ module Adapters
8
+ module SmartRag
9
+ # Extracts technical metadata from media files (image / audio / video).
10
+ #
11
+ # Stage 1 of the media memory plan (docs/media_memory_schema.md):
12
+ # - image -> Python Pillow bridge (same pattern as MarkitdownBridge)
13
+ # - audio -> ffprobe
14
+ # - video -> ffprobe
15
+ #
16
+ # Degradation policy (D5): when a dependency is missing the extractor
17
+ # returns the default media object with a warning instead of raising,
18
+ # so ingestion can still store file-level metadata.
19
+ #
20
+ # NOTE: This component lives in smart_brain during stage 1 (testable
21
+ # without a SmartRAG database). Stage 3 will sync it into the SmartRAG
22
+ # project as SmartRAG::Core::MediaMetadataExtractor and wire it into
23
+ # DocumentProcessor#extract_metadata.
24
+ class MediaMetadataExtractor
25
+ IMAGE_EXTS = %w[.jpg .jpeg .png .gif .webp .bmp .tif .tiff .heic .heif].freeze
26
+ AUDIO_EXTS = %w[.mp3 .wav .m4a .aac .flac .ogg .oga .opus .wma .aif .aiff].freeze
27
+ VIDEO_EXTS = %w[.mp4 .mkv .mov .webm .avi .flv .wmv .m4v .ts .mpeg .mpg].freeze
28
+
29
+ # Wide schema shared by all three media types; type-specific fields
30
+ # stay nil (design decision D1).
31
+ DEFAULT_MEDIA = {
32
+ format: nil,
33
+ width: nil,
34
+ height: nil,
35
+ dpi: nil,
36
+ color_mode: nil,
37
+ orientation: nil,
38
+ exif: {},
39
+ duration_ms: nil,
40
+ bitrate_bps: nil,
41
+ sample_rate_hz: nil,
42
+ channels: nil,
43
+ codec: nil,
44
+ fps: nil,
45
+ audio_codec: nil
46
+ }.freeze
47
+
48
+ # Extract technical metadata from a media file.
49
+ # @param [String] file_path path to the media file
50
+ # @param [String, Symbol, nil] media_type explicit type override
51
+ # @return [Hash] { media_type:, media: {...}, warnings: [...] }
52
+ def extract(file_path, media_type: nil)
53
+ detected = media_type || detect_media_type(file_path)
54
+ return { media_type: 'other', media: DEFAULT_MEDIA.dup, warnings: [] } unless detected
55
+
56
+ type = detected.to_sym
57
+ media = DEFAULT_MEDIA.dup
58
+ warnings = []
59
+
60
+ case type
61
+ when :image then extract_image(file_path, media, warnings)
62
+ when :audio then extract_audio(file_path, media, warnings)
63
+ when :video then extract_video(file_path, media, warnings)
64
+ end
65
+
66
+ { media_type: type.to_s, media: media, warnings: warnings }
67
+ rescue StandardError => e
68
+ {
69
+ media_type: (type ? type.to_s : 'other'),
70
+ media: (defined?(media) && media) || DEFAULT_MEDIA.dup,
71
+ warnings: ["media metadata extraction failed: #{e.message}"]
72
+ }
73
+ end
74
+
75
+ # Detect media type from file extension.
76
+ # @param [String] file_path
77
+ # @return [Symbol, nil] :image | :audio | :video | nil (unknown)
78
+ def detect_media_type(file_path)
79
+ ext = File.extname(file_path.to_s).downcase
80
+ return :image if IMAGE_EXTS.include?(ext)
81
+ return :audio if AUDIO_EXTS.include?(ext)
82
+ return :video if VIDEO_EXTS.include?(ext)
83
+
84
+ nil
85
+ end
86
+
87
+ # Check whether the image dependency (Pillow) is available.
88
+ def pillow_available?
89
+ return @pillow_available unless @pillow_available.nil?
90
+
91
+ @pillow_available = system('python3', '-c', 'import PIL', out: File::NULL, err: File::NULL) ||
92
+ system('python', '-c', 'import PIL', out: File::NULL, err: File::NULL)
93
+ end
94
+
95
+ # Check whether ffprobe is available.
96
+ def ffprobe_available?
97
+ return @ffprobe_available unless @ffprobe_available.nil?
98
+
99
+ @ffprobe_available = !ffprobe_cmd.nil?
100
+ end
101
+
102
+ private
103
+
104
+ def extract_image(file_path, media, warnings)
105
+ unless pillow_available?
106
+ warnings << 'Pillow (PIL) not available; image metadata skipped'
107
+ return media
108
+ end
109
+
110
+ python = %w[python3 python].find { |cmd| system(cmd, '-c', 'import PIL', out: File::NULL, err: File::NULL) }
111
+ script = <<~PYTHON
112
+ import json, sys
113
+ from PIL import Image
114
+
115
+ path = sys.argv[1]
116
+ img = Image.open(path)
117
+ info = {
118
+ 'format': (img.format or '').lower() or None,
119
+ 'width': img.width,
120
+ 'height': img.height,
121
+ 'color_mode': img.mode,
122
+ }
123
+ dpi = img.info.get('dpi')
124
+ info['dpi'] = round(dpi[0]) if dpi and isinstance(dpi[0], (int, float))
125
+ exif = {}
126
+ try:
127
+ from PIL import ExifTags
128
+ raw = img._getexif() if hasattr(img, '_getexif') else None
129
+ if raw:
130
+ for k, v in raw.items():
131
+ name = ExifTags.TAGS.get(k, str(k))
132
+ if v is None or isinstance(v, (int, float, str)):
133
+ exif[name] = v
134
+ except Exception:
135
+ pass
136
+ info['exif'] = exif
137
+ print(json.dumps(info))
138
+ PYTHON
139
+
140
+ output, status = Open3.capture2e(python, '-c', script, file_path)
141
+ unless status.success?
142
+ warnings << "image metadata extraction failed: #{output.strip}"
143
+ return media
144
+ end
145
+
146
+ parsed = JSON.parse(output)
147
+ media[:format] = parsed['format']
148
+ media[:width] = parsed['width']
149
+ media[:height] = parsed['height']
150
+ media[:color_mode] = parsed['color_mode']
151
+ media[:dpi] = parsed['dpi']
152
+ media[:exif] = parsed['exif'] || {}
153
+ media[:orientation] = derive_orientation(media[:width], media[:height])
154
+ media
155
+ rescue JSON::ParserError, SystemCallError => e
156
+ warnings << "image metadata extraction failed: #{e.message}"
157
+ media
158
+ end
159
+
160
+ def extract_audio(file_path, media, warnings)
161
+ probe = probe_ffprobe(file_path, warnings)
162
+ return media unless probe
163
+
164
+ format_info = probe['format'] || {}
165
+ stream = probe['streams'].to_a.find { |s| s['codec_type'] == 'audio' } || probe['streams'].to_a.first
166
+
167
+ media[:format] = File.extname(file_path.to_s).downcase.sub('.', '')
168
+ media[:codec] = stream && stream['codec_name']
169
+ media[:duration_ms] = ms_of(format_info['duration'])
170
+ media[:bitrate_bps] = int_of(format_info['bit_rate']) || (stream && int_of(stream['bit_rate']))
171
+ media[:sample_rate_hz] = stream && int_of(stream['sample_rate'])
172
+ media[:channels] = stream && int_of(stream['channels'])
173
+ media
174
+ end
175
+
176
+ def extract_video(file_path, media, warnings)
177
+ probe = probe_ffprobe(file_path, warnings)
178
+ return media unless probe
179
+
180
+ format_info = probe['format'] || {}
181
+ streams = probe['streams'].to_a
182
+ video_stream = streams.find { |s| s['codec_type'] == 'video' }
183
+ audio_stream = streams.find { |s| s['codec_type'] == 'audio' }
184
+
185
+ media[:format] = File.extname(file_path.to_s).downcase.sub('.', '')
186
+ media[:duration_ms] = ms_of(format_info['duration'])
187
+ media[:bitrate_bps] = int_of(format_info['bit_rate'])
188
+ if video_stream
189
+ media[:width] = int_of(video_stream['width'])
190
+ media[:height] = int_of(video_stream['height'])
191
+ media[:codec] = video_stream['codec_name']
192
+ media[:fps] = fps_of(video_stream)
193
+ end
194
+ media[:audio_codec] = audio_stream && audio_stream['codec_name']
195
+ media[:orientation] = derive_orientation(media[:width], media[:height])
196
+ media
197
+ end
198
+
199
+ def probe_ffprobe(file_path, warnings)
200
+ cmd = ffprobe_cmd
201
+ unless cmd
202
+ warnings << 'ffprobe not available; audio/video metadata skipped'
203
+ return nil
204
+ end
205
+
206
+ args = [cmd, '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', file_path]
207
+ output, status, = Open3.capture3(*args)
208
+ unless status.success?
209
+ warnings << "ffprobe failed: #{output.strip}"
210
+ return nil
211
+ end
212
+
213
+ JSON.parse(output)
214
+ rescue JSON::ParserError, SystemCallError, Errno::ENOENT => e
215
+ warnings << "ffprobe failed: #{e.message}"
216
+ nil
217
+ end
218
+
219
+ def ffprobe_cmd
220
+ @ffprobe_cmd ||= %w[ffprobe].find { |c| system('which', c, out: File::NULL, err: File::NULL) }
221
+ end
222
+
223
+ def ms_of(value)
224
+ return nil if value.nil?
225
+
226
+ (value.to_f * 1000).round
227
+ end
228
+
229
+ def int_of(value)
230
+ return nil if value.nil?
231
+
232
+ Integer(value.to_s, exception: false)
233
+ end
234
+
235
+ def fps_of(stream)
236
+ rate = stream['avg_frame_rate'].to_s
237
+ return nil if rate.empty?
238
+
239
+ num, den = rate.split('/')
240
+ return nil if num.nil?
241
+
242
+ den = '1' if den.to_s.empty? || den.to_i.zero?
243
+ (num.to_f / den.to_f).round(2)
244
+ end
245
+
246
+ def derive_orientation(width, height)
247
+ return nil unless width && height
248
+ return 'square' if width == height
249
+
250
+ width > height ? 'landscape' : 'portrait'
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -4,7 +4,12 @@ module SmartBrain
4
4
  module Adapters
5
5
  module SmartRag
6
6
  class NullClient
7
+ WRITE_OPERATIONS = %i[document media image audio video].freeze
8
+
7
9
  def retrieve(plan)
10
+ business_scoped = Array(plan.dig(:scope_context, :read)).any? do |ref|
11
+ (ref[:type] || ref['type']).to_s != 'session'
12
+ end
8
13
  {
9
14
  version: '0.1',
10
15
  plan_id: "local-#{plan[:request_id]}",
@@ -12,10 +17,47 @@ module SmartBrain
12
17
  generated_at: Time.now.utc.iso8601,
13
18
  evidences: [],
14
19
  stats: { candidates: 0, returned: 0, took_ms: 0 },
15
- explain: { ignored_fields: [] },
16
- warnings: ['smart_rag client not configured; returned empty evidences']
20
+ explain: { ignored_fields: business_scoped ? ['scope_context.read'] : [] },
21
+ warnings: [business_scoped ? 'smart_rag scope filter unavailable: client not configured; failed closed' :
22
+ 'smart_rag client not configured; returned empty evidences'],
23
+ scope_filter_applied: !business_scoped
17
24
  }
18
25
  end
26
+
27
+ WRITE_OPERATIONS.each do |type|
28
+ define_method("add_#{type}") do |_source, _options = {}|
29
+ { status: 'unsupported', media_type: type.to_s, section_count: 0,
30
+ warnings: ["smart_rag client not configured; add_#{type} ignored"] }
31
+ end
32
+ end
33
+
34
+ def enqueue_media(_source, _options = {})
35
+ { status: 'unsupported', warnings: ['smart_rag client not configured; enqueue_media ignored'] }
36
+ end
37
+
38
+ def media_job(job_id)
39
+ { status: 'unsupported', job_id: job_id, warnings: ['smart_rag client not configured; media_job ignored'] }
40
+ end
41
+
42
+ def media_jobs(status: nil, limit: 20, offset: 0)
43
+ { status: 'unsupported', jobs: [], total: 0,
44
+ warnings: ['smart_rag client not configured; media_jobs ignored'] }
45
+ end
46
+
47
+ def cancel_media_job(job_id)
48
+ { status: 'unsupported', job_id: job_id,
49
+ warnings: ['smart_rag client not configured; cancel_media_job ignored'] }
50
+ end
51
+
52
+ def retry_media_job(job_id)
53
+ { status: 'unsupported', job_id: job_id,
54
+ warnings: ['smart_rag client not configured; retry_media_job ignored'] }
55
+ end
56
+
57
+ def media_job_statistics
58
+ { status: 'unsupported', counts: {}, total: 0,
59
+ warnings: ['smart_rag client not configured; media_job_statistics ignored'] }
60
+ end
19
61
  end
20
62
  end
21
63
  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,65 @@ 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
+ # The `database` key maps to SMARTBRAIN_DB_NAME to match the documented
89
+ # env contract (SMARTBRAIN_DB_{HOST,PORT,NAME,USER,PASSWORD}).
90
+ suffix = key == :database ? 'NAME' : key.to_s.upcase
91
+ env_key = "SMARTBRAIN_DB_#{suffix}"
92
+ cfg[key] = ENV[env_key] if ENV.key?(env_key)
93
+ end
94
+ cfg
95
+ end
96
+
97
+ def fts_config
98
+ storage.fetch(:fts_config, nil) || database_config.fetch(:fts_config, 'simple')
99
+ end
40
100
  end
41
101
  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