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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ module Handlers
6
+ # Setup and discovery tool handlers
7
+ module SetupHandlers
8
+ def check_setup
9
+ issues = []
10
+ warnings = []
11
+ config = Configuration.new
12
+
13
+ global_db_exists = check_global_database(config, issues)
14
+ project_db_exists = check_project_database(config, warnings)
15
+ current_version, version_status, claude_md_exists = check_claude_md_version(warnings)
16
+ hooks_configured = check_hooks_configuration(warnings)
17
+
18
+ build_setup_result(
19
+ global_db_exists, project_db_exists, claude_md_exists,
20
+ hooks_configured, current_version, version_status,
21
+ issues, warnings
22
+ )
23
+ end
24
+
25
+ def list_projects
26
+ result = {global: nil, current_project: nil, other_projects: []}
27
+
28
+ if @manager
29
+ result[:global] = list_global_database
30
+ result[:current_project] = list_current_project
31
+ result[:other_projects] = discover_other_projects
32
+ elsif @legacy_store
33
+ result[:global] = {
34
+ exists: true,
35
+ path: @legacy_store.db.opts[:database],
36
+ facts_active: @legacy_store.facts.where(status: "active").count,
37
+ entities: @legacy_store.entities.count
38
+ }
39
+ end
40
+
41
+ result[:project_count] = 1 + result[:other_projects].size
42
+ result
43
+ end
44
+
45
+ private
46
+
47
+ def check_global_database(config, issues)
48
+ exists = File.exist?(config.global_db_path)
49
+ issues << "Global database not found at #{config.global_db_path}" unless exists
50
+ exists
51
+ end
52
+
53
+ def check_project_database(config, warnings)
54
+ exists = File.exist?(config.project_db_path)
55
+ warnings << "Project database not found at #{config.project_db_path}" unless exists
56
+ exists
57
+ end
58
+
59
+ def check_claude_md_version(warnings)
60
+ claude_md_path = ".claude/CLAUDE.md"
61
+ unless File.exist?(claude_md_path)
62
+ warnings << "No .claude/CLAUDE.md found"
63
+ return [nil, nil, false]
64
+ end
65
+
66
+ content = File.read(claude_md_path)
67
+ unless content.include?("ClaudeMemory")
68
+ warnings << "CLAUDE.md exists but no ClaudeMemory configuration found"
69
+ return [nil, nil, true]
70
+ end
71
+
72
+ current_version = SetupStatusAnalyzer.extract_version(content)
73
+ unless current_version
74
+ warnings << "CLAUDE.md has ClaudeMemory section but no version marker"
75
+ return [nil, "no_version_marker", true]
76
+ end
77
+
78
+ version_status = SetupStatusAnalyzer.determine_version_status(current_version, ClaudeMemory::VERSION)
79
+ if version_status == "outdated"
80
+ warnings << "Configuration version (v#{current_version}) is older than ClaudeMemory (v#{ClaudeMemory::VERSION}). Consider running upgrade."
81
+ end
82
+
83
+ [current_version, version_status, true]
84
+ end
85
+
86
+ def check_hooks_configuration(warnings)
87
+ settings_paths = [".claude/settings.json", ".claude/settings.local.json"]
88
+ settings_paths.each do |path|
89
+ next unless File.exist?(path)
90
+ begin
91
+ config_data = JSON.parse(File.read(path))
92
+ return true if config_data["hooks"]&.any?
93
+ rescue JSON::ParserError
94
+ warnings << "Invalid JSON in #{path}"
95
+ end
96
+ end
97
+
98
+ warnings << "No hooks configured for automatic ingestion"
99
+ false
100
+ end
101
+
102
+ def build_setup_result(global_db_exists, project_db_exists, claude_md_exists, hooks_configured, current_version, version_status, issues, warnings)
103
+ initialized = global_db_exists && claude_md_exists
104
+ status = SetupStatusAnalyzer.determine_status(global_db_exists, claude_md_exists, version_status)
105
+ recommendations = SetupStatusAnalyzer.generate_recommendations(initialized, version_status, warnings.any?)
106
+
107
+ {
108
+ status: status,
109
+ initialized: initialized,
110
+ version: {
111
+ current: current_version || "unknown",
112
+ latest: ClaudeMemory::VERSION,
113
+ status: version_status || "unknown"
114
+ },
115
+ components: {
116
+ global_database: global_db_exists,
117
+ project_database: project_db_exists,
118
+ claude_md: claude_md_exists,
119
+ hooks_configured: hooks_configured
120
+ },
121
+ issues: issues,
122
+ warnings: warnings,
123
+ recommendations: recommendations
124
+ }
125
+ end
126
+
127
+ def list_global_database
128
+ if @manager.global_exists?
129
+ @manager.ensure_global!
130
+ store = @manager.global_store
131
+ {
132
+ exists: true,
133
+ path: @manager.global_db_path,
134
+ facts_active: store.facts.where(status: "active").count,
135
+ facts_total: store.facts.count,
136
+ entities: store.entities.count
137
+ }
138
+ else
139
+ {exists: false, path: @manager.global_db_path}
140
+ end
141
+ end
142
+
143
+ def list_current_project
144
+ if @manager.project_exists?
145
+ @manager.ensure_project!
146
+ store = @manager.project_store
147
+ {
148
+ exists: true,
149
+ path: @manager.project_path,
150
+ db_path: @manager.project_db_path,
151
+ facts_active: store.facts.where(status: "active").count,
152
+ facts_total: store.facts.count,
153
+ entities: store.entities.count
154
+ }
155
+ else
156
+ {exists: false, path: @manager.project_path, db_path: @manager.project_db_path}
157
+ end
158
+ end
159
+
160
+ def discover_other_projects
161
+ return [] unless @manager.global_exists?
162
+
163
+ @manager.ensure_global!
164
+ global = @manager.global_store
165
+
166
+ promoted_paths = global.facts
167
+ .where(Sequel.like(:created_from, "promoted:%"))
168
+ .select(:created_from)
169
+ .distinct
170
+ .all
171
+ .filter_map { |f|
172
+ match = f[:created_from]&.match(/\Apromoted:(.+):\d+\z/)
173
+ match[1] if match
174
+ }
175
+ .uniq
176
+
177
+ fact_paths = global.facts
178
+ .exclude(project_path: nil)
179
+ .select(:project_path)
180
+ .distinct
181
+ .all
182
+ .map { |f| f[:project_path] }
183
+
184
+ all_paths = (promoted_paths + fact_paths).uniq
185
+ current = @manager.project_path
186
+
187
+ all_paths.filter_map { |path|
188
+ next if path == current
189
+
190
+ db_path = File.join(path, ".claude", "memory.sqlite3")
191
+ entry = {path: path, db_path: db_path, exists: File.exist?(db_path)}
192
+
193
+ if entry[:exists]
194
+ begin
195
+ temp_store = Store::SQLiteStore.new(db_path)
196
+ entry[:facts_active] = temp_store.facts.where(status: "active").count
197
+ entry[:facts_total] = temp_store.facts.count
198
+ entry[:entities] = temp_store.entities.count
199
+ temp_store.close
200
+ rescue Sequel::DatabaseError, Extralite::Error, IOError => _e
201
+ entry[:error] = "Could not read database"
202
+ end
203
+ end
204
+
205
+ entry
206
+ }
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ module Handlers
6
+ # Shortcut tool handlers (decisions, conventions, architecture)
7
+ module ShortcutHandlers
8
+ def decisions(args)
9
+ return {error: "Decisions shortcut requires StoreManager"} unless @manager
10
+
11
+ results = Recall.recent_decisions(@manager, limit: args["limit"] || 10)
12
+ format_shortcut_results(results, "decisions")
13
+ end
14
+
15
+ def conventions(args)
16
+ return {error: "Conventions shortcut requires StoreManager"} unless @manager
17
+
18
+ results = Recall.conventions(@manager, limit: args["limit"] || 20)
19
+ format_shortcut_results(results, "conventions")
20
+ end
21
+
22
+ def architecture(args)
23
+ return {error: "Architecture shortcut requires StoreManager"} unless @manager
24
+
25
+ results = Recall.architecture_choices(@manager, limit: args["limit"] || 10)
26
+ format_shortcut_results(results, "architecture")
27
+ end
28
+
29
+ private
30
+
31
+ def format_shortcut_results(results, category)
32
+ ResponseFormatter.format_shortcut_results(category, results)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ module Handlers
6
+ # Status and statistics tool handlers
7
+ module StatsHandlers
8
+ def status
9
+ result = {databases: {}}
10
+
11
+ if @manager
12
+ if @manager.global_exists?
13
+ @manager.ensure_global!
14
+ result[:databases][:global] = db_stats(@manager.global_store)
15
+ else
16
+ result[:databases][:global] = {exists: false}
17
+ end
18
+
19
+ if @manager.project_exists?
20
+ @manager.ensure_project!
21
+ result[:databases][:project] = db_stats(@manager.project_store)
22
+ else
23
+ result[:databases][:project] = {exists: false}
24
+ end
25
+ else
26
+ result[:databases][:legacy] = db_stats(@legacy_store)
27
+ end
28
+
29
+ result[:pending_distillation] = pending_distillation_count
30
+ result
31
+ end
32
+
33
+ def stats(args)
34
+ scope = args["scope"] || "all"
35
+ result = {scope: scope, databases: {}}
36
+
37
+ if @manager
38
+ if scope == "all" || scope == "global"
39
+ if @manager.global_exists?
40
+ @manager.ensure_global!
41
+ result[:databases][:global] = detailed_stats(@manager.global_store)
42
+ else
43
+ result[:databases][:global] = {exists: false}
44
+ end
45
+ end
46
+
47
+ if scope == "all" || scope == "project"
48
+ if @manager.project_exists?
49
+ @manager.ensure_project!
50
+ result[:databases][:project] = detailed_stats(@manager.project_store)
51
+ else
52
+ result[:databases][:project] = {exists: false}
53
+ end
54
+ end
55
+ else
56
+ result[:databases][:legacy] = detailed_stats(@legacy_store)
57
+ end
58
+
59
+ result
60
+ end
61
+
62
+ private
63
+
64
+ def pending_distillation_count
65
+ stores = if @manager
66
+ [@manager.global_exists? ? @manager.global_store : nil,
67
+ @manager.project_exists? ? @manager.project_store : nil].compact
68
+ elsif @legacy_store
69
+ [@legacy_store]
70
+ else
71
+ []
72
+ end
73
+
74
+ stores.sum { |store| store.count_undistilled(min_length: 200) }
75
+ end
76
+
77
+ def db_stats(store)
78
+ stats = {
79
+ exists: true,
80
+ facts_total: store.facts.count,
81
+ facts_active: store.facts.where(status: "active").count,
82
+ content_items: store.content_items.count,
83
+ open_conflicts: store.conflicts.where(status: "open").count,
84
+ schema_version: store.schema_version
85
+ }
86
+
87
+ vec_index = store.vector_index
88
+ stats[:vec_available] = vec_index.available?
89
+ stats[:vec_indexed] = vec_index.coverage_stats[:vec_indexed] if vec_index.available?
90
+
91
+ if fts_legacy?(store)
92
+ stats[:fts_legacy] = true
93
+ stats[:optimization_hint] = "Run 'claude-memory compact' to reduce database size by ~40%"
94
+ end
95
+
96
+ stats
97
+ end
98
+
99
+ def fts_legacy?(store)
100
+ row = store.db.fetch("SELECT sql FROM sqlite_master WHERE name = 'content_fts' AND type = 'table'").first
101
+ row && !row[:sql].to_s.include?("content=''")
102
+ rescue
103
+ false
104
+ end
105
+
106
+ def detailed_stats(store)
107
+ active_facts = store.facts.where(status: "active").count
108
+
109
+ stats = {
110
+ exists: true,
111
+ facts: fact_stats(store, active_facts),
112
+ entities: entity_stats(store),
113
+ content_items: content_stats(store),
114
+ provenance: provenance_stats(store, active_facts),
115
+ conflicts: conflict_stats(store),
116
+ schema_version: store.schema_version
117
+ }
118
+
119
+ stats[:vec] = vec_stats(store, active_facts)
120
+
121
+ stats
122
+ end
123
+
124
+ def fact_stats(store, active_facts)
125
+ stats = {
126
+ total: store.facts.count,
127
+ active: active_facts,
128
+ superseded: store.facts.where(status: "superseded").count
129
+ }
130
+
131
+ if active_facts > 0
132
+ stats[:top_predicates] = store.db[:facts]
133
+ .where(status: "active")
134
+ .group_and_count(:predicate)
135
+ .order(Sequel.desc(:count))
136
+ .limit(10)
137
+ .all
138
+ .map { |row| {predicate: row[:predicate], count: row[:count]} }
139
+ end
140
+
141
+ stats
142
+ end
143
+
144
+ def entity_stats(store)
145
+ {
146
+ total: store.entities.count,
147
+ by_type: store.db[:entities]
148
+ .group_and_count(:type)
149
+ .order(Sequel.desc(:count))
150
+ .all
151
+ .map { |row| {type: row[:type], count: row[:count]} }
152
+ }
153
+ end
154
+
155
+ def content_stats(store)
156
+ count = store.content_items.count
157
+ stats = {total: count}
158
+
159
+ if count > 0
160
+ stats[:date_range] = {
161
+ first: store.content_items.min(:occurred_at),
162
+ last: store.content_items.max(:occurred_at)
163
+ }
164
+ end
165
+
166
+ stats
167
+ end
168
+
169
+ def provenance_stats(store, active_facts)
170
+ return {facts_with_sources: 0, total_active_facts: 0, coverage_percentage: 0} if active_facts == 0
171
+
172
+ facts_with_provenance = store.db[:provenance]
173
+ .join(:facts, id: :fact_id)
174
+ .where(Sequel[:facts][:status] => "active")
175
+ .select(Sequel[:provenance][:fact_id])
176
+ .distinct
177
+ .count
178
+
179
+ {
180
+ facts_with_sources: facts_with_provenance,
181
+ total_active_facts: active_facts,
182
+ coverage_percentage: (facts_with_provenance * 100.0 / active_facts).round(1)
183
+ }
184
+ end
185
+
186
+ def vec_stats(store, _active_facts)
187
+ vec_index = store.vector_index
188
+ result = {available: vec_index.available?}
189
+ result.merge!(vec_index.coverage_stats) if vec_index.available?
190
+ result
191
+ end
192
+
193
+ def conflict_stats(store)
194
+ open = store.conflicts.where(status: "open").count
195
+ resolved = store.conflicts.where(status: "resolved").count
196
+
197
+ {open: open, resolved: resolved, total: open + resolved}
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -26,8 +26,9 @@ module ClaudeMemory
26
26
 
