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
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'tiers'
5
+
6
+ module SmartBrain
7
+ module Governance
8
+ # Stage-1 knowledge lifecycle (mempal parity):
9
+ #
10
+ # evidence → distill(candidate) → gate → promote(promoted) → demote/retire
11
+ #
12
+ # Operates over a MemoryStore (independent of backend). distill creates a
13
+ # candidate knowledge item at tier dao_ren|qi; gate is a read-only readiness
14
+ # check; promote/demote are evidence-backed transitions that append to the
15
+ # knowledge_events audit trail. Orthogonal to the conflict `status`.
16
+ class Lifecycle
17
+ DEMOTE_REASONS = %w[contradicted obsolete superseded].freeze
18
+
19
+ def initialize(memory_store:, config:, model_provider: nil)
20
+ @memory_store = memory_store
21
+ @config = config
22
+ @model_provider = model_provider
23
+ end
24
+
25
+ def distill(session_id:, statement:, content: nil, tier:, supporting_refs: [], domain: nil, field: nil,
26
+ reviewer: nil, key: nil, scope_id: nil, scope: nil)
27
+ tier = tier.to_s
28
+ raise ArgumentError, "tier must be one of #{Tiers::DISTILLABLE.join('/')}" unless Tiers.distillable?(tier)
29
+
30
+ statement = draft_statement(statement, supporting_refs: supporting_refs, content: content) if statement.nil? || statement.strip.empty?
31
+ raise ArgumentError, 'statement is required (provide one or wire an LLM provider)' if statement.nil? || statement.strip.empty?
32
+
33
+ item = {
34
+ type: 'knowledge',
35
+ key: key || "knowledge:#{tier}:#{slugify(statement)}",
36
+ value_json: {
37
+ statement: statement,
38
+ content: content,
39
+ tier: tier,
40
+ domain: domain,
41
+ field: field,
42
+ supporting_refs: Array(supporting_refs)
43
+ },
44
+ confidence: 0.5,
45
+ status: 'active',
46
+ tier: tier,
47
+ lifecycle_status: 'candidate',
48
+ source_turn_id: nil,
49
+ updated_at: Time.now.utc.iso8601
50
+ }
51
+ record = memory_store.create_item(session_id: session_id, item: item, scope_id: scope_id, scope: scope)
52
+ memory_store.record_event(
53
+ memory_item_id: record[:id], event_type: 'distill',
54
+ from_lifecycle: nil, to_lifecycle: 'candidate',
55
+ reason: 'distilled from evidence', reviewer: reviewer,
56
+ evidence_refs: Array(supporting_refs)
57
+ )
58
+ { memory_item: record, event: last_event(record[:id]) }
59
+ end
60
+
61
+ # Read-only promotion readiness check.
62
+ def gate(memory_item_id:)
63
+ item = memory_store.find_item(id: memory_item_id)
64
+ raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
65
+
66
+ refs = Array(item.dig(:value_json, :supporting_refs) || item.dig(:value_json, 'supporting_refs'))
67
+ min = min_supporting_refs
68
+ reviewer = item.dig(:value_json, :reviewer) || item.dig(:value_json, 'reviewer')
69
+ checks = {
70
+ lifecycle: item[:lifecycle_status],
71
+ supporting_refs: refs.size,
72
+ min_required: min,
73
+ reviewer: reviewer
74
+ }
75
+ ready = item[:lifecycle_status] == 'candidate' && (refs.size >= min || reviewer == 'human')
76
+ { ready: ready, target_status: 'promoted', checks: checks }
77
+ end
78
+
79
+ def promote(memory_item_id:, verification_refs:, reason:, reviewer: nil, force: false)
80
+ item = memory_store.find_item(id: memory_item_id)
81
+ raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
82
+
83
+ decision = gate(memory_item_id: memory_item_id)
84
+ if !decision[:ready] && !force
85
+ raise LifecycleGateError, "promotion gate failed: #{decision[:checks].inspect}"
86
+ end
87
+
88
+ from = item[:lifecycle_status]
89
+ updated = memory_store.set_lifecycle(
90
+ id: memory_item_id, lifecycle_status: 'promoted',
91
+ merge_value: { verification_refs: Array(verification_refs), reviewer: reviewer }
92
+ )
93
+ memory_store.record_event(
94
+ memory_item_id: memory_item_id, event_type: 'promote',
95
+ from_lifecycle: from, to_lifecycle: 'promoted',
96
+ reason: reason, reviewer: reviewer, evidence_refs: Array(verification_refs)
97
+ )
98
+ { memory_item: updated, event: last_event(memory_item_id) }
99
+ end
100
+
101
+ def demote(memory_item_id:, evidence_refs:, reason:, reason_type:, reviewer: nil)
102
+ raise ArgumentError, "reason_type must be one of #{DEMOTE_REASONS.join('/')}" unless DEMOTE_REASONS.include?(reason_type.to_s)
103
+
104
+ item = memory_store.find_item(id: memory_item_id)
105
+ raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
106
+
107
+ from = item[:lifecycle_status]
108
+ updated = memory_store.set_lifecycle(
109
+ id: memory_item_id, lifecycle_status: 'demoted',
110
+ merge_value: { demotion_reason: reason, reason_type: reason_type }
111
+ )
112
+ memory_store.record_event(
113
+ memory_item_id: memory_item_id, event_type: 'demote',
114
+ from_lifecycle: from, to_lifecycle: 'demoted',
115
+ reason: reason, reason_type: reason_type, reviewer: reviewer, evidence_refs: Array(evidence_refs)
116
+ )
117
+ { memory_item: updated, event: last_event(memory_item_id) }
118
+ end
119
+
120
+ def promote_to_scope(source_item:, target_scope:, session_id:, verification_refs:, reason:, reviewer: nil)
121
+ refs = Array(verification_refs)
122
+ unless reviewer == 'human' || refs.size >= min_supporting_refs
123
+ raise LifecycleGateError, "scope promotion requires human review or #{min_supporting_refs} verification refs"
124
+ end
125
+
126
+ provenance = {
127
+ source_memory_item_id: source_item[:id],
128
+ source_scope: source_item[:scope],
129
+ target_scope: { type: target_scope[:type], id: target_scope[:id] },
130
+ source_session_id: source_item[:source_session_id] || source_item[:session_id],
131
+ verification_refs: refs,
132
+ reviewer: reviewer,
133
+ reason: reason
134
+ }
135
+ item = source_item.slice(:type, :key, :confidence, :tier).merge(
136
+ value_json: (source_item[:value_json] || {}).merge(provenance: provenance),
137
+ status: 'active', lifecycle_status: 'promoted', source_turn_id: source_item[:source_turn_id],
138
+ source_message_id: source_item[:source_message_id],
139
+ source_session_id: source_item[:source_session_id] || source_item[:session_id],
140
+ updated_at: Time.now.utc.iso8601
141
+ )
142
+ target = memory_store.create_item(
143
+ session_id: session_id, item: item, scope_id: target_scope[:scope_id],
144
+ scope: { type: target_scope[:type], id: target_scope[:id] }
145
+ )
146
+ event = memory_store.record_event(
147
+ memory_item_id: target[:id], event_type: 'promote_to_scope',
148
+ from_lifecycle: source_item[:lifecycle_status], to_lifecycle: 'promoted',
149
+ reason: reason, reviewer: reviewer, evidence_refs: refs
150
+ )
151
+ target = memory_store.set_lifecycle(
152
+ id: target[:id], lifecycle_status: 'promoted',
153
+ merge_value: { provenance: provenance.merge(promotion_event_id: event[:id]) }
154
+ )
155
+ { source_memory_item: source_item, memory_item: target, event: event }
156
+ end
157
+
158
+ def retract(memory_item_id:, evidence_refs:, reason:, reviewer: nil)
159
+ item = memory_store.find_item(id: memory_item_id)
160
+ raise ArgumentError, "memory item not found: #{memory_item_id}" unless item
161
+
162
+ updated = memory_store.set_status(id: memory_item_id, status: 'retracted', merge_value: { retraction_reason: reason })
163
+ event = memory_store.record_event(
164
+ memory_item_id: memory_item_id, event_type: 'retract',
165
+ from_lifecycle: item[:lifecycle_status], to_lifecycle: item[:lifecycle_status],
166
+ reason: reason, reviewer: reviewer, evidence_refs: Array(evidence_refs)
167
+ )
168
+ { memory_item: updated, event: event }
169
+ end
170
+
171
+ def lineage(memory_item_id:)
172
+ lineage = []
173
+ current = memory_store.find_item(id: memory_item_id)
174
+ while current
175
+ lineage << current
176
+ source_id = current.dig(:value_json, :provenance, :source_memory_item_id)
177
+ break unless source_id
178
+
179
+ current = memory_store.find_item(id: source_id)
180
+ end
181
+ lineage
182
+ end
183
+
184
+ def events(memory_item_id:)
185
+ memory_store.events_for(memory_item_id: memory_item_id)
186
+ end
187
+
188
+ private
189
+
190
+ attr_reader :memory_store, :config, :model_provider
191
+
192
+ def min_supporting_refs
193
+ config.lifecycle.fetch(:min_supporting_refs, 2) rescue 2
194
+ end
195
+
196
+ def last_event(memory_item_id)
197
+ memory_store.events_for(memory_item_id: memory_item_id).last
198
+ end
199
+
200
+ def draft_statement(statement, supporting_refs:, content:)
201
+ return statement unless model_provider&.llm?
202
+ return nil if Array(supporting_refs).empty?
203
+
204
+ refs_text = supporting_refs_for(supporting_refs).map { |i| "- #{i[:key]}: #{i.dig(:value_json, :statement) || i[:value_json]}" }.join("\n")
205
+ result = model_provider.complete(
206
+ prompt: "Distill ONE concise knowledge statement (<= 1 line) from these observations; reply with only the statement.\n#{refs_text}",
207
+ temperature: 0.2, max_tokens: 120
208
+ )
209
+ result[:error] ? nil : result[:text]
210
+ rescue StandardError
211
+ nil
212
+ end
213
+
214
+ def supporting_refs_for(refs)
215
+ Array(refs).filter_map { |id| memory_store.find_item(id: id) }
216
+ end
217
+
218
+ def slugify(text)
219
+ text.to_s.downcase.scan(/[[:alnum:]]+/).first(6).join('-')[0, 60]
220
+ end
221
+ end
222
+
223
+ class LifecycleGateError < StandardError; end
224
+ end
225
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartBrain
4
+ module Governance
5
+ # Mind-model tiers (mempal parity): dao_tian 天道 / dao_ren 人道 / shu 术 /
6
+ # qi 器 / evidence 证据. Lower ORDER index = higher assembly priority.
7
+ module Tiers
8
+ ORDER = %w[dao_tian dao_ren shu qi evidence].freeze
9
+ PRIORITY = ORDER.each_with_index.to_h.freeze
10
+
11
+ # Default tier per memory type. dao_tian is never auto-assigned — it is
12
+ # reserved for user-pinned immutable principles.
13
+ DEFAULT_TIER_BY_TYPE = {
14
+ 'profile' => 'dao_ren',
15
+ 'decisions' => 'dao_ren',
16
+ 'patterns' => 'dao_ren',
17
+ 'goals' => 'shu',
18
+ 'tasks' => 'shu',
19
+ 'preferences' => 'qi',
20
+ 'cases' => 'qi',
21
+ 'entities' => 'evidence',
22
+ 'events' => 'evidence'
23
+ }.freeze
24
+
25
+ # Only dao_ren / qi knowledge can enter the candidate stage via distill
26
+ # (mempal rule: dao_tian and shu are not open to candidate state).
27
+ DISTILLABLE = %w[dao_ren qi].freeze
28
+
29
+ # lifecycle statuses that participate in automatic context assembly.
30
+ CONTEXT_LIFECYCLE = %w[raw promoted].freeze
31
+
32
+ module_function
33
+
34
+ def tier_for(type)
35
+ DEFAULT_TIER_BY_TYPE.fetch(type.to_s, 'evidence')
36
+ end
37
+
38
+ def priority(tier)
39
+ PRIORITY.fetch(tier.to_s, PRIORITY.fetch('evidence'))
40
+ end
41
+
42
+ def distillable?(tier)
43
+ DISTILLABLE.include?(tier.to_s)
44
+ end
45
+
46
+ def valid?(tier)
47
+ PRIORITY.key?(tier.to_s)
48
+ end
49
+
50
+ def dao_tian_limit(config)
51
+ (config.tiers.fetch(:dao_tian_limit, 1) rescue 1)
52
+ end
53
+
54
+ # Sort by tier priority asc (dao_tian first); stable on equal priority.
55
+ def sort_by_tier(items)
56
+ items.sort_by.with_index { |i, idx| [priority(i[:tier] || i['tier'] || 'evidence'), idx] }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../governance/tiers'
4
+
3
5
  module SmartBrain
