claude_memory 0.7.1 → 0.8.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/memory.sqlite3-shm +0 -0
  4. data/.claude/memory.sqlite3-wal +0 -0
  5. data/.claude/settings.json +78 -6
  6. data/.claude/settings.local.json +2 -1
  7. data/.claude/skills/improve/SKILL.md +113 -25
  8. data/.claude-plugin/commands/distill-transcripts.md +98 -0
  9. data/.claude-plugin/commands/memory-recall.md +67 -0
  10. data/.claude-plugin/marketplace.json +1 -1
  11. data/.claude-plugin/plugin.json +1 -1
  12. data/CHANGELOG.md +49 -1
  13. data/CLAUDE.md +29 -5
  14. data/docs/improvements.md +18 -56
  15. data/docs/quality_review.md +119 -224
  16. data/hooks/hooks.json +39 -7
  17. data/lib/claude_memory/commands/checks/distill_check.rb +61 -0
  18. data/lib/claude_memory/commands/checks/hooks_check.rb +2 -2
  19. data/lib/claude_memory/commands/checks/vec_check.rb +2 -1
  20. data/lib/claude_memory/commands/completion_command.rb +179 -0
  21. data/lib/claude_memory/commands/doctor_command.rb +2 -0
  22. data/lib/claude_memory/commands/help_command.rb +4 -0
  23. data/lib/claude_memory/commands/hook_command.rb +2 -1
  24. data/lib/claude_memory/commands/index_command.rb +85 -78
  25. data/lib/claude_memory/commands/initializers/database_ensurer.rb +16 -0
  26. data/lib/claude_memory/commands/initializers/global_initializer.rb +2 -1
  27. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +55 -11
  28. data/lib/claude_memory/commands/initializers/project_initializer.rb +2 -1
  29. data/lib/claude_memory/commands/install_skill_command.rb +78 -0
  30. data/lib/claude_memory/commands/registry.rb +3 -1
  31. data/lib/claude_memory/commands/skills/distill-transcripts.md +98 -0
  32. data/lib/claude_memory/commands/skills/memory-recall.md +67 -0
  33. data/lib/claude_memory/core/fact_ranker.rb +2 -2
  34. data/lib/claude_memory/core/rr_fusion.rb +23 -6
  35. data/lib/claude_memory/core/snippet_extractor.rb +7 -3
  36. data/lib/claude_memory/core/text_builder.rb +11 -0
  37. data/lib/claude_memory/domain/provenance.rb +0 -1
  38. data/lib/claude_memory/embeddings/api_adapter.rb +96 -0
  39. data/lib/claude_memory/embeddings/dimension_check.rb +23 -0
  40. data/lib/claude_memory/embeddings/fastembed_adapter.rb +4 -0
  41. data/lib/claude_memory/embeddings/generator.rb +4 -0
  42. data/lib/claude_memory/embeddings/resolver.rb +18 -0
  43. data/lib/claude_memory/hook/context_injector.rb +58 -2
  44. data/lib/claude_memory/hook/distillation_runner.rb +46 -0
  45. data/lib/claude_memory/hook/handler.rb +11 -2
  46. data/lib/claude_memory/index/vector_index.rb +15 -2
  47. data/lib/claude_memory/infrastructure/schema_validator.rb +3 -3
  48. data/lib/claude_memory/mcp/handlers/context_handlers.rb +38 -0
  49. data/lib/claude_memory/mcp/handlers/management_handlers.rb +145 -0
  50. data/lib/claude_memory/mcp/handlers/query_handlers.rb +115 -0
  51. data/lib/claude_memory/mcp/handlers/setup_handlers.rb +211 -0
  52. data/lib/claude_memory/mcp/handlers/shortcut_handlers.rb +37 -0
  53. data/lib/claude_memory/mcp/handlers/stats_handlers.rb +202 -0
  54. data/lib/claude_memory/mcp/instructions_builder.rb +2 -1
  55. data/lib/claude_memory/mcp/query_guide.rb +10 -0
  56. data/lib/claude_memory/mcp/response_formatter.rb +1 -0
  57. data/lib/claude_memory/mcp/text_summary.rb +26 -0
  58. data/lib/claude_memory/mcp/tool_definitions.rb +30 -1
  59. data/lib/claude_memory/mcp/tool_helpers.rb +43 -0
  60. data/lib/claude_memory/mcp/tools.rb +39 -678
  61. data/lib/claude_memory/recall/dual_engine.rb +105 -0
  62. data/lib/claude_memory/recall/legacy_engine.rb +138 -0
  63. data/lib/claude_memory/recall/query_core.rb +371 -0
  64. data/lib/claude_memory/recall.rb +29 -662
  65. data/lib/claude_memory/shortcuts.rb +4 -4
  66. data/lib/claude_memory/store/retry_handler.rb +61 -0
  67. data/lib/claude_memory/store/schema_manager.rb +68 -0
  68. data/lib/claude_memory/store/sqlite_store.rb +85 -201
  69. data/lib/claude_memory/templates/hooks.example.json +26 -7
  70. data/lib/claude_memory/version.rb +1 -1
  71. data/lib/claude_memory.rb +11 -0
  72. metadata +23 -1
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Embeddings
5
+ # Resolves an embedding provider by name or ENV.
6
+ # Three providers: tfidf (default), fastembed, api.
7
+ def self.resolve(name = nil, env: ENV)
8
+ provider = name || env["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] || "tfidf"
9
+
10
+ case provider
11
+ when "tfidf" then Generator.new
12
+ when "fastembed" then FastembedAdapter.new
13
+ when "api" then ApiAdapter.new(env: env)
14
+ else raise ArgumentError, "Unknown embedding provider: #{provider}. Available: tfidf, fastembed, api"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -9,6 +9,10 @@ module ClaudeMemory
9
9
  MAX_DECISIONS = 5
10
10
  MAX_CONVENTIONS = 5
11
11
  MAX_ARCHITECTURE = 5
12
+ MAX_UNDISTILLED = 3
13
+ MAX_TEXT_PER_ITEM = 1500
14
+
15
+ FRESH_SESSION_SOURCES = %w[startup resume clear].freeze
12
16
 
13
17
  QUERIES = {
14
18
  decisions: {query: "decision constraint rule requirement", scope: "all"},
@@ -16,8 +20,9 @@ module ClaudeMemory
16
20
  architecture: {query: "uses framework implements architecture pattern", scope: "all"}
17
21
  }.freeze
18
22
 
19
- def initialize(manager)
23
+ def initialize(manager, source: nil)
20
24
  @manager = manager
25
+ @source = source
21
26
  @recall = Recall.new(manager)
22
27
  end
23
28
 
@@ -33,6 +38,11 @@ module ClaudeMemory
33
38
  architecture = fetch(:architecture, MAX_ARCHITECTURE)
34
39
  sections << format_section("Architecture", architecture) if architecture.any?
35
40
 
41
+ if fresh_session?
42
+ undistilled = fetch_undistilled(MAX_UNDISTILLED)
43
+ sections << format_distillation_prompt(undistilled) if undistilled.any?
44
+ end
45
+
36
46
  return nil if sections.empty?
37
47
 
38
48
  sections.join("\n")
@@ -40,11 +50,16 @@ module ClaudeMemory
40
50
 
41
51
  private
42
52
 
53
+ def fresh_session?
54
+ @source.nil? || FRESH_SESSION_SOURCES.include?(@source)
55
+ end
56
+
43
57
  def fetch(category, limit)
44
58
  config = QUERIES.fetch(category)
45
59
  results = @recall.query(config[:query], limit: limit, scope: config[:scope])
46
60
  results.map { |r| format_fact(r[:fact]) }
47
- rescue => _e
61
+ rescue => e
62
+ ClaudeMemory.logger.debug("ContextInjector#fetch(#{category}) failed: #{e.message}")
48
63
  []
49
64
  end
50
65
 
@@ -62,6 +77,47 @@ module ClaudeMemory
62
77
  end
63
78
  end
64
79
 
80
+ def fetch_undistilled(limit)
81
+ stores = []
82
+ stores << @manager.project_store if @manager.project_store
83
+ stores << @manager.global_store if @manager.global_store
84
+
85
+ items = stores.flat_map { |s|
86
+ s.undistilled_content_items(limit: limit, min_length: 200)
87
+ }
88
+
89
+ items
90
+ .sort_by { |i| i[:occurred_at] || "" }
91
+ .reverse
92
+ .first(limit)
93
+ rescue => e
94
+ ClaudeMemory.logger.warn("ContextInjector#fetch_undistilled failed: #{e.message}")
95
+ []
96
+ end
97
+
98
+ def format_distillation_prompt(items)
99
+ lines = [
100
+ "## Pending Knowledge Extraction",
101
+ "",
102
+ "The following transcript segments haven't been deeply analyzed yet.",
103
+ "Extract facts, entities, and decisions, then call `memory.store_extraction`",
104
+ "followed by `memory.mark_distilled` for each item.",
105
+ "",
106
+ "**What to extract:** technology decisions, conventions, preferences, architecture",
107
+ "**What to skip:** debugging steps, code output, transient errors"
108
+ ]
109
+
110
+ items.each do |item|
111
+ ago = Core::RelativeTime.format(item[:occurred_at]) || "unknown"
112
+ truncated = Core::TextBuilder.truncate(item[:raw_text], MAX_TEXT_PER_ITEM)
113
+ lines << ""
114
+ lines << "### Content Item #{item[:id]} (#{ago})"
115
+ lines << truncated
116
+ end
117
+
118
+ lines.join("\n")
119
+ end
120
+
65
121
  def format_section(title, items)
66
122
  items = items.compact.uniq
67
123
  return nil if items.empty?
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Hook
5
+ class DistillationRunner
6
+ MIN_TEXT_LENGTH = 200
7
+
8
+ def initialize(store, distiller: Distill::NullDistiller.new)
9
+ @store = store
10
+ @distiller = distiller
11
+ end
12
+
13
+ def distill_item(content_id, project_path:, scope: "project")
14
+ item = @store.get_content_item(content_id)
15
+ return unless item
16
+
17
+ raw_text = item[:raw_text]
18
+ return unless raw_text && raw_text.length >= MIN_TEXT_LENGTH
19
+
20
+ extraction = @distiller.distill(raw_text, content_item_id: content_id)
21
+ return if extraction.empty?
22
+
23
+ resolver = Resolve::Resolver.new(@store)
24
+ @store.db.transaction do
25
+ resolve_result = resolver.apply(
26
+ extraction, content_item_id: content_id,
27
+ project_path: project_path, scope: scope
28
+ )
29
+ @store.record_ingestion_metrics(
30
+ content_item_id: content_id, input_tokens: 0,
31
+ output_tokens: 0, facts_extracted: resolve_result[:facts_created]
32
+ )
33
+ end
34
+ rescue => e
35
+ ClaudeMemory.logger.warn("DistillationRunner#distill_item(#{content_id}) failed: #{e.class} - #{e.message}")
36
+ ClaudeMemory.logger.warn(e.backtrace.first(5).join("\n"))
37
+ end
38
+
39
+ def distill_batch(project_path:, limit: 5)
40
+ items = @store.undistilled_content_items(limit: limit, min_length: MIN_TEXT_LENGTH)
41
+ items.each { |item| distill_item(item[:id], project_path: project_path) }
42
+ items.size
43
+ end
44
+ end
45
+ end
46
+ end
@@ -23,12 +23,20 @@ module ClaudeMemory
23
23
  raise PayloadError, "Missing required field: transcript_path" if transcript_path.nil? || transcript_path.empty?
24
24
 
25
25
  ingester = Ingest::Ingester.new(@store, env: @env)
26
- ingester.ingest(
26
+ result = ingester.ingest(
27
27
  source: "claude_code",
28
28
  session_id: session_id,
29
29
  transcript_path: transcript_path,
30
30
  project_path: project_path
31
31
  )
32
+
33
+ if result[:status] == :ingested && result[:content_id]
34
+ DistillationRunner.new(@store).distill_item(
35
+ result[:content_id], project_path: project_path
36
+ )
37
+ end
38
+
39
+ result
32
40
  rescue Ingest::TranscriptReader::FileNotFoundError => e
33
41
  # Transcript file doesn't exist (e.g., headless Claude session)
34
42
  # This is expected, not an error - return success with no-op status
@@ -56,7 +64,8 @@ module ClaudeMemory
56
64
  manager = @manager || build_manager(payload)
57
65
  manager.ensure_both!
58
66
 
59
- injector = ContextInjector.new(manager)
67
+ source = payload["source"]
68
+ injector = ContextInjector.new(manager, source: source)
60
69
  context_text = injector.generate_context
61
70
 
62
71
  {status: :ok, context: context_text}
@@ -6,13 +6,16 @@ module ClaudeMemory
6
6
  # Follows the same lazy-init pattern as LexicalFTS:
7
7
  # the extension and virtual table are created on first use.
8
8
  class VectorIndex
9
- EMBEDDING_DIMENSIONS = 384
9
+ DEFAULT_DIMENSIONS = 384
10
+
11
+ attr_reader :dimensions
10
12
 
11
13
  def initialize(store)
12
14
  @store = store
13
15
  @db = store.db
14
16
  @available = nil
15
17
  @vec_table_ensured = false
18
+ @dimensions = store.get_meta("embedding_dimensions")&.to_i || DEFAULT_DIMENSIONS
16
19
  end
17
20
 
18
21
  # Is the sqlite-vec extension loadable?
@@ -121,6 +124,16 @@ module ClaudeMemory
121
124
  indexed_ids.size
122
125
  end
123
126
 
127
+ # Delete all entries from the vec0 virtual table.
128
+ # Used when clearing stale embeddings after a dimension change.
129
+ def clear!
130
+ return false unless available?
131
+
132
+ ensure_vec_table!
133
+ @db.run("DELETE FROM facts_vec")
134
+ true
135
+ end
136
+
124
137
  # Number of entries in the vec0 virtual table
125
138
  def count
126
139
  return 0 unless available?
@@ -162,7 +175,7 @@ module ClaudeMemory
162
175
 
163
176
  @db.run(<<~SQL)
164
177
  CREATE VIRTUAL TABLE IF NOT EXISTS facts_vec
165
- USING vec0(fact_id INTEGER PRIMARY KEY, embedding float[#{EMBEDDING_DIMENSIONS}] distance_metric=cosine)
178
+ USING vec0(fact_id INTEGER PRIMARY KEY, embedding float[#{@dimensions}] distance_metric=cosine)
166
179
  SQL
167
180
  @vec_table_ensured = true
168
181
  end
@@ -166,7 +166,7 @@ module ClaudeMemory
166
166
  end
167
167
 
168
168
  def check_embedding_dimensions(issues)
169
- # Check that all embeddings have correct dimensions (384)
169
+ expected = @store.get_meta("embedding_dimensions")&.to_i || 384
170
170
  facts_with_embeddings = @store.facts
171
171
  .where(Sequel.~(embedding_json: nil))
172
172
  .select(:id, :embedding_json)
@@ -174,8 +174,8 @@ module ClaudeMemory
174
174
 
175
175
  facts_with_embeddings.each do |fact|
176
176
  embedding = JSON.parse(fact[:embedding_json])
177
- if embedding.size != 384
178
- issues << {severity: "error", message: "Fact #{fact[:id]} has embedding with incorrect dimensions (#{embedding.size}, expected 384)"}
177
+ if embedding.size != expected
178
+ issues << {severity: "error", message: "Fact #{fact[:id]} has embedding with incorrect dimensions (#{embedding.size}, expected #{expected})"}
179
179
  break # Only report first occurrence
180
180
  end
181
181
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ module Handlers
6
+ # Context-aware query handlers (facts by tool, branch, directory)
7
+ module ContextHandlers
8
+ def facts_by_tool(args)
9
+ tool_name = args["tool_name"]
10
+ scope = extract_scope(args)
11
+ limit = extract_limit(args, default: 20)
12
+
13
+ results = @recall.facts_by_tool(tool_name, limit: limit, scope: scope)
14
+ ResponseFormatter.format_tool_facts(tool_name, scope, results)
15
+ end
16
+
17
+ def facts_by_context(args)
18
+ scope = extract_scope(args)
19
+ limit = extract_limit(args, default: 20)
20
+
21
+ if args["git_branch"]
22
+ results = @recall.facts_by_branch(args["git_branch"], limit: limit, scope: scope)
23
+ context_type = "git_branch"
24
+ context_value = args["git_branch"]
25
+ elsif args["cwd"]
26
+ results = @recall.facts_by_directory(args["cwd"], limit: limit, scope: scope)
27
+ context_type = "cwd"
28
+ context_value = args["cwd"]
29
+ else
30
+ return {error: "Must provide either git_branch or cwd parameter"}
31
+ end
32
+
33
+ ResponseFormatter.format_context_facts(context_type, context_value, scope, results)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ module Handlers
6
+ # Management tool handlers (store_extraction, promote, sweep, changes, conflicts)
7
+ module ManagementHandlers
8
+ def store_extraction(args)
9
+ scope = args["scope"] || "project"
10
+ store = get_store_for_scope(scope)
11
+ return {error: "Database not available"} unless store
12
+
13
+ entities = (args["entities"] || []).map { |e| symbolize_keys(e) }
14
+ facts = (args["facts"] || []).map { |f| symbolize_keys(f) }
15
+ decisions = (args["decisions"] || []).map { |d| symbolize_keys(d) }
16
+
17
+ config = Configuration.new
18
+ project_path = config.project_dir
19
+ occurred_at = Time.now.utc.iso8601
20
+
21
+ searchable_text = Core::TextBuilder.build_searchable_text(entities, facts, decisions)
22
+ content_item_id = create_synthetic_content_item(store, searchable_text, project_path, occurred_at)
23
+ index_content_item(store, content_item_id, searchable_text)
24
+
25
+ extraction = Distill::Extraction.new(
26
+ entities: entities,
27
+ facts: facts,
28
+ decisions: decisions,
29
+ signals: []
30
+ )
31
+
32
+ resolver = Resolve::Resolver.new(store)
33
+ result = resolver.apply(
34
+ extraction,
35
+ content_item_id: content_item_id,
36
+ occurred_at: occurred_at,
37
+ project_path: project_path,
38
+ scope: scope
39
+ )
40
+
41
+ {
42
+ success: true,
43
+ scope: scope,
44
+ entities_created: result[:entities_created],
45
+ facts_created: result[:facts_created],
46
+ facts_superseded: result[:facts_superseded],
47
+ conflicts_created: result[:conflicts_created]
48
+ }
49
+ end
50
+
51
+ def promote(args)
52
+ return {error: "Promote requires StoreManager"} unless @manager
53
+
54
+ fact_id = args["fact_id"]
55
+ global_fact_id = @manager.promote_fact(fact_id)
56
+
57
+ if global_fact_id
58
+ {
59
+ success: true,
60
+ project_fact_id: fact_id,
61
+ global_fact_id: global_fact_id,
62
+ message: "Fact promoted to global memory"
63
+ }
64
+ else
65
+ {error: "Fact #{fact_id} not found in project database"}
66
+ end
67
+ end
68
+
69
+ def sweep_now(args)
70
+ scope = args["scope"] || "project"
71
+ store = get_store_for_scope(scope)
72
+ return {error: "Database not available"} unless store
73
+
74
+ sweeper = Sweep::Sweeper.new(store)
75
+ budget = args["budget_seconds"] || 5
76
+ stats = if args["escalate"]
77
+ sweeper.run_with_escalation!(budget_seconds: budget)
78
+ else
79
+ sweeper.run!(budget_seconds: budget)
80
+ end
81
+ ResponseFormatter.format_sweep_stats(scope, stats)
82
+ end
83
+
84
+ def changes(args)
85
+ since = args["since"] || (Time.now - 86400 * 7).utc.iso8601
86
+ scope = args["scope"] || "all"
87
+ list = @recall.changes(since: since, limit: args["limit"] || 20, scope: scope)
88
+ ResponseFormatter.format_changes(since, list)
89
+ end
90
+
91
+ def conflicts(args)
92
+ scope = args["scope"] || "all"
93
+ list = @recall.conflicts(scope: scope)
94
+ ResponseFormatter.format_conflicts(list)
95
+ end
96
+
97
+ def mark_distilled(args)
98
+ content_item_id = args["content_item_id"]
99
+ facts_extracted = args["facts_extracted"] || 0
100
+
101
+ store = find_store_for_content_item(content_item_id)
102
+ return {error: "Content item #{content_item_id} not found"} unless store
103
+
104
+ store.record_ingestion_metrics(
105
+ content_item_id: content_item_id,
106
+ input_tokens: 0,
107
+ output_tokens: 0,
108
+ facts_extracted: facts_extracted
109
+ )
110
+
111
+ {
112
+ success: true,
113
+ content_item_id: content_item_id,
114
+ facts_extracted: facts_extracted
115
+ }
116
+ end
117
+
118
+ private
119
+
120
+ def create_synthetic_content_item(store, text, project_path, occurred_at)
121
+ text_hash = Digest::SHA256.hexdigest(text)
122
+ store.upsert_content_item(
123
+ source: "mcp_extraction",
124
+ session_id: "mcp-#{Time.now.to_i}",
125
+ transcript_path: nil,
126
+ project_path: project_path,
127
+ text_hash: text_hash,
128
+ byte_len: text.bytesize,
129
+ raw_text: text,
130
+ occurred_at: occurred_at
131
+ )
132
+ end
133
+
134
+ def index_content_item(store, content_item_id, text)
135
+ fts = Index::LexicalFTS.new(store)
136
+ fts.index_content_item(content_item_id, text)
137
+ end
138
+
139
+ def symbolize_keys(hash)
140
+ Core::TextBuilder.symbolize_keys(hash)
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ module Handlers
6
+ # Query and recall tool handlers
7
+ module QueryHandlers
8
+ def recall(args)
9
+ return database_not_found_error unless databases_exist?
10
+
11
+ scope = extract_scope(args)
12
+ limit = extract_limit(args)
13
+ compact = args["compact"] == true
14
+ query = args["query"]
15
+ intent = extract_intent(args)
16
+ results = @recall.query(query, limit: limit, scope: scope, include_raw_text: !compact, intent: intent)
17
+ ResponseFormatter.format_recall_results(results, compact: compact, query: query)
18
+ rescue Sequel::DatabaseError, Sequel::DatabaseConnectionError, Errno::ENOENT => e
19
+ classified_error(e, tool_name: "memory.recall")
20
+ end
21
+
22
+ def recall_index(args)
23
+ scope = extract_scope(args)
24
+ limit = extract_limit(args, default: 20)
25
+ intent = extract_intent(args)
26
+ results = @recall.query_index(args["query"], limit: limit, scope: scope, intent: intent)
27
+ ResponseFormatter.format_index_results(args["query"], scope, results)
28
+ end
29
+
30
+ def recall_details(args)
31
+ fact_ids = args["fact_ids"]
32
+ scope = args["scope"] || "project"
33
+
34
+ explanations = fact_ids.map do |fact_id|
35
+ explanation = @recall.explain(fact_id, scope: scope)
36
+ next nil if explanation.is_a?(Core::NullExplanation)
37
+
38
+ ResponseFormatter.format_detailed_explanation(explanation)
39
+ end.compact
40
+
41
+ {
42
+ fact_count: explanations.size,
43
+ facts: explanations
44
+ }
45
+ end
46
+
47
+ def explain(args)
48
+ scope = args["scope"] || "project"
49
+ explanation = @recall.explain(args["fact_id"], scope: scope)
50
+ return {error: "Fact not found in #{scope} database"} if explanation.is_a?(Core::NullExplanation)
51
+
52
+ ResponseFormatter.format_explanation(explanation, scope)
53
+ end
54
+
55
+ def recall_semantic(args)
56
+ query = args["query"]
57
+ mode = (args["mode"] || "both").to_sym
58
+ scope = extract_scope(args)
59
+ limit = extract_limit(args)
60
+ compact = args["compact"] == true
61
+ explain = args["explain"] == true
62
+ intent = extract_intent(args)
63
+
64
+ results = @recall.query_semantic(query, limit: limit, scope: scope, mode: mode, explain: explain, intent: intent)
65
+ ResponseFormatter.format_semantic_results(query, mode.to_s, scope, results, compact: compact)
66
+ end
67
+
68
+ def search_concepts(args)
69
+ concepts = args["concepts"]
70
+ scope = extract_scope(args)
71
+ limit = extract_limit(args)
72
+ compact = args["compact"] == true
73
+
74
+ return {error: "Must provide 2-5 concepts"} unless (2..5).cover?(concepts.size)
75
+
76
+ results = @recall.query_concepts(concepts, limit: limit, scope: scope)
77
+ ResponseFormatter.format_concept_results(concepts, scope, results, compact: compact)
78
+ end
79
+
80
+ def fact_graph(args)
81
+ fact_id = args["fact_id"]
82
+ depth = args["depth"] || 2
83
+ scope = args["scope"] || "project"
84
+
85
+ graph = @recall.fact_graph(fact_id, depth: depth, scope: scope)
86
+
87
+ return {error: "Fact #{fact_id} not found in #{scope} database"} if graph[:node_count] == 0
88
+
89
+ graph
90
+ end
91
+
92
+ def undistilled(args)
93
+ limit = args["limit"] || 3
94
+ min_length = args["min_length"] || 200
95
+ max_text = 2000
96
+
97
+ items = collect_undistilled_items(limit: limit, min_length: min_length)
98
+
99
+ {
100
+ count: items.size,
101
+ items: items.map { |item|
102
+ {
103
+ content_item_id: item[:id],
104
+ occurred_at: item[:occurred_at],
105
+ occurred_ago: Core::RelativeTime.format(item[:occurred_at]),
106
+ project_path: item[:project_path],
107
+ raw_text: Core::TextBuilder.truncate(item[:raw_text], max_text)
108
+ }
109
+ }
110
+ }
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end