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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require_relative '../support/levenshtein'
5
+
6
+ module SmartBrain
7
+ module Governance
8
+ # Offline, zero-LLM, zero-network contradiction detector (mempal parity).
9
+ # Scans a piece of text against a session's entities + KG edges and reports
10
+ # three finding types:
11
+ # - SimilarNameConflict: a token within Levenshtein <= N of a known entity
12
+ # - RelationContradiction: text mentions both endpoints of an active edge
13
+ # alongside a negation/contrary cue (may contradict the relation)
14
+ # - StaleFact: text references an edge whose valid_to < now (invalidated)
15
+ #
16
+ # Heuristic by design — surfaces candidates for human/agent review.
17
+ class FactCheck
18
+ TOKEN_RE = /[[:alnum:]_\-\p{Han}]+/.freeze
19
+
20
+ def initialize(memory_store:, config:)
21
+ @memory_store = memory_store
22
+ @config = config
23
+ end
24
+
25
+ def check(session_id:, text:, now: Time.now.utc, scope_ids: nil)
26
+ now = Time.parse(now).utc if now.is_a?(String)
27
+ text_l = text.to_s.downcase
28
+ tokens = text.to_s.downcase.scan(TOKEN_RE)
29
+
30
+ findings = []
31
+ findings.concat(similar_name_conflicts(session_id:, scope_ids:, tokens: tokens))
32
+ findings.concat(relation_contradictions(session_id:, scope_ids:, text_l: text_l))
33
+ findings.concat(stale_facts(session_id:, scope_ids:, text_l: text_l, now: now))
34
+
35
+ { findings: findings, checked_at: Time.now.utc.iso8601,
36
+ counts: { total: findings.size,
37
+ similar_name: findings.count { |f| f[:type] == 'SimilarNameConflict' },
38
+ relation_contradiction: findings.count { |f| f[:type] == 'RelationContradiction' },
39
+ stale_fact: findings.count { |f| f[:type] == 'StaleFact' } } }
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :memory_store, :config
45
+
46
+ def similar_name_conflicts(session_id:, scope_ids:, tokens:)
47
+ distance = config.fact_check.fetch(:similar_name_distance, 2) rescue 2
48
+ names = memory_store.entities(session_id: session_id, scope_ids: scope_ids).each_with_object([]) do |e, acc|
49
+ acc << { name: e[:canonical].to_s, entity: e } unless e[:canonical].to_s.empty?
50
+ acc << { name: e[:name].to_s, entity: e } unless e[:name].to_s.empty?
51
+ end
52
+
53
+ tokens.product(names).each_with_object([]) do |(token, named), acc|
54
+ next if token.length < 3 || named[:name].length < 3
55
+ next if token == named[:name]
56
+
57
+ d = Support::Levenshtein.distance(token, named[:name])
58
+ next unless d.between?(1, distance)
59
+
60
+ acc << { type: 'SimilarNameConflict', severity: 'warning',
61
+ detail: "'#{token}' is within Levenshtein #{d} of entity '#{named[:name]}'",
62
+ refs: { token: token, entity: named[:name], entity_id: named[:entity][:id], distance: d } }
63
+ end
64
+ end
65
+
66
+ def relation_contradictions(session_id:, scope_ids:, text_l:)
67
+ cues = negation_cues
68
+ edges = safe_edges(session_id, scope_ids).select { |e| e[:status] == 'active' }
69
+ return [] if edges.empty? || cues.empty?
70
+
71
+ edges.each_with_object([]) do |edge, acc|
72
+ s, o = edge[:subject].downcase, edge[:object].downcase
73
+ next unless text_l.include?(s) && text_l.include?(o)
74
+ next unless cues.any? { |cue| text_l.include?(cue.to_s.downcase) }
75
+
76
+ acc << { type: 'RelationContradiction', severity: 'warning',
77
+ detail: "text may contradict relation '#{edge[:subject]} #{edge[:predicate]} #{edge[:object]}'",
78
+ refs: { edge_id: edge[:id], subject: edge[:subject], predicate: edge[:predicate], object: edge[:object] } }
79
+ end
80
+ end
81
+
82
+ def stale_facts(session_id:, scope_ids:, text_l:, now:)
83
+ safe_edges(session_id, scope_ids).select { |e| e[:status] == 'invalidated' && e[:valid_to] }
84
+ .each_with_object([]) do |edge, acc|
85
+ valid_to = Time.parse(edge[:valid_to].to_s).utc rescue next
86
+ next if valid_to >= now
87
+
88
+ s, o = edge[:subject].downcase, edge[:object].downcase
89
+ next unless text_l.include?(s) || text_l.include?(o)
90
+
91
+ acc << { type: 'StaleFact', severity: 'info',
92
+ detail: "text references invalidated fact '#{edge[:subject]} #{edge[:predicate]} #{edge[:object]}' (valid_to #{edge[:valid_to]})",
93
+ refs: { edge_id: edge[:id], subject: edge[:subject], object: edge[:object], valid_to: edge[:valid_to] } }
94
+ end
95
+ end
96
+
97
+ def safe_edges(session_id, scope_ids)
98
+ return [] unless memory_store.respond_to?(:query_edges)
99
+
100
+ memory_store.query_edges(session_id: session_id, scope_ids: scope_ids, include_invalid: true)
101
+ rescue StandardError
102
+ []
103
+ end
104
+
105
+ def negation_cues
106
+ config.fact_check.fetch(:negation_cues, []) rescue []
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartBrain
4
+ module Governance
5
+ # Knowledge graph: typed triples (subject-predicate-object) with temporal
6
+ # validity (valid_from/valid_to) over a MemoryStore. Orthogonal to the
7
+ # Stage-1 lifecycle and to memory_items — edges live in kg_edges and are
8
+ # added explicitly (via turn_events[:edges] extraction) or by the agent.
9
+ class KnowledgeGraph
10
+ def initialize(memory_store:, config:)
11
+ @memory_store = memory_store
12
+ @config = config
13
+ end
14
+
15
+ def add(session_id:, subject:, predicate:, object:, source_turn_id: nil,
16
+ source_memory_item_id: nil, confidence: nil, valid_from: nil,
17
+ scope_id: nil, scope: nil, source_session_id: nil)
18
+ memory_store.add_edge(
19
+ session_id: session_id,
20
+ scope_id: scope_id,
21
+ scope: scope,
22
+ source_session_id: source_session_id,
23
+ edge: {
24
+ subject: subject, predicate: predicate, object: object,
25
+ source_turn_id: source_turn_id, source_memory_item_id: source_memory_item_id,
26
+ confidence: confidence || default_confidence, valid_from: valid_from
27
+ }
28
+ )
29
+ end
30
+
31
+ def query(session_id: nil, scope_ids: nil, subject: nil, predicate: nil, object: nil, include_invalid: false)
32
+ memory_store.query_edges(session_id: session_id, scope_ids: scope_ids, subject: subject,
33
+ predicate: predicate, object: object,
34
+ include_invalid: include_invalid)
35
+ end
36
+
37
+ def timeline(session_id: nil, scope_ids: nil, subject:)
38
+ memory_store.edges_for_subject(session_id: session_id, scope_ids: scope_ids, subject: subject)
39
+ end
40
+
41
+ def invalidate(edge_id:, reason: nil)
42
+ memory_store.invalidate_edge(id: edge_id, reason: reason)
43
+ end
44
+
45
+ def stats(session_id: nil, scope_ids: nil)
46
+ memory_store.edge_stats(session_id: session_id, scope_ids: scope_ids)
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :memory_store, :config
52
+
53
+ def default_confidence
54
+ config.kg.fetch(:default_confidence, 0.6)
55
+ rescue StandardError
56
+ 0.6
57
+ end
58
+ end
59
+ end
60
+ end
@@ -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