4
6
  module MemoryExtractor
5
7
  class Extractor
@@ -16,11 +18,14 @@ module SmartBrain
16
18
  collect(items, explain, events[:decisions], type: 'decisions', confidence: confidence(:user_asserted), source_turn_id: turn[:id])
17
19
  collect(items, explain, events[:goals], type: 'goals', confidence: confidence(:user_asserted), source_turn_id: turn[:id])
18
20
  collect(items, explain, events[:events], type: 'events', confidence: confidence(:tool_derived), source_turn_id: turn[:id])
21
+ collect(items, explain, events[:profile], type: 'profile', confidence: confidence(:user_asserted), source_turn_id: turn[:id])
22
+ collect(items, explain, events[:cases], type: 'cases', confidence: confidence(:tool_derived), source_turn_id: turn[:id])
23
+ collect(items, explain, events[:patterns], type: 'patterns', confidence: confidence(:inferred), source_turn_id: turn[:id])
19
24
 
20
25
  Array(events[:preferences]).each do |preference|
21
26
  key = preference.fetch(:key)
22
27
  if preference[:confirmed]
23
- items << build_item(type: 'preferences', key: key, value_json: preference, source_turn_id: turn[:id], confidence: confidence(:user_asserted))
28
+ items << build_item(type: 'preferences', key: key, value_json: preference, source_turn_id: turn[:id], confidence: confidence(:user_asserted), scope_ref: preference[:scope_ref])
24
29
  explain << "write preferences:#{key}"
