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
@@ -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)
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'time'
5
+
6
+ module SmartBrain
7
+ module EventStore
8
+ # Postgres-backed EventStore. Drop-in replacement for EventStore::InMemory:
9
+ # writes the truth layer (sessions/turns/messages/refs) and reproduces the
10
+ # same return shapes so the runtime and diagnostics are backend-agnostic.
11
+ class Postgres
12
+ def initialize(db:, clock:)
13
+ @db = db
14
+ @clock = clock
15
+ end
16
+
17
+ def append_turn(session_id:, turn_events:, created_at:, domain_id: 'legacy')
18
+ db.transaction do
19
+ ensure_session(session_id, domain_id)
20
+ seq = next_seq(session_id)
21
+ turn_id = SecureRandom.uuid
22
+ messages = normalize_messages(turn_id: turn_id, messages: turn_events[:messages] || [], created_at: created_at)
23
+ refs = normalize_refs(turn_id: turn_id, refs: turn_events[:refs] || [], created_at: created_at)
24
+ normalized_events = turn_events.merge(messages: messages, refs: refs)
25
+
26
+ db[:turns].insert(
27
+ id: turn_id,
28
+ session_id: session_id,
29
+ seq: seq,
30
+ created_at: time_from(created_at),
31
+ events_json: Sequel.pg_jsonb(symbolizable(normalized_events))
32
+ )
33
+ insert_messages(turn_id, messages)
34
+ insert_refs(turn_id, refs)
35
+
36
+ {
37
+ id: turn_id,
38
+ session_id: session_id,
39
+ seq: seq,
40
+ created_at: iso8601(created_at),
41
+ turn_events: normalized_events
42
+ }
43
+ end
44
+ end
45
+
46
+ def turns_count(session_id:)
47
+ db[:turns].where(session_id: session_id).count
48
+ end
49
+
50
+ def recent_turns(session_id:, limit:)
51
+ last_turn_ids = db[:turns].where(session_id: session_id).order(Sequel.desc(:seq)).limit(limit).select(:id)
52
+ db[:messages].where(turn_id: last_turn_ids).order(:created_at).map do |m|
53
+ {
54
+ turn_id: m[:turn_id],
55
+ message_id: m[:id],
56
+ role: m[:role],
57
+ content: m[:content],
58
+ created_at: iso8601(m[:created_at])
59
+ }
60
+ end
61
+ end
62
+
63
+ def recent_refs(session_id:, limit:)
64
+ last_turn_ids = db[:turns].where(session_id: session_id).order(Sequel.desc(:seq)).limit(limit).select(:id)
65
+ db[:refs].where(turn_id: last_turn_ids).order(:created_at).map do |r|
66
+ {
67
+ id: r[:id],
68
+ turn_id: r[:turn_id],
69
+ ref_type: r[:ref_type],
70
+ ref_uri: r[:ref_uri],
71
+ ref_meta_json: symbolize(r[:ref_meta_json]),
72
+ created_at: iso8601(r[:created_at])
73
+ }
74
+ end
75
+ end
76
+
77
+ def entity_frequencies(session_id:, window_turns:)
78
+ payloads = db[:turns].where(session_id: session_id)
79
+ .order(Sequel.desc(:seq))
80
+ .limit(window_turns)
81
+ .select(:events_json)
82
+ .all
83
+ freq = Hash.new(0)
84
+ payloads.each do |row|
85
+ events = symbolize(row[:events_json])
86
+ Array(events[:entities]).each do |entity|
87
+ canonical = entity[:canonical] || entity[:name]
88
+ freq[canonical.to_s.downcase] += 1
89
+ end
90
+ end
91
+ freq
92
+ end
93
+
94
+ def all_turns(session_id:)
95
+ dataset = session_id.nil? ? db[:turns] : db[:turns].where(session_id: session_id)
96
+ dataset.order(:session_id, :seq).map do |t|
97
+ {
98
+ id: t[:id],
99
+ session_id: t[:session_id],
100
+ seq: t[:seq],
101
+ created_at: iso8601(t[:created_at]),
102
+ turn_events: symbolize(t[:events_json])
103
+ }
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ attr_reader :db, :clock
110
+
111
+ def ensure_session(session_id, domain_id)
112
+ row = db[:sessions].where(id: session_id).first
113
+ if row && row[:domain_id] != domain_id
114
+ raise ArgumentError, "session #{session_id} belongs to domain #{row[:domain_id]}"
115
+ end
116
+ return if row
117
+
118
+ db[:sessions].insert(id: session_id, domain_id: domain_id, created_at: time_from(clock.call), metadata_json: Sequel.pg_jsonb({}))
119
+ end
120
+
121
+ def next_seq(session_id)
122
+ (db[:turns].where(session_id: session_id).max(:seq) || 0) + 1
123
+ end
124
+
125
+ def insert_messages(turn_id, messages)
126
+ messages.each do |m|
127
+ db[:messages].insert(
128
+ id: m[:id],
129
+ turn_id: turn_id,
130
+ role: m[:role],
131
+ content: m[:content].to_s,
132
+ model: m[:model],
133
+ created_at: time_from(m[:created_at]),
134
+ meta_json: Sequel.pg_jsonb({})
135
+ )
136
+ end
137
+ end
138
+
139
+ def insert_refs(turn_id, refs)
140
+ refs.each do |r|
141
+ db[:refs].insert(
142
+ id: r[:id],
143
+ turn_id: turn_id,
144
+ ref_type: r[:ref_type].to_s,
145
+ ref_uri: r[:ref_uri].to_s,
146
+ ref_meta_json: Sequel.pg_jsonb(symbolizable(r[:ref_meta_json] || {})),
147
+ created_at: time_from(r[:created_at])
148
+ )
149
+ end
150
+ end
151
+
152
+ def normalize_messages(turn_id:, messages:, created_at:)
153
+ messages.map do |m|
154
+ m.merge(
155
+ id: m[:id] || SecureRandom.uuid,
156
+ turn_id: turn_id,
157
+ created_at: m[:created_at] || iso8601(created_at)
158
+ )
159
+ end
160
+ end
161
+
162
+ def normalize_refs(turn_id:, refs:, created_at:)
163
+ refs.map do |r|
164
+ r.merge(
165
+ id: r[:id] || SecureRandom.uuid,
166
+ turn_id: turn_id,
167
+ created_at: r[:created_at] || iso8601(created_at),
168
+ ref_meta_json: r[:ref_meta_json] || {}
169
+ )
170
+ end
171
+ end
172
+
173
+ def time_from(value)
174
+ case value
175
+ when Time then value
176
+ when String then Time.parse(value)
177
+ else clock.call
178
+ end
179
+ end
180
+
181
+ def iso8601(value)
182
+ time = case value
183
+ when Time then value
184
+ when String then Time.parse(value)
185
+ else clock.call
186
+ end
187
+ time.iso8601
188
+ end
189
+
190
+ def symbolizable(obj)
191
+ SmartBrain::DB.stringify_keys(obj)
192
+ end
193
+
194
+ def symbolize(obj)
195
+ SmartBrain::DB.symbolize_jsonb(obj)
196
+ end
197
+ end
198
+ end
199
+ end
@@ -3,14 +3,19 @@
3
3
  module SmartBrain