27
27
  parts << usage_hint(store_or_manager)
28
28
  parts.compact.join("\n\n")
29
- rescue => _e
29
+ rescue => e
30
30
  # Never fail initialization — return minimal instructions
31
+ ClaudeMemory.logger.debug("InstructionsBuilder failed: #{e.message}")
31
32
  "ClaudeMemory v#{ClaudeMemory::VERSION} — long-term memory for Claude Code."
32
33
  end
33
34
 
@@ -66,6 +66,16 @@ module ClaudeMemory
66
66
  - Use when: you need facts from a specific workflow context
67
67
  - Cost: ~300-800 tokens per call
68
68
 
69
+ ### Tier 5: Distillation Management
70
+
71
+ **memory.undistilled** — List content items not yet deeply distilled
72
+ - Use when: you want to find ingested content that hasn't been processed by LLM extraction
73
+ - Cost: ~200-400 tokens per call
74
+
75
+ **memory.mark_distilled** — Mark a content item as distilled after extraction
76
+ - Use after: performing LLM-based fact extraction on undistilled content
77
+ - Cost: ~100 tokens per call
78
+
69
79
  ## Recommended Workflow
70
80
 
71
81
  1. **Start broad**: `memory.recall` or shortcut tools (decisions/conventions/architecture)
@@ -278,6 +278,7 @@ module ClaudeMemory
278
278
  source: result[:source],