25
30
  else
26
31
  explain << "skip preferences:#{key} not confirmed"
@@ -32,7 +37,7 @@ module SmartBrain
32
37
  canonical = (entity[:canonical] || entity[:name]).to_s.downcase
33
38
  should_write = entity[:remember] || entity_structure_signal?(entity) || entity_frequencies[canonical] >= freq_threshold
34
39
  if should_write
35
- items << build_item(type: 'entities', key: key, value_json: entity, source_turn_id: turn[:id], confidence: confidence(:inferred))
40
+ items << build_item(type: 'entities', key: key, value_json: entity, source_turn_id: turn[:id], confidence: confidence(:inferred), scope_ref: entity[:scope_ref])
36
41
  explain << "write entities:#{key}"
37
42
  else
38
43
  explain << "skip entities:#{key} below threshold"
@@ -40,14 +45,24 @@ module SmartBrain
40
45
  end
41
46
 
42
47
  Array(events[:retractions]).each do |retraction|
43
- items << build_item(type: retraction.fetch(:type), key: retraction.fetch(:key), value_json: retraction, source_turn_id: turn[:id], confidence: confidence(:user_asserted), status: 'retracted')
48
+ items << build_item(type: retraction.fetch(:type), key: retraction.fetch(:key), value_json: retraction, source_turn_id: turn[:id], confidence: confidence(:user_asserted), status: 'retracted', scope_ref: retraction[:scope_ref])
44
49
  explain << "retract #{retraction.fetch(:type)}:#{retraction.fetch(:key)}"