4
4
  module Fusion
5
5
  class Merger
6
- def initialize(config:)
6
+ RERANK_THRESHOLD = 4
7
+ RERANK_MAX = 20
8
+
9
+ def initialize(config:, model_provider: nil)
7
10
  @config = config
11
+ @model_provider = model_provider
8
12
  end
9
13
 
10
14
  def merge(query:, memory_evidence:, resource_evidence:)
11
15
  combined = (memory_evidence + normalize_resource(resource_evidence))
12
16
  deduped = dedupe(combined)
13
17
  ranked = deduped.sort_by { |item| -rerank_score(item, query) }
18
+ ranked = llm_rerank(query, ranked) if llm_rerank_enabled? && ranked.size >= RERANK_THRESHOLD
14
19
  diversified, dropped = apply_diversity(ranked)
15
20
  selected = apply_budget(diversified)
16
21
 
@@ -23,7 +28,31 @@ module SmartBrain
23
28
 
24
29
  private
25
30
 
26
- attr_reader :config
31
+ attr_reader :config, :model_provider
32
+
33
+ def llm_rerank_enabled?
34
+ return false unless model_provider&.llm?
35
+ return false unless config.llm.fetch(:rerank_enabled, false)
36
+
37
+ true
38
+ end
39
+
40
+ # Re-rank the candidate head with the LLM; preserve lexical order for the
41
+ # tail and on any error (graceful fallback so compose never breaks).
42
+ def llm_rerank(query, items)
43
+ head = items.first(RERANK_MAX)
44
+ docs = head.map { |i| "#{i[:title]}: #{i[:snippet]}" }
45
+ scored = model_provider.rerank(query: query, documents: docs)
46
+ scores_by_index = scored.each_with_object({}) { |s, h| h[s[:index]] = s[:score].to_f }
47
+
48
+ reordered = head.each_with_index.map do |item, idx|
49
+ item.merge(_llm: scores_by_index.fetch(idx, 0.0))
50
+ end.sort_by { |i| -i[:_llm] }
51
+ reordered.each { |i| i.delete(:_llm) }
52
+ reordered + items[RERANK_MAX..]
53
+ rescue StandardError
54
+ items
55
+ end
27
56
 
