claude_memory 0.4.0 → 0.5.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/CLAUDE.md +1 -1
  3. data/.claude/rules/claude_memory.generated.md +14 -1
  4. data/.claude/skills/check-memory/SKILL.md +10 -0
  5. data/.claude/skills/improve/SKILL.md +12 -1
  6. data/.claude-plugin/plugin.json +1 -1
  7. data/CHANGELOG.md +70 -0
  8. data/db/migrations/008_add_provenance_line_range.rb +21 -0
  9. data/db/migrations/009_add_docid.rb +39 -0
  10. data/db/migrations/010_add_llm_cache.rb +30 -0
  11. data/docs/improvements.md +72 -1084
  12. data/docs/influence/claude-supermemory.md +498 -0
  13. data/docs/influence/qmd.md +424 -2022
  14. data/docs/quality_review.md +64 -705
  15. data/lib/claude_memory/commands/doctor_command.rb +45 -4
  16. data/lib/claude_memory/commands/explain_command.rb +11 -6
  17. data/lib/claude_memory/commands/stats_command.rb +1 -1
  18. data/lib/claude_memory/core/fact_graph.rb +122 -0
  19. data/lib/claude_memory/core/fact_query_builder.rb +34 -14
  20. data/lib/claude_memory/core/fact_ranker.rb +3 -20
  21. data/lib/claude_memory/core/relative_time.rb +45 -0
  22. data/lib/claude_memory/core/result_sorter.rb +2 -2
  23. data/lib/claude_memory/core/rr_fusion.rb +57 -0
  24. data/lib/claude_memory/core/snippet_extractor.rb +97 -0
  25. data/lib/claude_memory/domain/fact.rb +3 -1
  26. data/lib/claude_memory/index/index_query.rb +2 -0
  27. data/lib/claude_memory/index/lexical_fts.rb +18 -0
  28. data/lib/claude_memory/infrastructure/operation_tracker.rb +7 -21
  29. data/lib/claude_memory/infrastructure/schema_validator.rb +30 -25
  30. data/lib/claude_memory/ingest/content_sanitizer.rb +8 -1
  31. data/lib/claude_memory/ingest/ingester.rb +67 -56
  32. data/lib/claude_memory/ingest/tool_extractor.rb +1 -1
  33. data/lib/claude_memory/ingest/tool_filter.rb +55 -0
  34. data/lib/claude_memory/logging/logger.rb +112 -0
  35. data/lib/claude_memory/mcp/query_guide.rb +96 -0
  36. data/lib/claude_memory/mcp/response_formatter.rb +86 -23
  37. data/lib/claude_memory/mcp/server.rb +34 -4
  38. data/lib/claude_memory/mcp/text_summary.rb +257 -0
  39. data/lib/claude_memory/mcp/tool_definitions.rb +20 -4
  40. data/lib/claude_memory/mcp/tools.rb +133 -120
  41. data/lib/claude_memory/publish.rb +12 -2
  42. data/lib/claude_memory/recall/expansion_detector.rb +44 -0
  43. data/lib/claude_memory/recall.rb +93 -41
  44. data/lib/claude_memory/resolve/resolver.rb +72 -40
  45. data/lib/claude_memory/store/sqlite_store.rb +99 -24
  46. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  47. data/lib/claude_memory/version.rb +1 -1
  48. data/lib/claude_memory.rb +21 -0
  49. metadata +14 -2
  50. data/docs/remaining_improvements.md +0 -330
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../core/relative_time"
4
+ require_relative "../core/snippet_extractor"
5
+
3
6
  module ClaudeMemory
4
7
  module MCP
5
8
  # Pure logic for formatting domain objects into MCP tool responses
@@ -7,26 +10,32 @@ module ClaudeMemory
7
10
  class ResponseFormatter
8
11
  # Format recall query results into MCP response
9
12
  # @param results [Array<Hash>] Recall results with :fact and :receipts
13
+ # @param compact [Boolean] Omit receipts for smaller responses
14
+ # @param query [String, nil] Original query for snippet extraction
10
15
  # @return [Hash] MCP response with facts array