45
50
  end
46
51
 
52
+ # KG edges: pass-through (subject/predicate/object). Orthogonal to
53
+ # memory_items — runtime persists them to kg_edges via KnowledgeGraph.
54
+ edges = Array(events[:edges]).map do |edge|
55
+ next unless edge[:subject] && edge[:predicate] && edge[:object]
56
+
57
+ { subject: edge[:subject], predicate: edge[:predicate],
58
+ object: edge[:object], confidence: edge[:confidence], scope_ref: edge[:scope_ref] }
59
+ end.compact
60
+
47
61
  {
48
62
  session_id: session_id,
49
63
  items: items,
50
- explain: explain
64
+ explain: explain,
65
+ edges: edges
51
66
  }
52
67
  end
53
68
 
@@ -58,12 +73,12 @@ module SmartBrain
58
73
  def collect(items, explain, raw_items, type:, confidence:, source_turn_id:)
59
74
  Array(raw_items).each do |entry|
60
75
  key = entry.fetch(:key)
61
- items << build_item(type: type, key: key, value_json: entry, source_turn_id: source_turn_id, confidence: confidence)
76
+ items << build_item(type: type, key: key, value_json: entry, source_turn_id: source_turn_id, confidence: confidence, scope_ref: entry[:scope_ref])
62
77
  explain << "write #{type}:#{key}"