279
279
  similarity: result[:similarity]
280
280
  }
281
+ fact[:score_trace] = result[:score_trace] if result[:score_trace]
281
282
  fact[:receipts] = result[:receipts].map { |r| format_receipt(r, query: query) } unless compact
282
283
  fact
283
284
  end
@@ -28,6 +28,8 @@ module ClaudeMemory
28
28
  when "memory.recall_semantic" then summarize_semantic(result)
29
29
  when "memory.search_concepts" then summarize_concepts(result)
30
30
  when "memory.fact_graph" then summarize_fact_graph(result)
31
+ when "memory.undistilled" then summarize_undistilled(result)
32
+ when "memory.mark_distilled" then summarize_mark_distilled(result)
31
33
  when "memory.check_setup" then summarize_check_setup(result)
32
34
  else JSON.generate(result)
33
35
  end
@@ -122,6 +124,10 @@ module ClaudeMemory
122
124
  "- #{name}: #{info[:facts_active]} active facts, #{info[:open_conflicts]} conflicts (schema v#{info[:schema_version]})"
123
125
  end
124
126
  end
127
+
128
+ pending = result[:pending_distillation] || 0
129
+ lines << "Pending distillation: #{pending}" if pending > 0
130
+
125
131
  lines.join("\n")
126
132
  end