11
- def self.format_recall_results(results)
16
+ def self.format_recall_results(results, compact: false, query: nil)
12
17
  {
13
- facts: results.map { |r| format_recall_fact(r) }
18
+ facts: results.map { |r| format_recall_fact(r, compact: compact, query: query) }
14
19
  }
15
20
  end
16
21
 
17
22
  # Format single recall fact result
18
23
  # @param result [Hash] Single result with :fact, :receipts, :source
24
+ # @param compact [Boolean] Omit receipts
25
+ # @param query [String, nil] Original query for snippet extraction
19
26
  # @return [Hash] Formatted fact for MCP response
20
- def self.format_recall_fact(result)
21
- {
27
+ def self.format_recall_fact(result, compact: false, query: nil)
28
+ fact = {
22
29
  id: result[:fact][:id],
30
+ docid: result[:fact][:docid],
23
31
  subject: result[:fact][:subject_name],
24
32
  predicate: result[:fact][:predicate],
25
33
  object: result[:fact][:object_literal],
26
34
  status: result[:fact][:status],
27
- source: result[:source],
28
- receipts: result[:receipts].map { |p| format_receipt(p) }
35
+ source: result[:source]
29
36
  }
37
+ fact[:receipts] = result[:receipts].map { |p| format_receipt(p, query: query) } unless compact
38
+ fact
30
39
  end
31
40
 
32
41
  # Format index query results with token estimates
@@ -52,6 +61,7 @@ module ClaudeMemory
52
61
  def self.format_index_fact(result)
53
62
  {
54
63
  id: result[:id],
64
+ docid: result[:docid],
55
65
  subject: result[:subject],
56
66
  predicate: result[:predicate],
57
67
  object_preview: result[:object_preview],
@@ -71,11 +81,13 @@ module ClaudeMemory
71
81
  {
72
82
  fact: {
73
83
  id: explanation[:fact][:id],
84
+ docid: explanation[:fact][:docid],
74
85
  subject: explanation[:fact][:subject_name],
75
86
  predicate: explanation[:fact][:predicate],
76
87
  object: explanation[:fact][:object_literal],
77
88
  status: explanation[:fact][:status],
78
89
  valid_from: explanation[:fact][:valid_from],
90
+ valid_from_ago: Core::RelativeTime.format(explanation[:fact][:valid_from]),
79
91
  valid_to: explanation[:fact][:valid_to]
80
92
  },
81
93
  source: scope,
@@ -93,6 +105,7 @@ module ClaudeMemory
93
105
  {
94
106
  fact: {
95
107
  id: explanation[:fact][:id],
108
+ docid: explanation[:fact][:docid],
96
109
  subject: explanation[:fact][:subject_name],
97
110
  predicate: explanation[:fact][:predicate],
98
111
  object: explanation[:fact][:object_literal],
@@ -100,6 +113,7 @@ module ClaudeMemory
100
113
  confidence: explanation[:fact][:confidence],
101
114
  scope: explanation[:fact][:scope],
102
115
  valid_from: explanation[:fact][:valid_from],
116
+ valid_from_ago: Core::RelativeTime.format(explanation[:fact][:valid_from]),
103
117
  valid_to: explanation[:fact][:valid_to]
104
118
  },
105
119
  receipts: explanation[:receipts].map { |r| format_detailed_receipt(r) },
@@ -113,21 +127,55 @@ module ClaudeMemory
113
127
 
114
128
  # Format receipt (provenance) with minimal fields
115
129
  # @param receipt [Hash] Receipt with :quote and :strength
130
+ # @param query [String, nil] Optional query for snippet extraction
116
131
  # @return [Hash] Formatted receipt
117
- def self.format_receipt(receipt)
118
- {quote: receipt[:quote], strength: receipt[:strength]}
132
+ def self.format_receipt(receipt, query: nil)
133
+ result = {quote: receipt[:quote], strength: receipt[:strength]}
134
+ append_line_range(result, receipt)
135
+ append_snippet(result, receipt, query)
136
+ result
119
137
  end
120
138
 
121
139
  # Format detailed receipt with session and timestamp
122
140
  # @param receipt [Hash] Receipt with full fields
141
+ # @param query [String, nil] Optional query for snippet extraction
123
142
  # @return [Hash] Formatted detailed receipt
124
- def self.format_detailed_receipt(receipt)
125
- {
143
+ def self.format_detailed_receipt(receipt, query: nil)
144
+ result = {
126
145
  quote: receipt[:quote],
127
146
  strength: receipt[:strength],
128
147
  session_id: receipt[:session_id],
129
- occurred_at: receipt[:occurred_at]
148
+ occurred_at: receipt[:occurred_at],
149
+ occurred_ago: Core::RelativeTime.format(receipt[:occurred_at])
130
150
  }
151
+ append_line_range(result, receipt)
152
+ append_snippet(result, receipt, query)
153
+ result
154
+ end
155
+
156
+ # Append stored line range from provenance if available
157
+ # @param result [Hash] Result hash to append to
158
+ # @param receipt [Hash] Receipt with optional :line_start, :line_end
159
+ def self.append_line_range(result, receipt)
160
+ return unless receipt[:line_start]
161
+
162
+ result[:line_start] = receipt[:line_start]
163
+ result[:line_end] = receipt[:line_end]
164
+ end
165
+
166
+ # Extract and append snippet from raw_text if available
167
+ # @param result [Hash] Result hash to append to
168
+ # @param receipt [Hash] Receipt with optional :raw_text
169
+ # @param query [String, nil] Query for snippet extraction
170
+ def self.append_snippet(result, receipt, query)
171
+ return unless query && receipt[:raw_text]
172
+
173
+ extracted = Core::SnippetExtractor.extract_with_lines(receipt[:raw_text], query)
174
+ return unless extracted
175
+
176
+ result[:snippet] = extracted[:snippet]
177
+ result[:line_start] = extracted[:line_start]
178
+ result[:line_end] = extracted[:line_end]
131
179
  end
132
180
 
133
181
  # Format changes list into MCP response
@@ -147,10 +195,12 @@ module ClaudeMemory
147
195
  def self.format_change(change)
148
196
  {
149
197
  id: change[:id],
198
+ docid: change[:docid],
150
199
  predicate: change[:predicate],
151
200
  object: change[:object_literal],
152
201
  status: change[:status],
153
202
  created_at: change[:created_at],
203
+ created_ago: Core::RelativeTime.format(change[:created_at]),
154
204
  source: change[:source]
155
205
  }
156
206
  end
@@ -198,62 +248,73 @@ module ClaudeMemory
198
248
  # @param mode [String] Search mode (vector, text, both)
199
249
  # @param scope [String] Scope
200
250
  # @param results [Array<Hash>] Results with similarity scores
251
+ # @param compact [Boolean] Omit receipts for smaller responses
201
252
  # @return [Hash] Formatted semantic search response
202
- def self.format_semantic_results(query, mode, scope, results)
253
+ def self.format_semantic_results(query, mode, scope, results, compact: false)
203
254
  {
204
255
  query: query,
205
256
  mode: mode,
206
257
  scope: scope,
207
258
  count: results.size,
208
- facts: results.map { |r| format_semantic_fact(r) }
259
+ facts: results.map { |r| format_semantic_fact(r, compact: compact, query: query) }
209
260
  }
210
261
  end
211
262
 
212
263
  # Format single semantic search fact with similarity
213
264
  # @param result [Hash] Result with fact, receipts, and similarity
265
+ # @param compact [Boolean] Omit receipts
266
+ # @param query [String, nil] Original query for snippet extraction
214
267
  # @return [Hash] Formatted fact with similarity
215
- def self.format_semantic_fact(result)
216
- {
268
+ def self.format_semantic_fact(result, compact: false, query: nil)
269
+ fact = {
217
270
  id: result[:fact][:id],
271
+ docid: result[:fact][:docid],
218
272
  subject: result[:fact][:subject_name],
219
273
  predicate: result[:fact][:predicate],
220
274
  object: result[:fact][:object_literal],
221
275
  scope: result[:fact][:scope],
222
276
  source: result[:source],
223
- similarity: result[:similarity],
224
- receipts: result[:receipts].map { |r| format_receipt(r) }
277
+ similarity: result[:similarity]
225
278
  }
279
+ fact[:receipts] = result[:receipts].map { |r| format_receipt(r, query: query) } unless compact
280
+ fact
226
281
  end
227
282
 
228
283
  # Format concept search results
229
284
  # @param concepts [Array<String>] Concepts searched
230
285
  # @param scope [String] Scope
231
286
  # @param results [Array<Hash>] Results with similarity scores
287
+ # @param compact [Boolean] Omit receipts for smaller responses
232
288
  # @return [Hash] Formatted concept search response
233
- def self.format_concept_results(concepts, scope, results)
289
+ def self.format_concept_results(concepts, scope, results, compact: false)
290
+ query = concepts.join(" ")
234
291
  {
235
292
  concepts: concepts,
236
293
  scope: scope,
237
294
  count: results.size,
238
- facts: results.map { |r| format_concept_fact(r) }
295
+ facts: results.map { |r| format_concept_fact(r, compact: compact, query: query) }
239
296
  }
240
297
  end
241
298
 
242
299
  # Format single concept search fact with multi-concept similarity
243
300
  # @param result [Hash] Result with average and per-concept similarities
301
+ # @param compact [Boolean] Omit receipts
302
+ # @param query [String, nil] Original query for snippet extraction
244
303
  # @return [Hash] Formatted fact with concept similarities
245
- def self.format_concept_fact(result)
246
- {
304
+ def self.format_concept_fact(result, compact: false, query: nil)
305
+ fact = {
247
306
  id: result[:fact][:id],
307
+ docid: result[:fact][:docid],
248
308
  subject: result[:fact][:subject_name],
249
309
  predicate: result[:fact][:predicate],
250
310
  object: result[:fact][:object_literal],
251
311
  scope: result[:fact][:scope],
252
312
  source: result[:source],
253
313
  average_similarity: result[:similarity],
254
- concept_similarities: result[:concept_similarities],
255
- receipts: result[:receipts].map { |r| format_receipt(r) }
314
+ concept_similarities: result[:concept_similarities]
256
315
  }
316
+ fact[:receipts] = result[:receipts].map { |r| format_receipt(r, query: query) } unless compact
317
+ fact
257
318
  end
258
319
 
259
320
  # Format shortcut query results (decisions, architecture, etc.)
@@ -274,6 +335,7 @@ module ClaudeMemory
274
335
  def self.format_shortcut_fact(result)
275
336
  {
276
337
  id: result[:fact][:id],
338
+ docid: result[:fact][:docid],
277
339
  subject: result[:fact][:subject_name],
278
340
  predicate: result[:fact][:predicate],
279
341
  object: result[:fact][:object_literal],
@@ -318,6 +380,7 @@ module ClaudeMemory
318
380
  def self.format_generic_fact(result)
319
381
  {
320
382
  id: result[:fact][:id],
383
+ docid: result[:fact][:docid],
321
384
  subject: result[:fact][:subject_name],
322
385
  predicate: result[:fact][:predicate],
323
386
  object: result[:fact][:object_literal],
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "query_guide"
5
+ require_relative "text_summary"
4
6
 
5
7
  module ClaudeMemory
6
8
  module MCP
@@ -58,6 +60,10 @@ module ClaudeMemory
58
60
  handle_tools_list(id)
59
61
  when "tools/call"
60
62
  handle_tools_call(id, request["params"])
63
+ when "prompts/list"
64
+ handle_prompts_list(id)
65
+ when "prompts/get"
66
+ handle_prompts_get(id, request["params"])
61
67
  when "shutdown"
62
68
  @running = false
63
69
  {jsonrpc: "2.0", id: id, result: nil}
@@ -73,7 +79,8 @@ module ClaudeMemory
73
79
  result: {
74
80
  protocolVersion: PROTOCOL_VERSION,
75
81
  capabilities: {
76
- tools: {}
82
+ tools: {},
83
+ prompts: {}
77
84
  },
78
85
  serverInfo: {
79
86
  name: "claude-memory",
@@ -104,17 +111,40 @@ module ClaudeMemory
104
111
  # Connections are automatically reopened on next use
105
112
  release_connections
106
113
 
114
+ text_summary = TextSummary.for_tool(name, result)
115
+
107
116
  {
108
117
  jsonrpc: "2.0",
109
118
  id: id,
110
119
  result: {
111
120
  content: [
112
- {type: "text", text: JSON.generate(result)}
113
- ]
121
+ {type: "text", text: text_summary}
122
+ ],
123
+ structuredContent: result
124
+ }
125
+ }
126
+ end
127
+
128
+ def handle_prompts_list(id)
129
+ {
130
+ jsonrpc: "2.0",
131
+ id: id,
132
+ result: {
133
+ prompts: [QueryGuide.definition]
114
134
  }
115
135
  }
116
136
  end
117
137
 
138
+ def handle_prompts_get(id, params)
139
+ name = params&.dig("name")
140
+
141
+ if name == QueryGuide::PROMPT_NAME
142
+ {jsonrpc: "2.0", id: id, result: QueryGuide.content}
143
+ else
144
+ {jsonrpc: "2.0", id: id, error: {code: -32602, message: "Unknown prompt: #{name}"}}
145
+ end
146
+ end
147
+
118
148
  def release_connections
119
149
  if @store_or_manager.is_a?(Store::StoreManager)
120
150
  # Release both global and project store connections
@@ -124,7 +154,7 @@ module ClaudeMemory
124
154
  # Release single store connection (legacy)
125
155
  @store_or_manager.db.disconnect
126
156
  end
127
- rescue
157
+ rescue Sequel::DatabaseError, Extralite::Error
128
158
  # Silently ignore disconnect errors
129
159
  # Connection will be reopened automatically on next use
130
160
  end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ # Generates human-readable text summaries from MCP tool results.
6
+ # Used alongside structuredContent for dual content/structuredContent pattern.
7
+ module TextSummary
8
+ def self.for_tool(name, result)
9
+ return result[:error] if result[:error]
10
+
11
+ case name
12
+ when "memory.recall" then summarize_recall(result)
13
+ when "memory.recall_index" then summarize_recall_index(result)
14
+ when "memory.recall_details" then summarize_recall_details(result)
15
+ when "memory.explain" then summarize_explain(result)
16
+ when "memory.changes" then summarize_changes(result)
17
+ when "memory.conflicts" then summarize_conflicts(result)
18
+ when "memory.sweep_now" then summarize_sweep(result)
19
+ when "memory.status" then summarize_status(result)
20
+ when "memory.stats" then summarize_stats(result)
21
+ when "memory.promote" then summarize_promote(result)
22
+ when "memory.store_extraction" then summarize_extraction(result)
23
+ when "memory.decisions" then summarize_shortcut(result)
24
+ when "memory.conventions" then summarize_shortcut(result)
25
+ when "memory.architecture" then summarize_shortcut(result)
26
+ when "memory.facts_by_tool" then summarize_tool_facts(result)
27
+ when "memory.facts_by_context" then summarize_context_facts(result)
28
+ when "memory.recall_semantic" then summarize_semantic(result)
29
+ when "memory.search_concepts" then summarize_concepts(result)
30
+ when "memory.fact_graph" then summarize_fact_graph(result)
31
+ when "memory.check_setup" then summarize_check_setup(result)
32
+ else JSON.generate(result)
33
+ end
34
+ end
35
+
36
+ def self.summarize_recall(result)
37
+ facts = result[:facts] || []
38
+ return "No facts found." if facts.empty?
39
+
40
+ lines = ["Found #{facts.size} fact(s):"]
41
+ facts.each do |f|
42
+ lines << "- [#{fact_label(f)}] #{f[:subject]}.#{f[:predicate]} = #{f[:object]}"
43
+ end
44
+ lines.join("\n")
45
+ end
46
+
47
+ def self.summarize_recall_index(result)
48
+ facts = result[:facts] || []
49
+ return "No matching facts for '#{result[:query]}'." if facts.empty?
50
+
51
+ lines = ["#{result[:result_count]} fact(s) matching '#{result[:query]}' (~#{result[:total_estimated_tokens]} tokens):"]
52
+ facts.each do |f|
53
+ lines << "- [#{fact_label(f)}] #{f[:subject]}.#{f[:predicate]}: #{f[:object_preview]} (#{f[:tokens]}t)"
54
+ end
55
+ lines.join("\n")
56
+ end
57
+
58
+ def self.summarize_recall_details(result)
59
+ facts = result[:facts] || []
60
+ return "No facts found for given IDs." if facts.empty?
61
+
62
+ lines = ["#{result[:fact_count]} fact(s):"]
63
+ facts.each do |f|
64
+ fact = f[:fact]
65
+ lines << "- [#{fact_label(fact)}] #{fact[:subject]}.#{fact[:predicate]} = #{fact[:object]} (#{fact[:status]}, #{fact[:scope]})"
66
+ end
67
+ lines.join("\n")
68
+ end
69
+
70
+ def self.summarize_explain(result)
71
+ f = result[:fact]
72
+ lines = ["Fact [#{fact_label(f)}]: #{f[:subject]}.#{f[:predicate]} = #{f[:object]}"]
73
+ lines << "Status: #{f[:status]}, valid from: #{f[:valid_from_ago] || f[:valid_from] || "unknown"}"
74
+ lines << "Source: #{result[:source]}"
75
+
76
+ receipts = result[:receipts] || []
77
+ if receipts.any?
78
+ lines << "Evidence: #{receipts.map { |r| r[:quote] }.join("; ")}"
79
+ end
80
+
81
+ lines.join("\n")
82
+ end
83
+
84
+ def self.summarize_changes(result)
85
+ changes = result[:changes] || []
86
+ return "No changes since #{result[:since]}." if changes.empty?
87
+
88
+ lines = ["#{changes.size} change(s) since #{result[:since]}:"]
89
+ changes.each do |c|
90
+ ago = c[:created_ago] ? " (#{c[:created_ago]})" : ""
91
+ lines << "- [#{fact_label(c)}] #{c[:predicate]}: #{c[:object]} [#{c[:status]}]#{ago}"
92
+ end
93
+ lines.join("\n")
94
+ end
95
+
96
+ def self.summarize_conflicts(result)
97
+ return "No open conflicts." if result[:count] == 0
98
+
99
+ lines = ["#{result[:count]} conflict(s):"]
100
+ (result[:conflicts] || []).each do |c|
101
+ lines << "- Conflict ##{c[:id]}: fact #{c[:fact_a]} vs fact #{c[:fact_b]} (#{c[:status]})"
102
+ end
103
+ lines.join("\n")
104
+ end
105
+
106
+ def self.summarize_sweep(result)
107
+ "Sweep (#{result[:scope]}): #{result[:proposed_expired]} proposed expired, " \
108
+ "#{result[:disputed_expired]} disputed expired, " \
109
+ "#{result[:orphaned_deleted]} orphaned deleted, " \
110
+ "#{result[:content_pruned]} content pruned " \
111
+ "(#{result[:elapsed_seconds]}s)"
112
+ end
113
+
114
+ def self.summarize_status(result)
115
+ dbs = result[:databases] || {}
116
+ lines = ["Memory status:"]
117
+ dbs.each do |name, info|
118
+ lines << if info[:exists] == false
119
+ "- #{name}: not initialized"
120
+ else
121
+ "- #{name}: #{info[:facts_active]} active facts, #{info[:open_conflicts]} conflicts (schema v#{info[:schema_version]})"
122
+ end
123
+ end
124
+ lines.join("\n")
125
+ end
126
+
127
+ def self.summarize_stats(result)
128
+ dbs = result[:databases] || {}
129
+ lines = ["Stats (scope: #{result[:scope]}):"]
130
+ dbs.each do |name, info|
131
+ if info[:exists] == false
132
+ lines << "- #{name}: not initialized"
133
+ else
134
+ facts = info[:facts] || {}
135
+ lines << "- #{name}: #{facts[:active]}/#{facts[:total]} active facts, #{info[:entities]&.dig(:total) || 0} entities"
136
+ end
137
+ end
138
+ lines.join("\n")
139
+ end
140
+
141
+ def self.summarize_promote(result)
142
+ if result[:success]
143
+ "Fact #{result[:project_fact_id]} promoted to global (new ID: #{result[:global_fact_id]})"
144
+ else
145
+ result[:error]
146
+ end
147
+ end
148
+
149
+ def self.summarize_extraction(result)
150
+ if result[:success]
151
+ "Stored: #{result[:facts_created]} facts, #{result[:entities_created]} entities, " \
152
+ "#{result[:facts_superseded]} superseded, #{result[:conflicts_created]} conflicts"
153
+ else
154
+ result[:error]
155
+ end
156
+ end
157
+
158
+ def self.summarize_shortcut(result)
159
+ facts = result[:facts] || []
160
+ return "No #{result[:category]} found." if facts.empty?
161
+
162
+ lines = ["#{result[:count]} #{result[:category]}:"]
163
+ facts.each do |f|
164
+ lines << "- [#{fact_label(f)}] #{f[:object]}"
165
+ end
166
+ lines.join("\n")
167
+ end
168
+
169
+ def self.summarize_tool_facts(result)
170
+ facts = result[:facts] || []
171
+ return "No facts discovered via #{result[:tool_name]}." if facts.empty?
172
+
173
+ lines = ["#{result[:count]} fact(s) from #{result[:tool_name]}:"]
174
+ facts.each do |f|
175
+ lines << "- [#{fact_label(f)}] #{f[:subject]}.#{f[:predicate]} = #{f[:object]}"
176
+ end
177
+ lines.join("\n")
178
+ end
179
+
180
+ def self.summarize_context_facts(result)
181
+ facts = result[:facts] || []
182
+ return "No facts for #{result[:context_type]}=#{result[:context_value]}." if facts.empty?
183
+
184
+ lines = ["#{result[:count]} fact(s) for #{result[:context_type]}=#{result[:context_value]}:"]
185
+ facts.each do |f|
186
+ lines << "- [#{fact_label(f)}] #{f[:subject]}.#{f[:predicate]} = #{f[:object]}"
187
+ end
188
+ lines.join("\n")
189
+ end
190
+
191
+ def self.summarize_semantic(result)
192
+ facts = result[:facts] || []
193
+ return "No semantic matches for '#{result[:query]}'." if facts.empty?
194
+
195
+ lines = ["#{result[:count]} match(es) for '#{result[:query]}' (#{result[:mode]}):"]
196
+ facts.each do |f|
197
+ sim = f[:similarity] ? " (#{(f[:similarity] * 100).round}%)" : ""
198
+ lines << "- [#{fact_label(f)}] #{f[:subject]}.#{f[:predicate]} = #{f[:object]}#{sim}"
199
+ end
200
+ lines.join("\n")
201
+ end
202
+
203
+ def self.summarize_concepts(result)
204
+ facts = result[:facts] || []
205
+ concepts_str = (result[:concepts] || []).join(" + ")
206
+ return "No facts matching all concepts: #{concepts_str}." if facts.empty?
207
+
208
+ lines = ["#{result[:count]} fact(s) matching #{concepts_str}:"]
209
+ facts.each do |f|
210
+ sim = f[:average_similarity] ? " (#{(f[:average_similarity] * 100).round}%)" : ""
211
+ lines << "- [#{fact_label(f)}] #{f[:subject]}.#{f[:predicate]} = #{f[:object]}#{sim}"
212
+ end
213
+ lines.join("\n")
214
+ end
215
+
216
+ def self.summarize_fact_graph(result)
217
+ nodes = result[:nodes] || []
218
+ edges = result[:edges] || []
219
+ return "Fact #{result[:root_fact_id]} not found." if nodes.empty?
220
+
221
+ lines = ["Graph for fact ##{result[:root_fact_id]} (depth #{result[:depth]}): #{result[:node_count]} nodes, #{result[:edge_count]} edges"]
222
+ nodes.each do |n|
223
+ label = n[:docid] || n[:id]
224
+ lines << "- [#{label}] #{n[:subject]}.#{n[:predicate]} = #{n[:object]} (#{n[:status]})"
225
+ end
226
+ if edges.any?
227
+ lines << "Edges:"
228
+ edges.each do |e|
229
+ lines << " #{e[:from]} --#{e[:type]}--> #{e[:to]}"
230
+ end
231
+ end
232
+ lines.join("\n")
233
+ end
234
+
235
+ def self.summarize_check_setup(result)
236
+ lines = ["Setup status: #{result[:status]}"]
237
+ lines << "Version: #{result[:version][:current]} (latest: #{result[:version][:latest]})"
238
+
239
+ components = result[:components] || {}
240
+ lines << "Components: global_db=#{components[:global_database]}, project_db=#{components[:project_database]}, hooks=#{components[:hooks_configured]}"
241
+
242
+ issues = result[:issues] || []
243
+ issues.each { |i| lines << "Issue: #{i}" }
244
+
245
+ warnings = result[:warnings] || []
246
+ warnings.each { |w| lines << "Warning: #{w}" }
247
+
248
+ lines.join("\n")
249
+ end
250
+
251
+ # Format fact identifier: prefer docid if available, fall back to integer id
252
+ def self.fact_label(fact)
253
+ fact[:docid] || fact[:id]
254
+ end
255
+ end
256
+ end
257
+ end
@@ -17,7 +17,8 @@ module ClaudeMemory
17
17
  properties: {
18
18
  query: {type: "string", description: "Search query for existing knowledge (e.g., 'authentication flow', 'error handling', 'database setup')"},
19
19
  limit: {type: "integer", description: "Max results", default: 10},
20
- scope: {type: "string", enum: ["all", "global", "project"], description: "Filter by scope: 'all' (default), 'global', or 'project'", default: "all"}
20
+ scope: {type: "string", enum: ["all", "global", "project"], description: "Filter by scope: 'all' (default), 'global', or 'project'", default: "all"},
21
+ compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses", default: false}
21
22
  },
22
23
  required: ["query"]
23
24
  }
@@ -53,7 +54,7 @@ module ClaudeMemory
53
54
  inputSchema: {
54
55
  type: "object",
55
56
  properties: {
56
- fact_id: {type: "integer", description: "Fact ID to explain"},
57
+ fact_id: {description: "Fact ID (integer) or docid (8-char hex string) to explain"},
57
58
  scope: {type: "string", enum: ["global", "project"], description: "Which database to look in", default: "project"}
58
59
  },
59
60
  required: ["fact_id"]
@@ -240,7 +241,8 @@ module ClaudeMemory
240
241
  query: {type: "string", description: "Search query"},
241
242
  mode: {type: "string", enum: ["vector", "text", "both"], default: "both", description: "Search mode: vector (embeddings), text (FTS), or both (hybrid)"},
242
243
  limit: {type: "integer", default: 10, description: "Maximum results to return"},
243
- scope: {type: "string", enum: ["all", "global", "project"], default: "all", description: "Filter by scope"}
244
+ scope: {type: "string", enum: ["all", "global", "project"], default: "all", description: "Filter by scope"},
245
+ compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses", default: false}
244
246
  },
245
247
  required: ["query"]
246
248
  }
@@ -259,11 +261,25 @@ module ClaudeMemory
259
261
  description: "2-5 concepts that must all be present"
260
262
  },
261
263
  limit: {type: "integer", default: 10, description: "Maximum results to return"},
262
- scope: {type: "string", enum: ["all", "global", "project"], default: "all", description: "Filter by scope"}
264
+ scope: {type: "string", enum: ["all", "global", "project"], default: "all", description: "Filter by scope"},
265
+ compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses", default: false}
263
266
  },
264
267
  required: ["concepts"]
265
268
  }
266
269
  },
270
+ {
271
+ name: "memory.fact_graph",
272
+ description: "Build a dependency graph showing how facts relate through supersession and conflict links. Returns nodes (facts) and edges (supersedes/conflicts).",
273
+ inputSchema: {
274
+ type: "object",
275
+ properties: {
276
+ fact_id: {type: "integer", description: "Root fact ID to start traversal from"},
277
+ depth: {type: "integer", description: "Maximum BFS traversal depth (1-5)", default: 2},
278
+ scope: {type: "string", enum: ["global", "project"], description: "Which database to search", default: "project"}
279
+ },
280
+ required: ["fact_id"]
281
+ }
282
+ },
267
283
  {
268
284
  name: "memory.check_setup",
269
285
  description: "Check ClaudeMemory initialization status. Returns version info, issues found, and recommendations.",