63
78
  end
64
79
  end
65
80
 
66
- def build_item(type:, key:, value_json:, source_turn_id:, confidence:, status: 'active')
81
+ def build_item(type:, key:, value_json:, source_turn_id:, confidence:, status: 'active', scope_ref: nil)
67
82
  {
68
83
  type: type,
69
84
  key: key,
@@ -71,7 +86,10 @@ module SmartBrain
71
86
  source_turn_id: source_turn_id,
72
87
  confidence: confidence,
73
88
  status: status,
74
- updated_at: Time.now.utc.iso8601
89
+ tier: Governance::Tiers.tier_for(type),
90
+ lifecycle_status: 'raw',
91
+ updated_at: Time.now.utc.iso8601,
92
+ scope_ref: scope_ref
75
93
  }
76
94
  end
77
95
 
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require_relative '../governance/tiers'
5
+ require_relative '../retrievers/exact_retriever'
4
6
 
5
7
  module SmartBrain
6
8
  module MemoryStore
@@ -8,8 +10,12 @@ module SmartBrain
8
10
  OVERWRITE_TYPES = %w[preferences goals tasks].freeze
9
11
 
10
12
  def initialize
11
- @by_session = Hash.new { |h, k| h[k] = [] }
13
+ @by_scope = Hash.new { |h, k| h[k] = [] }
12
14
  @entities_index = Hash.new { |h, k| h[k] = [] }
15
+ @summaries = {}
16
+ @events = []
17
+ @edges = []
18
+ @exact = Retrievers::ExactRetriever.new
13
19
  end
14
20
 
15
21
  def upsert(extracted)
@@ -19,7 +25,9 @@ module SmartBrain
19
25
  conflicts = []
20
26
 
21
27
  items.each do |item|