127
133
 
@@ -233,6 +239,26 @@ module ClaudeMemory
233
239
  lines.join("\n")
234
240
  end
235
241
 
242
+ def self.summarize_undistilled(result)
243
+ items = result[:items] || []
244
+ return "No undistilled content items." if items.empty?
245
+
246
+ lines = ["#{result[:count]} undistilled content item(s):"]
247
+ items.each do |i|
248
+ ago = i[:occurred_ago] || "unknown"
249
+ lines << "- Item ##{i[:content_item_id]} (#{ago}): #{(i[:raw_text] || "")[0, 80]}..."
250
+ end
251
+ lines.join("\n")
252
+ end
253
+
254
+ def self.summarize_mark_distilled(result)
255
+ if result[:success]
256
+ "Marked content item ##{result[:content_item_id]} as distilled (#{result[:facts_extracted]} facts extracted)"
257
+ else
258
+ result[:error]
259
+ end
260
+ end
261
+
236
262
  def self.summarize_check_setup(result)
237
263
  lines = ["Setup status: #{result[:status]}"]
238
264
  lines << "Version: #{result[:version][:current]} (latest: #{result[:version][:latest]})"
@@ -25,6 +25,7 @@ module ClaudeMemory
25
25
  type: "object",
26
26
  properties: {
27
27
  query: {type: "string", description: "Search query for existing knowledge (e.g., 'authentication flow', 'error handling', 'database setup')"},
28
+ intent: {type: "string", description: "Optional intent to disambiguate the query (e.g., 'migration' or 'performance' when query is 'database'). Steers search without replacing the query."},
28
29
  limit: {type: "integer", description: "Max results", default: 10},
29
30
  scope: {type: "string", enum: ["all", "global", "project"], description: "Filter by scope: 'all' (default), 'global', or 'project'", default: "all"},
30
31
  compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses (~800 → ~300 tokens/result)", default: false}
@@ -40,6 +41,7 @@ module ClaudeMemory
40
41
  type: "object",
41
42
  properties: {
42
43
  query: {type: "string", description: "Search query for existing knowledge (e.g., 'client errors', 'database choice')"},
44
+ intent: {type: "string", description: "Optional intent to disambiguate the query (e.g., 'schema' or 'optimization' when query is 'database'). Steers search without replacing the query."},
43
45
  limit: {type: "integer", description: "Maximum results to return", default: 20},
44
46
  scope: {type: "string", enum: ["all", "global", "project"], description: "Scope: 'all' (both), 'global' (user-wide), 'project' (current only)", default: "all"}
45
47
  },
@@ -265,10 +267,12 @@ module ClaudeMemory
265
267
  type: "object",
266
268
  properties: {
267
269
  query: {type: "string", description: "Search query"},
270
+ intent: {type: "string", description: "Optional intent to disambiguate the query (e.g., 'security' when query is 'authentication'). Disables BM25 shortcut to ensure vector search runs."},
268
271
  mode: {type: "string", enum: ["vector", "text", "both"], default: "both", description: "Search mode: vector (embeddings), text (FTS), or both (hybrid)"},
269
272
  limit: {type: "integer", default: 10, description: "Maximum results to return"},
270
273
  scope: {type: "string", enum: ["all", "global", "project"], default: "all", description: "Filter by scope"},
271
- compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses (~800 → ~300 tokens/result)", default: false}
274
+ compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses (~800 → ~300 tokens/result)", default: false},
275
+ explain: {type: "boolean", description: "Include per-result score traces showing FTS rank, vector similarity, and RRF contribution", default: false}
272
276
  },
273
277
  required: ["query"]
274
278
  },