28
57
  def normalize_resource(items)
29
58
  items.map do |item|
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tiers'
4
+
5
+ module SmartBrain
6
+ module Governance
7
+ # Deterministic, citation-first briefing (mempal parity). No LLM, no DB
8
+ # writes — assembles purely from memory_store/event_store. Two modes:
9
+ # - brief: full cognitive snapshot (summary/key_facts/evidence/entities/
10
+ # unresolved/uncertainty/next_actions), every fact carries a citation.
11
+ # - wake_up: minimal L0/L1 importance-ordered resume payload.
12
+ class Briefing
13
+ KEY_FACT_TYPES = %w[decisions goals patterns].freeze
14
+
15
+ def initialize(memory_store:, event_store:, config:)
16
+ @memory_store = memory_store
17
+ @event_store = event_store
18
+ @config = config
19
+ end
20
+
21
+ def brief(session_id:, query: nil, scope_ids: nil, domain_id: 'legacy', scope_context: nil)
22
+ items = memory_store.active_items(session_id: session_id, scope_ids: scope_ids)
23
+ recent = event_store.recent_turns(session_id: session_id, limit: briefing_recent_turns)
24
+ search_query = query || last_user_message(recent) || ''
25
+
26
+ {
27
+ session_id: session_id,
28
+ domain_id: domain_id,
29
+ scope_context: scope_context,
30
+ summary: summary_text(session_id),
31
+ key_facts: key_facts(items),
32
+ evidence: evidence(session_id: session_id, scope_ids: scope_ids, query: search_query),
33
+ entities: memory_store.entities(session_id: session_id, scope_ids: scope_ids),
34
+ unresolved: unresolved(items),
35
+ uncertainty: uncertainty(items),
36
+ next_actions: next_actions(items),
37
+ generated_at: Time.now.utc.iso8601
38
+ }
39
+ end
40
+
41
+ def wake_up(session_id:, scope_ids: nil, domain_id: 'legacy', scope_context: nil)
42
+ items = memory_store.active_items(session_id: session_id, scope_ids: scope_ids)
43
+ summary = memory_store.latest_summary(session_id: session_id)
44
+
45
+ {
46
+ session_id: session_id,
47
+ domain_id: domain_id,
48
+ scope_context: scope_context,
49
+ l0: {
50
+ identity: items.select { |i| i[:type] == 'profile' },
51
+ principles: items.select { |i| i[:tier] == 'dao_tian' },
52
+ preferences: items.select { |i| i[:type] == 'preferences' }
53
+ },
54
+ l1: {
55
+ goals_decisions: items.select { |i| %w[goals decisions].include?(i[:type]) },
56
+ summary: summary ? summary[:text] : nil
57
+ },
58
+ generated_at: Time.now.utc.iso8601
59
+ }
60
+ end
61
+
62
+ def resume(query:)
63
+ matches = event_store.all_turns(session_id: nil)
64
+ .map { |t| t[:session_id] }.uniq
65
+ .select { |sid| sid.to_s.downcase.include?(query.to_s.downcase) }
66
+ matches.map do |sid|
67
+ items = memory_store.active_items(session_id: sid)
68
+ {
69
+ session_id: sid,
70
+ recent_decisions: items.select { |i| i[:type] == 'decisions' }.first(5).map { |i| fact_citation(i) },
71
+ next_actions: next_actions(items)
72
+ }
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ attr_reader :memory_store, :event_store, :config
79
+
80
+ def summary_text(session_id)
81
+ summary = memory_store.latest_summary(session_id: session_id)
82
+ summary ? summary[:text] : nil
83
+ end
84
+
85
+ def key_facts(items)
86
+ selected = items.select { |i| KEY_FACT_TYPES.include?(i[:type]) }
87
+ Tiers.sort_by_tier(selected).first(briefing_key_facts_max).map { |i| fact_citation(i) }
88
+ end
89
+
90
+ def fact_citation(item)
91
+ {
92
+ memory_item_id: item[:id],
93
+ type: item[:type],
94
+ key: item[:key],
95
+ tier: item[:tier] || Tiers.tier_for(item[:type]),
96
+ confidence: item[:confidence],
97
+ source_turn_id: item[:source_turn_id],
98
+ status: item[:status],
99
+ lifecycle_status: item[:lifecycle_status]
100
+ }
101
+ end
102
+
103
+ def evidence(session_id:, scope_ids:, query:)
104
+ return [] if query.to_s.strip.empty?
105
+
106
+ memory_store.search_memory(query: query, session_id: session_id, scope_ids: scope_ids, limit: briefing_key_facts_max)
107
+ rescue StandardError
108
+ []
109
+ end
110
+
111
+ def unresolved(items)
112
+ items.select { |i| i[:type] == 'tasks' && %w[todo doing blocked].include?(task_status(i)) }
113
+ .map { |i| fact_citation(i) }
114
+ end
115
+
116
+ def uncertainty(items)
117
+ items.select { |i| (i[:confidence] || 1.0) < uncertainty_threshold }.map { |i| fact_citation(i) }
118
+ end
119
+
120
+ def next_actions(items)
121
+ items.select { |i| i[:type] == 'tasks' && %w[todo doing].include?(task_status(i)) }
122
+ .map { |i| fact_citation(i) }
123
+ end
124
+
125
+ def task_status(item)
126
+ item.dig(:value_json, :status).to_s
127
+ end
128
+
129
+ def last_user_message(recent)
130
+ recent.reverse.find { |t| t[:role] == 'user' } && recent.reverse.find { |t| t[:role] == 'user' }[:content]
131
+ end
132
+
133
+ def briefing_key_facts_max
134
+ config.briefing.fetch(:key_facts_max, 8) rescue 8
135
+ end
136
+
137
+ def uncertainty_threshold
138
+ config.briefing.fetch(:uncertainty_threshold, 0.6) rescue 0.6
139
+ end
140
+
141
+ def briefing_recent_turns
142
+ config.briefing.fetch(:recent_turns, 6) rescue 6
143
+ end
144
+ end
145
+ end
146
+ end