22
- existing = active_item(session_id: session_id, type: item[:type], key: item[:key])
28
+ scope_id = item[:scope_id] || legacy_scope_id(session_id)
29
+ scope = item[:scope] || { type: 'session', id: session_id }
30
+ existing = active_item(scope_id: scope_id, type: item[:type], key: item[:key])
23
31
 
24
32
  if existing && item[:status] == 'retracted'
25
33
  existing[:status] = 'retracted'
@@ -32,47 +40,224 @@ module SmartBrain
32
40
  conflicts << { type: 'overwrite', key: item[:key], previous_memory_item_id: existing[:id] }
33
41
  end
34
42
 
35
- record = item.merge(id: SecureRandom.uuid, status: item[:status] || 'active')
36
- by_session[session_id] << record
37
- update_entities(session_id: session_id, record: record)
38
- written << record.slice(:id, :type, :key, :status, :confidence)
43
+ record = item.merge(
44
+ id: SecureRandom.uuid, status: item[:status] || 'active', session_id: session_id,
45
+ source_session_id: item[:source_session_id] || session_id, scope_id: scope_id, scope: scope
46
+ )
47
+ by_scope[scope_id] << record
48
+ update_entities(scope_id: scope_id, record: record)
49
+ written << record.slice(:id, :type, :key, :status, :confidence, :scope_id, :scope, :source_session_id)
39
50
  end
40
51
 
41
52
  { count: written.size, items: written, conflicts: conflicts }
42
53
  end
43
54
 
44
- def active_items(session_id:)
45
- by_session[session_id].select { |item| item[:status] == 'active' }
55
+ def active_items(session_id: nil, scope_ids: nil)
56
+ selected_scope_ids = normalize_scope_ids(session_id, scope_ids)
57
+ selected_scope_ids.flat_map { |scope_id| by_scope[scope_id] }
58
+ .select { |item| item[:status] == 'active' && included_lifecycle?(item) }
46
59
  end
47
60
 