@@ -309,6 +313,31 @@ module ClaudeMemory
309
313
  },
310
314
  annotations: READ_ONLY
311
315
  },
316
+ {
317
+ name: "memory.undistilled",
318
+ description: "List content items not yet deeply distilled. Returns raw transcript text for knowledge extraction.",
319
+ inputSchema: {
320
+ type: "object",
321
+ properties: {
322
+ limit: {type: "integer", default: 3, description: "Max items to return"},
323
+ min_length: {type: "integer", default: 200, description: "Min text length (skip tiny deltas)"}
324
+ }
325
+ },
326
+ annotations: READ_ONLY
327
+ },
328
+ {
329
+ name: "memory.mark_distilled",
330
+ description: "Mark a content item as distilled after extracting facts from it.",
331
+ inputSchema: {
332
+ type: "object",
333
+ properties: {
334
+ content_item_id: {type: "integer", description: "ID of the distilled content item"},
335
+ facts_extracted: {type: "integer", default: 0, description: "Number of facts extracted"}
336
+ },
337
+ required: ["content_item_id"]
338
+ },
339
+ annotations: WRITE_IDEMPOTENT
340
+ },
312
341
  {
313
342
  name: "memory.check_setup",
314
343
  description: "Check ClaudeMemory initialization status. Returns version info, issues found, and recommendations.",
@@ -75,6 +75,49 @@ module ClaudeMemory
75
75
  def extract_limit(args, default: 10)
76
76
  args["limit"] || default
77
77
  end
78
+
79
+ # Extract optional intent parameter for query disambiguation
80
+ # @param args [Hash] Tool arguments
81
+ # @return [String, nil] Intent string or nil if not provided/blank
82
+ def extract_intent(args)
83
+ intent = args["intent"]
84
+ (intent.nil? || intent.to_s.strip.empty?) ? nil : intent.to_s.strip
85
+ end
86
+
87
+ # Collect undistilled content items from both stores (or legacy store)
88
+ # @param limit [Integer] Maximum items to return
89
+ # @param min_length [Integer] Minimum byte_len to include
90
+ # @return [Array<Hash>] Undistilled items sorted by recency
91
+ def collect_undistilled_items(limit:, min_length: 200)
92
+ if @manager
93
+ stores = []
94
+ stores << @manager.project_store if @manager.project_exists?
95
+ stores << @manager.global_store if @manager.global_exists?
96
+ items = stores.flat_map { |s| s.undistilled_content_items(limit: limit, min_length: min_length) }
97
+ items.sort_by { |i| i[:occurred_at] || "" }.reverse.first(limit)
98
+ elsif @legacy_store
99
+ @legacy_store.undistilled_content_items(limit: limit, min_length: min_length)
100
+ else
101
+ []
102
+ end
103
+ end
104
+
105
+ # Find the store containing a given content item
106
+ # @param content_item_id [Integer] Content item ID to locate
107
+ # @return [Store::SQLiteStore, nil] The store containing the item, or nil
108
+ def find_store_for_content_item(content_item_id)
109
+ if @manager
110
+ if @manager.project_store&.content_items&.where(id: content_item_id)&.any?
111
+ @manager.project_store
112
+ elsif @manager.global_store&.content_items&.where(id: content_item_id)&.any?
113
+ @manager.global_store
114
+ end
115
+ elsif @legacy_store
116
+ if @legacy_store.content_items.where(id: content_item_id).any?
117
+ @legacy_store
118
+ end
119
+ end
120
+ end
78
121
  end
79
122
  end
80
123
  end