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,63 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # 场景三:Postgres 持久化 —— 跨「重启」召回。
5
+ # 演示:进程 A 写记忆 → 进程 B(新 Runtime,同一 DB)经 FTS 召回。
6
+ #
7
+ # 前置:本地 PG + smart_brain 角色/库。若没建,脚本会打印步骤并退出。
8
+ # sudo -u postgres createuser -d smart_brain
9
+ # sudo -u postgres psql -c "ALTER USER smart_brain PASSWORD 'smart_brain';"
10
+ # sudo -u postgres createdb -O smart_brain smart_brain_test
11
+ #
12
+ # 运行: SMARTBRAIN_DB_NAME=smart_brain_test ruby examples/03_postgres_persistence.rb
13
+
14
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
15
+ require 'smart_brain'
16
+ require 'smart_brain/db'
17
+
18
+ def hr(title) = puts("\n=== #{title} ===")
19
+
20
+ ENV['SMARTBRAIN_BACKEND'] = 'postgres'
21
+ ENV['SMARTBRAIN_DB_NAME'] ||= 'smart_brain_test'
22
+
23
+ config = SmartBrain::Configuration.load
24
+ unless SmartBrain::DB.reachable?(config)
25
+ warn "✗ 连不上 Postgres(#{config.database_config[:host]}:#{config.database_config[:port]}/#{config.database_config[:database]})。"
26
+ warn " 先建库:见脚本顶部注释或 docs/user_guide.md §10。"
27
+ exit 1
28
+ end
29
+
30
+ # 清场(保证可重复运行)
31
+ db = SmartBrain::DB.connect(config)
32
+ SmartBrain::DB.migrate(db)
33
+ db.run('TRUNCATE kg_edges, knowledge_events, memory_chunks, memory_items, summaries, messages, ' \
34
+ 'refs, tool_calls, turns, sessions, entities, entity_mentions RESTART IDENTITY CASCADE')
35
+ SmartBrain::DB.disconnect!
36
+
37
+ sid = 'pg-demo'
38
+
39
+ hr '1) 进程 A:commit_turn 写入(含中文决策 + KG 边)'
40
+ SmartBrain.configure
41
+ SmartBrain.commit_turn(
42
+ session_id: sid,
43
+ turn_events: {
44
+ messages: [{ role: 'user', content: '记一下:SmartBrain 用 Postgres 做持久化' }],
45
+ decisions: [{ key: 'decision:pg:persist', decision: 'SmartBrain 用 Postgres 持久化记忆' }],
46
+ entities: [{ key: 'entity:db:pg', name: 'Postgres', canonical: 'postgres', remember: true }],
47
+ edges: [{ subject: 'smart_brain', predicate: 'persists_via', object: 'postgres' }]
48
+ }
49
+ )
50
+ puts '✓ 进程 A 写完,退出(模拟进程结束)'
51
+
52
+ hr '2) 进程 B:全新 Runtime(同一 DB)—— 验证持久化'
53
+ SmartBrain.configure # 模拟重启:构造全新的 Runtime,共享同一个 Postgres
54
+ puts "backend=#{SmartBrain.storage_backend}"
55
+ hits = SmartBrain.search_memory(session_id: sid, query: '持久化')
56
+ puts "FTS 搜 '持久化' → #{hits.size} 条(mode=#{hits.first&.dig(:mode)})"
57
+ puts "kg_query → #{SmartBrain.kg_query(session_id: sid, subject: 'smart_brain').map { |e| "#{e[:predicate]} #{e[:object]}" }.inspect}"
58
+
59
+ ctx = SmartBrain.compose_context(session_id: sid, user_message: '持久化方案是什么?')
60
+ mem = ctx[:evidence].select { |e| e[:source] == 'memory' }
61
+ puts "compose_context 召回 memory 证据 #{mem.size} 条(跨重启仍命中)"
62
+
63
+ puts "\n✅ 场景三完成。记忆、KG 边、摘要均跨进程持久化。"
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # 场景四:Ollama LLM —— 真摘要 + rerank。
5
+ # 演示 ModelProvider 接真实本地模型后:working_summary 走真摘要、Fusion 走 LLM rerank。
6
+ #
7
+ # 前置:本机 ollama 已起、装了某模型(默认用 llama2 —— 快、非思维链;qwen3 更强但慢)。
8
+ # ollama serve & ollama pull llama2
9
+ #
10
+ # 运行: ruby examples/04_ollama_llm.rb
11
+ # 换模型: SMARTBRAIN_LLM_MODEL=qwen3 ruby examples/04_ollama_llm.rb
12
+
13
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
14
+ require 'smart_brain'
15
+ require 'smart_brain/model_provider'
16
+ require 'smart_brain/memory_store/in_memory'
17
+ require 'smart_brain/consolidator/working_summary'
18
+
19
+ def hr(title) = puts("\n=== #{title} ===")
20
+
21
+ base_url = ENV.fetch('SMARTBRAIN_LLM_BASE_URL', 'http://localhost:11434')
22
+ model = ENV.fetch('SMARTBRAIN_LLM_MODEL', 'llama2')
23
+
24
+ unless system("curl -s -m 2 #{base_url}/api/tags >/dev/null 2>&1")
25
+ warn "✗ 连不上 ollama(#{base_url})。先 ollama serve 并 ollama pull #{model}。"
26
+ exit 1
27
+ end
28
+
29
+ provider = SmartBrain::ModelProvider::Ollama.new(
30
+ model: model, base_url: base_url, temperature: 0.2, timeout_seconds: 180, think: false
31
+ )
32
+ puts "provider=#{provider.class} model=#{model} llm?=#{provider.llm?}"
33
+
34
+ hr '1) complete:让模型回答'
35
+ r = provider.complete(prompt: '用一句话说明 PostgreSQL 的 FTS。', max_tokens: 120)
36
+ puts "回答:#{r[:text].strip[0, 200]}"
37
+
38
+ hr '2) rerank:LLM-as-judge 重排候选'
39
+ docs = [
40
+ '埃菲尔铁塔在巴黎。',
41
+ 'PostgreSQL 支持 JSONB 与全文检索(FTS)。',
42
+ 'Ruby 是一种动态语言。'
43
+ ]
44
+ ranked = provider.rerank(query: '关系型数据库的全文检索', documents: docs)
45
+ puts '按相关性重排:'
46
+ ranked.each { |x| puts " [#{x[:index]}] score=#{x[:score].round(2)} :: #{docs[x[:index]][0, 40]}" }
47
+
48
+ hr '3) working_summary:真摘要(带模板兜底)'
49
+ store = SmartBrain::MemoryStore::InMemory.new
50
+ store.upsert(session_id: 's', items: [
51
+ { type: 'decisions', key: 'decision:db', value_json: { decision: '用 Postgres 持久化' },
52
+ confidence: 0.8, status: 'active', tier: 'dao_ren', lifecycle_status: 'raw', updated_at: Time.now.utc.iso8601 },
53
+ { type: 'tasks', key: 'task:mcp', value_json: { title: '加 MCP server' },
54
+ confidence: 0.9, status: 'active', tier: 'shu', lifecycle_status: 'raw', updated_at: Time.now.utc.iso8601 }
55
+ ])
56
+ ws = SmartBrain::Consolidator::WorkingSummary.new(
57
+ config: SmartBrain::Configuration.load, clock: -> { Time.now.utc },
58
+ memory_store: store, model_provider: provider
59
+ )
60
+ summary = ws.update(
61
+ session_id: 's', turn_count: 1, stage_event: true,
62
+ recent_turns: [{ role: 'user', content: '用 Postgres' }, { role: 'assistant', content: '好,持久化到 memory_chunks' }],
63
+ memory_items: store.active_items(session_id: 's')
64
+ )
65
+ puts "summary_method=#{summary[:summary_method]}"
66
+ puts "摘要:\n#{summary[:text]}"
67
+
68
+ puts "\n✅ 场景四完成。stub→ollama 后 summary/rerank 走真实模型(超时/截断自动降级到模板/词法)。"
69
+ puts " 注:思维链模型(qwen3/deepseek-r1)同步较慢,本示例默认 llama2;换 qwen3 用 SMARTBRAIN_LLM_MODEL=qwen3。"
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # 场景五:SmartBrain + SmartRAG —— 对话记忆 + 资源检索融合。
5
+ # SmartBrain 管对话记忆/治理,SmartRAG 管文档/网页 RAG;compose_context 命中「查资料/引用」
6
+ # 意图时自动调用 SmartRAG,再由 Fusion 统一去重 + rerank + diversity。
7
+ #
8
+ # 前置(缺任一则脚本会打印步骤并优雅退出):
9
+ # 1) SmartRAG gem 已装(bundle 里已有 smart_rag)。
10
+ # 2) SmartRAG 的数据库可连(默认读下方 env)。
11
+ # 3) (可选)embedding/llm 配置。
12
+ # 完整可运行版见仓库根目录 conversation_demo.rb。
13
+ #
14
+ # 运行:
15
+ # SMARTBRAIN_RAG_DB_HOST=127.0.0.1 \
16
+ # SMARTBRAIN_RAG_DB_NAME=smart_rag_development \
17
+ # SMARTBRAIN_RAG_DB_USER=rag_user SMARTBRAIN_RAG_DB_PASSWORD=rag_pwd \
18
+ # ruby examples/05_smart_rag_integration.rb
19
+
20
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
21
+ require 'smart_brain'
22
+ require 'smart_brain/adapters/smart_rag/direct_client'
23
+
24
+ def hr(title) = puts("\n=== #{title} ===")
25
+
26
+ begin
27
+ require 'smart_rag'
28
+ rescue LoadError => e
29
+ warn "✗ 加载 smart_rag 失败:#{e.message}"
30
+ warn " 请先确保 smart_rag gem 可用(bundle 里已声明)。"
31
+ exit 1
32
+ end
33
+
34
+ rag_config = {
35
+ database: {
36
+ adapter: 'postgresql',
37
+ host: ENV.fetch('SMARTBRAIN_RAG_DB_HOST', '127.0.0.1'),
38
+ port: ENV.fetch('SMARTBRAIN_RAG_DB_PORT', '5432').to_i,
39
+ database: ENV.fetch('SMARTBRAIN_RAG_DB_NAME', 'smart_rag_development'),
40
+ user: ENV.fetch('SMARTBRAIN_RAG_DB_USER', 'rag_user'),
41
+ password: ENV.fetch('SMARTBRAIN_RAG_DB_PASSWORD', 'rag_pwd')
42
+ }
43
+ }
44
+
45
+ rag =
46
+ begin
47
+ SmartRAG::SmartRAG.new(rag_config)
48
+ rescue StandardError => e
49
+ warn "✗ SmartRAG 初始化失败:#{e.class}: #{e.message}"
50
+ warn " 检查上方数据库 env,或参考仓库根目录 conversation_demo.rb 的完整配置(含 llm/embedding)。"
51
+ exit 1
52
+ end
53
+
54
+ # 挂载资源后端
55
+ SmartBrain.configure(smart_rag_client: SmartBrain::Adapters::SmartRag::DirectClient.new(rag: rag))
56
+ sid = "rag-#{Process.pid}"
57
+
58
+ hr '1) commit_turn:写一轮对话记忆'
59
+ SmartBrain.commit_turn(
60
+ session_id: sid,
61
+ turn_events: {
62
+ messages: [{ role: 'user', content: '帮我查一下 Ruby 命名规范的资料' }],
63
+ goals: [{ key: 'goal:learn:ruby', goal: '学习 Ruby 规范' }]
64
+ }
65
+ )
66
+ puts '✓ 对话记忆已写'
67
+
68
+ hr '2) compose_context:命中「查资料」意图 → 调 SmartRAG + 融合'
69
+ ctx = SmartBrain.compose_context(session_id: sid, user_message: '查一下 Ruby 命名规范并给出引用')
70
+ mem = ctx[:evidence].count { |e| e[:source] == 'memory' }
71
+ res = ctx[:evidence].count { |e| e[:source] == 'resource' }
72
+ puts "evidence:memory=#{mem} resource=#{res} (memory/resource 比 = #{SmartBrain.diagnostics[:metrics][:memory_resource_ratio]})"
73
+ puts "资源检索是否触发:#{ctx.dig(:debug, :planner, :purpose) == 'research' ? '是(purpose=research)' : '否'}"
74
+ ctx[:evidence].first(3).each do |e|
75
+ puts " - [#{e[:source]}] #{e[:title]} score=#{e[:score].round(2)}"
76
+ end
77
+
78
+ puts "\n✅ 场景五完成。SmartBrain 记忆 + SmartRAG 资源在 Fusion 层统一融合。"
79
+ puts " 若上方 resource=0:往 SmartRAG 灌点文档(rag.add_document ...),或换一个明显命中资源检索的 query。"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'smart_brain'
4
+
5
+ SmartBrain.configure
6
+
7
+ domain_id = 'demo-domain'
8
+ project_ref = { type: 'project', id: 'project-001' }
9
+ task_ref = { type: 'task', id: 'task-030' }
10
+ scope_context = {
11
+ read: [{ type: 'global', id: 'default' }, project_ref, task_ref],
12
+ write: [project_ref, task_ref],
13
+ default_write: task_ref
14
+ }
15
+
16
+ source = SmartBrain.commit_turn(
17
+ domain_id: domain_id,
18
+ session_id: 'writer-session',
19
+ scope_context: scope_context,
20
+ turn_events: {
21
+ decisions: [{ key: 'decision:database', decision: 'Use PostgreSQL for this task' }]
22
+ }
23
+ ).dig(:memory_written, :items).first
24
+
25
+ promotion = SmartBrain.promote_to_scope(
26
+ memory_item_id: source[:id],
27
+ target_scope: project_ref,
28
+ domain_id: domain_id,
29
+ session_id: 'writer-session',
30
+ scope_context: scope_context,
31
+ verification_refs: ['architecture-review-001'],
32
+ reason: 'Confirmed as a project-wide decision',
33
+ reviewer: 'human'
34
+ )
35
+
36
+ reader_context = {
37
+ read: [{ type: 'global', id: 'default' }, project_ref, { type: 'task', id: 'task-031' }],
38
+ write: [{ type: 'task', id: 'task-031' }],
39
+ default_write: { type: 'task', id: 'task-031' }
40
+ }
41
+ context = SmartBrain.compose_context(
42
+ domain_id: domain_id,
43
+ session_id: 'reader-session',
44
+ scope_context: reader_context,
45
+ user_message: 'Which database did the project choose?'
46
+ )
47
+
48
+ puts "Promoted item: #{promotion.dig(:memory_item, :id)}"
49
+ puts "Recalled scopes: #{context[:evidence].map { |item| item[:scope] }.compact.inspect}"
50
+ puts "Lineage: #{SmartBrain.promotion_lineage(memory_item_id: promotion.dig(:memory_item, :id)).map { |item| item[:id] }.inspect}"
@@ -0,0 +1,49 @@
1
+ # SmartBrain 示例集
2
+
3
+ 每个脚本都是**自包含、可独立运行**的,从易到难、按需引入外部依赖。
4
+ 全部默认零外部依赖即可起步;PG / Ollama / SmartRAG 按场景按需开启。
5
+
6
+ ## 场景速查
7
+
8
+ | 脚本 | 场景 | 依赖 | 后端 |
9
+ |---|---|---|---|
10
+ | [`01_memory_basic.rb`](01_memory_basic.rb) | 最小闭环:commit→compose→search→brief→diagnostics | 无 | memory |
11
+ | [`02_governance.rb`](02_governance.rb) | 记忆治理:lifecycle + KG + fact_check + brief | 无 | memory |
12
+ | [`03_postgres_persistence.rb`](03_postgres_persistence.rb) | 跨「重启」持久化召回 | PostgreSQL | postgres |
13
+ | [`04_ollama_llm.rb`](04_ollama_llm.rb) | LLM 真摘要 + rerank | Ollama | memory |
14
+ | [`05_smart_rag_integration.rb`](05_smart_rag_integration.rb) | 对话记忆 + 资源 RAG 融合 | SmartRAG | memory |
15
+ | [`06_multi_scope_memory.rb`](06_multi_scope_memory.rb) | project/task 跨会话共享、冲突与 promotion | 无 | memory |
16
+
17
+ ## 运行
18
+
19
+ ```bash
20
+ cd /root/smart_brain
21
+
22
+ ruby examples/01_memory_basic.rb # 直接跑,无需任何配置
23
+ ruby examples/02_governance.rb # 直接跑
24
+
25
+ SMARTBRAIN_DB_NAME=smart_brain_test \
26
+ ruby examples/03_postgres_persistence.rb # 需先建库(见脚本顶部注释)
27
+
28
+ ruby examples/04_ollama_llm.rb # 需 ollama serve + ollama pull llama2
29
+ SMARTBRAIN_LLM_MODEL=qwen3 ruby examples/04_ollama_llm.rb # 换更强的模型(更慢)
30
+
31
+ SMARTBRAIN_RAG_DB_HOST=... SMARTBRAIN_RAG_DB_NAME=smart_rag_development \
32
+ ruby examples/05_smart_rag_integration.rb # 需 SmartRAG 实例
33
+ ruby examples/06_multi_scope_memory.rb
34
+ ```
35
+
36
+ > 没装对应依赖时,03/04/05 会**打印设置步骤并优雅退出**(不会崩)。
37
+
38
+ ## 阅读顺序建议
39
+
40
+ 1. `01_memory_basic.rb` —— 先建立 commit/compose 心智模型。
41
+ 2. `02_governance.rb` —— 理解「记忆宫殿」的分层与生命周期。
42
+ 3. 按需挑 03/04/05 之一看持久化、LLM、资源检索如何接入。
43
+
44
+ ## 参考文档
45
+
46
+ - 完整用法:[`../docs/user_guide.md`](../docs/user_guide.md)
47
+ - MCP 接入:[`../docs/mcp.md`](../docs/mcp.md)
48
+ - 设计与契约:`../docs/smartbrain_design.md`、`../docs/memory_types.md`、`../docs/policies.md`
49
+ - 完整 SmartRAG 集成 demo:`../conversation_demo.rb`、`../example.rb`
data/exe/smart_brain ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # SmartBrain CLI — drive the memory runtime from the shell or as an agent tool.
5
+ #
6
+ # smart_brain status
7
+ # smart_brain migrate (postgres backend only)
8
+ # smart_brain commit --data '{"session_id":..,"turn_events":{..}}'
9
+ # smart_brain compose --data '{"session_id":..,"user_message":..}'
10
+ # smart_brain search --data '{"session_id":..,"query":..}'
11
+ # smart_brain serve [--host 0.0.0.0] [--port 9292] [--config PATH]
12
+ # smart_brain mcp [--config PATH] (stdio JSON-RPC, for Claude Code / Cursor)
13
+
14
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
15
+
16
+ require 'optparse'
17
+ require 'json'
18
+ require 'smart_brain'
19
+ require 'smart_brain/server/service'
20
+ require 'smart_brain/server/http_app'
21
+ require 'smart_brain/server/mcp_server'
22
+
23
+ module SmartBrain
24
+ module CLI
25
+ module_function
26
+
27
+ def run(argv)
28
+ config_path = ENV['SMARTBRAIN_CONFIG']
29
+ global = OptionParser.new do |opts|
30
+ opts.banner = 'Usage: smart_brain <command> [options]'
31
+ opts.on('--config PATH', 'Path to brain.yml') { |p| config_path = p }
32
+ opts.on('--version', 'Print version and exit') { puts SmartBrain::VERSION; exit 0 }
33
+ opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
34
+ end
35
+ global.order!(argv)
36
+
37
+ command = argv.shift
38
+ abort(global.to_s) unless command
39
+
40
+ case command
41
+ when 'status' then print_json(Server::Service.build(config_path: config_path).status)
42
+ when 'migrate' then print_json(ok: Server::Service.build(config_path: config_path).migrate)
43
+ when 'commit', 'compose', 'search', 'fact-check', 'brief', 'wake-up'
44
+ data_cmd(command, argv, config_path)
45
+ when 'knowledge' then knowledge_cmd(argv, config_path)
46
+ when 'kg' then kg_cmd(argv, config_path)
47
+ when 'serve' then serve(argv, config_path)
48
+ when 'mcp' then mcp(config_path)
49
+ else
50
+ warn "unknown command: #{command}"
51
+ warn global.to_s
52
+ exit 1
53
+ end
54
+ end
55
+
56
+ def data_cmd(command, argv, config_path)
57
+ options = {}
58
+ parser = OptionParser.new do |opts|
59
+ opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
60
+ opts.on('-h', '--help') { puts opts; exit 0 }
61
+ end
62
+ parser.parse!(argv)
63
+
64
+ payload = read_payload(options[:data])
65
+ service = Server::Service.build(config_path: config_path)
66
+ result =
67
+ case command
68
+ when 'commit' then service.commit(**payload.slice(:domain_id, :session_id, :scope_context, :turn_events).merge(turn_events: payload[:turn_events] || {}))
69
+ when 'compose' then service.compose(**payload.slice(:domain_id, :session_id, :scope_context, :user_message, :agent_state).merge(agent_state: payload[:agent_state] || {}))
70
+ when 'search' then service.search(**payload.slice(:domain_id, :session_id, :scope_context, :query, :limit))
71
+ when 'fact-check' then service.fact_check(**payload.slice(:domain_id, :session_id, :scope_context, :text))
72
+ when 'brief' then service.brief(**payload.slice(:domain_id, :session_id, :scope_context, :query))
73
+ when 'wake-up' then service.wake_up(**payload.slice(:domain_id, :session_id, :scope_context))
74
+ end
75
+ print_json(result)
76
+ end
77
+
78
+ def knowledge_cmd(argv, config_path)
79
+ options = {}
80
+ OptionParser.new do |opts|
81
+ opts.banner = 'usage: smart_brain knowledge <distill|gate|promote|demote|retract|promote-to-scope|lineage|events> --data JSON'
82
+ opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
83
+ opts.on('-h', '--help') { puts opts; exit 0 }
84
+ end.parse!(argv)
85
+
86
+ action = argv.shift
87
+ abort 'usage: smart_brain knowledge <distill|gate|promote|demote|retract|promote-to-scope|lineage|events> --data JSON' unless action
88
+
89
+ payload = read_payload(options[:data])
90
+ service = Server::Service.build(config_path: config_path)
91
+ result =
92
+ case action
93
+ when 'distill' then service.distill(**payload.slice(:domain_id, :session_id, :scope_context, :statement, :content, :tier, :supporting_refs, :domain, :field, :reviewer, :key))
94
+ when 'gate' then service.gate(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
95
+ when 'promote' then service.promote(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :verification_refs, :reason, :reviewer, :force))
96
+ when 'demote' then service.demote(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :evidence_refs, :reason, :reason_type, :reviewer))
97
+ when 'retract' then service.retract(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context, :evidence_refs, :reason, :reviewer))
98
+ when 'promote-to-scope' then service.promote_to_scope(**payload.slice(:memory_item_id, :target_scope, :domain_id, :session_id, :scope_context, :verification_refs, :reason, :reviewer))
99
+ when 'lineage' then service.promotion_lineage(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
100
+ when 'events' then service.knowledge_events(**payload.slice(:memory_item_id, :domain_id, :session_id, :scope_context))
101
+ else abort "unknown knowledge action: #{action}"
102
+ end
103
+ print_json(result)
104
+ rescue SmartBrain::Governance::LifecycleGateError, ArgumentError => e
105
+ warn "#{e.class}: #{e.message}"
106
+ exit 1
107
+ end
108
+
109
+ def kg_cmd(argv, config_path)
110
+ options = {}
111
+ OptionParser.new do |opts|
112
+ opts.banner = 'usage: smart_brain kg <add|query|timeline|invalidate|stats> --data JSON'
113
+ opts.on('--data JSON', 'JSON payload (or read from stdin)') { |j| options[:data] = j }
114
+ opts.on('-h', '--help') { puts opts; exit 0 }
115
+ end.parse!(argv)
116
+ action = argv.shift
117
+ abort 'usage: smart_brain kg <add|query|timeline|invalidate|stats> --data JSON' unless action
118
+
119
+ payload = read_payload(options[:data])
120
+ service = Server::Service.build(config_path: config_path)
121
+ result =
122
+ case action
123
+ when 'add' then service.kg_add(**payload.slice(:domain_id, :session_id, :scope_context, :scope_ref, :subject, :predicate, :object, :confidence, :source_turn_id))
124
+ when 'query' then service.kg_query(**payload.slice(:domain_id, :session_id, :scope_context, :subject, :predicate, :object, :include_invalid))
125
+ when 'timeline' then service.kg_timeline(**payload.slice(:domain_id, :session_id, :scope_context, :subject))
126
+ when 'invalidate' then service.kg_invalidate(**payload.slice(:edge_id, :reason, :domain_id, :session_id, :scope_context))
127
+ when 'stats' then service.kg_stats(**payload.slice(:domain_id, :session_id, :scope_context))
128
+ else abort "unknown kg action: #{action}"
129
+ end
130
+ print_json(result)
131
+ end
132
+
133
+ def serve(argv, config_path)
134
+ options = { host: '127.0.0.1', port: 9292 }
135
+ OptionParser.new do |opts|
136
+ opts.on('--host HOST') { |h| options[:host] = h }
137
+ opts.on('--port PORT', Integer) { |p| options[:port] = p }
138
+ opts.on('-h', '--help') { puts opts; exit 0 }
139
+ end.parse!(argv)
140
+
141
+ service = Server::Service.build(config_path: config_path)
142
+ Server::HttpApp.service = service
143
+ warn "[smart_brain] HTTP on http://#{options[:host]}:#{options[:port]} (backend: #{service.backend})"
144
+ Server::HttpApp.run!(host: options[:host], port: options[:port], server: :puma) do |server|
145
+ # Puma-specific tuning can go here.
146
+ end
147
+ end
148
+
149
+ def mcp(config_path)
150
+ service = Server::Service.build(config_path: config_path)
151
+ Server::McpServer.new(service: service).run
152
+ end
153
+
154
+ def read_payload(raw)
155
+ return {} if raw.nil? && $stdin.tty?
156
+ raw ||= $stdin.read
157
+ raw.to_s.strip.empty? ? {} : JSON.parse(raw, symbolize_names: true)
158
+ rescue JSON::ParserError => e
159
+ abort "invalid JSON payload: #{e.message}"
160
+ end
161
+
162
+ def print_json(payload)
163
+ puts JSON.generate(payload)
164
+ end
165
+ end
166
+ end
167
+
168
+ SmartBrain::CLI.run(ARGV)
@@ -1,16 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'scope_filter'
4
+
3
5
  module SmartBrain
4
6
  module Adapters
5
7
  module SmartRag
6
8
  class DirectClient
7
- def initialize(rag:)
9
+ include ScopeFilter
10
+
11
+ def initialize(rag:, scope_mapper: nil, fail_closed: true)
8
12
  @rag = rag
13
+ @scope_mapper = scope_mapper
14
+ @fail_closed = fail_closed
9
15
  end
10
16
 
11
17
  def retrieve(plan)
12
- response = rag.retrieve(plan: plan)
13
- normalize_pack(response, request_id: plan[:request_id])
18
+ scoped_plan, required, ignored, warnings = prepare_scoped_plan(plan)
19
+ return scope_failure_pack(plan, ignored, warnings, prefix: 'scope-blocked') unless scoped_plan
20
+
21
+ response = rag.retrieve(plan: scoped_plan)
22
+ pack = normalize_pack(response, request_id: plan[:request_id])
23
+ enforce_scope_confirmation(pack, required: required, ignored: ignored, warnings: warnings)
14
24
  rescue StandardError => e
15
25
  {
16
26
  version: '0.1',
@@ -26,7 +36,7 @@ module SmartBrain
26
36
 
27
37
  private
28
38
 
29
- attr_reader :rag
39
+ attr_reader :rag, :scope_mapper, :fail_closed
30
40
 
31
41
  def normalize_pack(response, request_id:)
32
42
  pack = response.is_a?(Hash) ? response : {}
@@ -38,7 +48,8 @@ module SmartBrain
38
48
  evidences: Array(pack[:evidences]),
39
49
  stats: pack[:stats] || { candidates: Array(pack[:evidences]).size, returned: Array(pack[:evidences]).size, took_ms: 0 },
40
50
  explain: pack[:explain] || { ignored_fields: [] },
41
- warnings: Array(pack[:warnings])
51
+ warnings: Array(pack[:warnings]),
52
+ scope_filter_applied: pack[:scope_filter_applied] == true
42
53
  }
43
54
  end
44
55
  end
@@ -1,18 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'scope_filter'
4
+
3
5
  module SmartBrain
4
6
  module Adapters
5
7
  module SmartRag
6
8
  class HttpClient
7
- def initialize(transport:, timeout_seconds: 2)
9
+ include ScopeFilter
10
+
11
+ def initialize(transport:, timeout_seconds: 2, scope_mapper: nil, fail_closed: true)
8
12
  @transport = transport
9
13
  @timeout_seconds = timeout_seconds
14
+ @scope_mapper = scope_mapper
15
+ @fail_closed = fail_closed
10
16
  end
11
17
 
12
18
  def retrieve(plan)
13
19
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
- raw = transport.call(plan, timeout_seconds: timeout_seconds)
15
- build_pack(raw: raw, request_id: plan[:request_id], took_ms: elapsed_ms(started_at))
20
+ scoped_plan, required, ignored, warnings = prepare_scoped_plan(plan)
21
+ return scope_failure_pack(plan, ignored, warnings, prefix: 'scope-blocked') unless scoped_plan
22
+
23
+ raw = transport.call(scoped_plan, timeout_seconds: timeout_seconds)
24
+ pack = build_pack(raw: raw, request_id: plan[:request_id], took_ms: elapsed_ms(started_at))
25
+ enforce_scope_confirmation(pack, required: required, ignored: ignored, warnings: warnings)
16
26
  rescue Timeout::Error
17
27
  {
18
28
  version: '0.1',
@@ -28,7 +38,7 @@ module SmartBrain
28
38
 
29
39
  private
30
40
 
31
- attr_reader :transport, :timeout_seconds
41
+ attr_reader :transport, :timeout_seconds, :scope_mapper, :fail_closed
32
42
 
33
43
  def build_pack(raw:, request_id:, took_ms:)
34
44
  ignored = []
@@ -48,7 +58,8 @@ module SmartBrain
48
58
  explain: {
49
59
  ignored_fields: ignored + Array(raw.dig(:explain, :ignored_fields))
50
60
  },
51
- warnings: Array(raw[:warnings])
61
+ warnings: Array(raw[:warnings]),
62
+ scope_filter_applied: raw[:scope_filter_applied] == true
52
63
  }
53
64
  end
54
65
 
@@ -5,6 +5,9 @@ module SmartBrain
5
5
  module SmartRag
6
6
  class NullClient
7
7
  def retrieve(plan)
8
+ business_scoped = Array(plan.dig(:scope_context, :read)).any? do |ref|
9
+ (ref[:type] || ref['type']).to_s != 'session'
10
+ end
8
11
  {
9
12
  version: '0.1',
10
13
  plan_id: "local-#{plan[:request_id]}",
@@ -12,8 +15,10 @@ module SmartBrain
12
15
  generated_at: Time.now.utc.iso8601,
13
16
  evidences: [],
14
17
  stats: { candidates: 0, returned: 0, took_ms: 0 },
15
- explain: { ignored_fields: [] },
16
- warnings: ['smart_rag client not configured; returned empty evidences']
18
+ explain: { ignored_fields: business_scoped ? ['scope_context.read'] : [] },
19
+ warnings: [business_scoped ? 'smart_rag scope filter unavailable: client not configured; failed closed' :
20
+ 'smart_rag client not configured; returned empty evidences'],
21
+ scope_filter_applied: !business_scoped
17
22
  }
18
23
  end
19
24
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartBrain
4
+ module Adapters
5
+ module SmartRag
6
+ module ScopeFilter
7
+ private
8
+
9
+ def prepare_scoped_plan(plan)
10
+ refs = Array(plan.dig(:scope_context, :read)).reject { |ref| (ref[:type] || ref['type']).to_s == 'session' }
11
+ return [plan, false, [], []] if refs.empty?
12
+
13
+ unless scope_mapper
14
+ warning = 'smart_rag scope filter unavailable: no scope mapper configured'
15
+ return [nil, true, ['scope_context.read'], [warning]] if fail_closed
16
+ return [plan, true, ['scope_context.read'], [warning]]
17
+ end
18
+
19
+ mapped = scope_mapper.call(
20
+ domain_id: plan.dig(:debug, :caller, :domain_id),
21
+ scopes: refs
22
+ )
23
+ applied = mapped.fetch(:applied, true)
24
+ filters = mapped[:filters] || mapped.reject { |key, _value| %i[applied warnings ignored_fields].include?(key) }
25
+ warnings = Array(mapped[:warnings])
26
+ ignored = Array(mapped[:ignored_fields])
27
+ return [nil, true, ignored + ['scope_context.read'], warnings + ['smart_rag scope mapper did not apply filters']] if !applied && fail_closed
28
+
29
+ [plan.merge(scope_filters: filters), true, ignored, warnings]
30
+ rescue StandardError => e
31
+ warning = "smart_rag scope mapping failed: #{e.message}"
32
+ fail_closed ? [nil, true, ['scope_context.read'], [warning]] : [plan, true, ['scope_context.read'], [warning]]
33
+ end
34
+
35
+ def scope_failure_pack(plan, ignored, warnings, prefix:)
36
+ {
37
+ version: '0.2', request_id: plan[:request_id], plan_id: "#{prefix}-#{plan[:request_id]}",
38
+ generated_at: Time.now.utc.iso8601, evidences: [],
39
+ stats: { candidates: 0, returned: 0, took_ms: 0 },
40
+ explain: { ignored_fields: ignored }, warnings: warnings,
41
+ scope_filter_applied: false
42
+ }
43
+ end
44
+
45
+ def enforce_scope_confirmation(pack, required:, ignored:, warnings:)
46
+ confirmed = pack[:scope_filter_applied] == true
47
+ pack[:explain] ||= { ignored_fields: [] }
48
+ pack[:explain][:ignored_fields] = Array(pack.dig(:explain, :ignored_fields)) + ignored
49
+ pack[:warnings] = Array(pack[:warnings]) + warnings
50
+ return pack unless required && !confirmed
51
+
52
+ pack[:explain][:ignored_fields] << 'scope_context.read'
53
+ pack[:warnings] << 'smart_rag did not confirm scope filter application'
54
+ pack[:evidences] = [] if fail_closed
55
+ pack
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end