48
- def entities(session_id:)
49
- entities_index[session_id]
61
+ def entities(session_id: nil, scope_ids: nil)
62
+ normalize_scope_ids(session_id, scope_ids).flat_map { |scope_id| entities_index[scope_id] }
63
+ end
64
+
65
+ # Substring/exact search over active memory_items. Mirrors the FTS path of
66
+ # MemoryStore::Postgres#search_memory so MemoryRetriever is backend-agnostic.
67
+ def search_memory(query:, session_id: nil, scope_ids: nil, limit:)
68
+ @exact.retrieve(
69
+ query: query, memory_items: active_items(session_id: session_id, scope_ids: scope_ids), recent_turns: [], limit: limit
70
+ )
71
+ end
72
+
73
+ def save_summary(session_id:, summary:)
74
+ summaries[session_id] = summary
75
+ summary
76
+ end
77
+
78
+ def latest_summary(session_id:)
79
+ summaries[session_id]
80
+ end
81
+
82
+ def all_summaries
83
+ summaries.each_with_object({}) { |(sid, s), h| h[sid] = s }
84
+ end
85
+
86
+ # --- knowledge lifecycle -------------------------------------------------
87
+ def create_item(session_id:, item:, scope_id: nil, scope: nil)
88
+ resolved_scope_id = scope_id || item[:scope_id] || legacy_scope_id(session_id)
89
+ record = item.merge(
90
+ id: SecureRandom.uuid, session_id: session_id, source_session_id: item[:source_session_id] || session_id,
91
+ scope_id: resolved_scope_id, scope: scope || item[:scope] || { type: 'session', id: session_id }
92
+ )
93
+ by_scope[resolved_scope_id] << record
94
+ update_entities(scope_id: resolved_scope_id, record: record)
95
+ record
96
+ end
97
+
98
+ def find_item(id:)
99
+ by_scope.values.flatten.find { |i| i[:id] == id }
100
+ end
101
+
102
+ def set_lifecycle(id:, lifecycle_status:, merge_value: nil)
103
+ item = find_item(id: id)
104
+ return nil unless item
105
+
106
+ item[:lifecycle_status] = lifecycle_status
107
+ item[:value_json] = (item[:value_json] || {}).merge(merge_value || {})
108
+ item[:updated_at] = Time.now.utc.iso8601
109
+ item
110
+ end
111
+
112
+ def set_status(id:, status:, merge_value: nil)
113
+ item = find_item(id: id)
114
+ return nil unless item
115
+
116
+ item[:status] = status
117
+ item[:value_json] = (item[:value_json] || {}).merge(merge_value || {})
118
+ item[:updated_at] = Time.now.utc.iso8601
119
+ item
120
+ end
121
+
122
+ def record_event(memory_item_id:, event_type:, from_lifecycle: nil, to_lifecycle: nil,
123
+ reason: nil, reason_type: nil, reviewer: nil, evidence_refs: [])
124
+ event = {
125
+ id: SecureRandom.uuid,
126
+ memory_item_id: memory_item_id,
127
+ event_type: event_type,
128
+ from_lifecycle: from_lifecycle,
129
+ to_lifecycle: to_lifecycle,
130
+ reason: reason,
131
+ reason_type: reason_type,
132
+ reviewer: reviewer,
133
+ evidence_refs: Array(evidence_refs),
134
+ created_at: Time.now.utc.iso8601
135
+ }
136
+ events << event
137
+ event
138
+ end
139
+
140
+ def events_for(memory_item_id:)
141
+ events.select { |e| e[:memory_item_id] == memory_item_id }
142
+ end
143
+
144
+ # --- knowledge graph -----------------------------------------------------
145
+ def add_edge(session_id:, edge:, scope_id: nil, scope: nil, source_session_id: nil)
146
+ resolved_scope_id = scope_id || edge[:scope_id] || legacy_scope_id(session_id)
147
+ record = {
148
+ id: SecureRandom.uuid,
149
+ session_id: session_id,
150
+ source_session_id: source_session_id || edge[:source_session_id] || session_id,
151
+ scope_id: resolved_scope_id,
152
+ scope: scope || edge[:scope] || { type: 'session', id: session_id },
153
+ subject: edge[:subject].to_s,
154
+ predicate: edge[:predicate].to_s,
155
+ object: edge[:object].to_s,
156
+ subject_entity_id: resolve_entity_id(nil, edge[:subject], scope_ids: [resolved_scope_id]),
157
+ object_entity_id: resolve_entity_id(nil, edge[:object], scope_ids: [resolved_scope_id]),
158
+ valid_from: edge[:valid_from] || Time.now.utc.iso8601,
159
+ valid_to: edge[:valid_to],
160
+ source_turn_id: edge[:source_turn_id],
161
+ source_memory_item_id: edge[:source_memory_item_id],
162
+ confidence: edge[:confidence] || 0.6,
163
+ status: edge[:status] || 'active',
164
+ meta: edge[:meta] || {},
165
+ created_at: Time.now.utc.iso8601
166
+ }
167
+ edges << record
168
+ record
169
+ end
170
+
171
+ def query_edges(session_id: nil, scope_ids: nil, subject: nil, predicate: nil, object: nil, include_invalid: false)
172
+ selected_scope_ids = normalize_scope_ids(session_id, scope_ids)
173
+ edges.select do |e|
174
+ next false unless selected_scope_ids.include?(e[:scope_id])
175
+ next false if !include_invalid && e[:status] != 'active'
176
+ next false if subject && e[:subject].downcase != subject.to_s.downcase
177
+ next false if predicate && e[:predicate].downcase != predicate.to_s.downcase
178
+ next false if object && e[:object].downcase != object.to_s.downcase
179
+
180
+ true
181
+ end.sort_by { |e| e[:valid_from].to_s }
182
+ end
183
+
184
+ def edges_for_subject(session_id: nil, scope_ids: nil, subject:)
185
+ query_edges(session_id: session_id, scope_ids: scope_ids, subject: subject, include_invalid: true)
186
+ end
187
+
188
+ def invalidate_edge(id:, reason: nil)
189
+ edge = edges.find { |e| e[:id] == id }
190
+ return nil unless edge
191
+
192
+ edge[:valid_to] = Time.now.utc.iso8601
193
+ edge[:status] = 'invalidated'
194
+ edge[:meta] = (edge[:meta] || {}).merge(invalidated_reason: reason)
195
+ edge
196
+ end
197
+
198
+ def find_edge(id:)
199
+ edges.find { |edge| edge[:id] == id }
200
+ end
201
+
202
+ def edge_stats(session_id: nil, scope_ids: nil)
203
+ selected_scope_ids = normalize_scope_ids(session_id, scope_ids)
204
+ scoped = edges.select { |e| selected_scope_ids.include?(e[:scope_id]) }
205
+ { total: scoped.size,
206
+ active: scoped.count { |e| e[:status] == 'active' },
207
+ invalidated: scoped.count { |e| e[:status] == 'invalidated' } }
208
+ end
209
+
210
+ def resolve_entity_id(session_id, name, scope_ids: nil)
211
+ return nil if name.nil? || name.to_s.empty?
212
+
213
+ match = entities(session_id: session_id, scope_ids: scope_ids).find do |e|
214
+ e[:canonical].to_s.downcase == name.to_s.downcase ||
215
+ e[:name].to_s.downcase == name.to_s.downcase
216
+ end
217
+ match && match[:id]
50
218
  end
51
219
 
52
220
  private
53
221
 
54
- attr_reader :by_session, :entities_index
222
+ attr_reader :by_scope, :entities_index, :summaries, :events, :edges
55
223
 
56
- def active_item(session_id:, type:, key:)
57
- by_session[session_id].find { |row| row[:type] == type && row[:key] == key && row[:status] == 'active' }
224
+ def active_item(scope_id:, type:, key:)
225
+ by_scope[scope_id].find { |row| row[:type] == type && row[:key] == key && row[:status] == 'active' }
58
226
  end
59
227
 
60
- def update_entities(session_id:, record:)
228
+ def included_lifecycle?(item)
229
+ Governance::Tiers::CONTEXT_LIFECYCLE.include?(item[:lifecycle_status] || 'raw')
230
+ end
231
+
232
+ def update_entities(scope_id:, record:)
61
233
  return unless record[:type] == 'entities'
62
234
 
63
235
  value = record[:value_json]
64
236
  canonical = value[:canonical] || value[:name]
65
- existing = entities_index[session_id].find { |e| e[:canonical] == canonical && e[:kind] == value[:kind] }
237
+ existing = entities_index[scope_id].find { |e| e[:canonical] == canonical && e[:kind] == value[:kind] }
66
238
  return if existing
67
239
 
68
- entities_index[session_id] << {
240
+ entities_index[scope_id] << {
69
241
  id: SecureRandom.uuid,
70
242
  name: value[:name] || canonical,
71
243
  kind: value[:kind] || 'other',
72
244
  canonical: canonical,
73
- memory_item_id: record[:id]
245
+ memory_item_id: record[:id],
246
+ scope_id: record[:scope_id],
247
+ scope: record[:scope],
248
+ source_session_id: record[:source_session_id]
74
249
  }
75
250
  end
251
+
252
+ def normalize_scope_ids(session_id, scope_ids)
253
+ ids = Array(scope_ids).compact
254
+ ids = [legacy_scope_id(session_id)] if ids.empty? && session_id
255
+ ids
256
+ end
257
+
258
+ def legacy_scope_id(session_id)
259
+ "legacy:session:#{session_id}"
260
+ end
76
261
  end
77
262
  end
78
263
  end