claude_memory 0.7.1 → 0.9.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +32 -2
  4. data/.claude/settings.json +65 -15
  5. data/.claude/settings.local.json +5 -2
  6. data/.claude/skills/improve/SKILL.md +113 -25
  7. data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
  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 +2 -2
  11. data/.claude-plugin/plugin.json +3 -3
  12. data/.claude-plugin/scripts/hook-runner.sh +14 -0
  13. data/.claude-plugin/scripts/serve-mcp.sh +14 -0
  14. data/.ruby-version +1 -1
  15. data/CHANGELOG.md +90 -1
  16. data/CLAUDE.md +56 -18
  17. data/README.md +35 -0
  18. data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
  19. data/db/migrations/014_canonicalize_predicates.rb +30 -0
  20. data/docs/improvements.md +74 -74
  21. data/docs/influence/claude-mem.md +1 -0
  22. data/docs/influence/claude-supermemory.md +1 -0
  23. data/docs/influence/episodic-memory.md +1 -0
  24. data/docs/influence/grepai.md +1 -0
  25. data/docs/influence/kbs.md +1 -0
  26. data/docs/influence/lossless-claw.md +1 -0
  27. data/docs/influence/qmd.md +1 -0
  28. data/docs/quality_review.md +119 -224
  29. data/hooks/hooks.json +39 -7
  30. data/lib/claude_memory/commands/checks/distill_check.rb +61 -0
  31. data/lib/claude_memory/commands/checks/hooks_check.rb +2 -2
  32. data/lib/claude_memory/commands/checks/vec_check.rb +2 -1
  33. data/lib/claude_memory/commands/completion_command.rb +149 -0
  34. data/lib/claude_memory/commands/doctor_command.rb +2 -0
  35. data/lib/claude_memory/commands/embeddings_command.rb +198 -0
  36. data/lib/claude_memory/commands/help_command.rb +12 -1
  37. data/lib/claude_memory/commands/hook_command.rb +2 -1
  38. data/lib/claude_memory/commands/index_command.rb +85 -78
  39. data/lib/claude_memory/commands/initializers/database_ensurer.rb +16 -0
  40. data/lib/claude_memory/commands/initializers/global_initializer.rb +2 -1
  41. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +55 -11
  42. data/lib/claude_memory/commands/initializers/project_initializer.rb +2 -1
  43. data/lib/claude_memory/commands/install_skill_command.rb +78 -0
  44. data/lib/claude_memory/commands/registry.rb +47 -32
  45. data/lib/claude_memory/commands/reject_command.rb +62 -0
  46. data/lib/claude_memory/commands/restore_command.rb +77 -0
  47. data/lib/claude_memory/commands/skills/distill-transcripts.md +102 -0
  48. data/lib/claude_memory/commands/skills/memory-recall.md +67 -0
  49. data/lib/claude_memory/commands/stats_command.rb +98 -2
  50. data/lib/claude_memory/configuration.rb +14 -1
  51. data/lib/claude_memory/core/fact_ranker.rb +2 -2
  52. data/lib/claude_memory/core/rr_fusion.rb +23 -6
  53. data/lib/claude_memory/core/snippet_extractor.rb +7 -3
  54. data/lib/claude_memory/core/text_builder.rb +11 -0
  55. data/lib/claude_memory/distill/json_schema.md +8 -4
  56. data/lib/claude_memory/distill/null_distiller.rb +2 -0
  57. data/lib/claude_memory/domain/entity.rb +13 -1
  58. data/lib/claude_memory/domain/fact.rb +26 -2
  59. data/lib/claude_memory/domain/provenance.rb +0 -1
  60. data/lib/claude_memory/embeddings/api_adapter.rb +97 -0
  61. data/lib/claude_memory/embeddings/dimension_check.rb +23 -0
  62. data/lib/claude_memory/embeddings/fastembed_adapter.rb +46 -12
  63. data/lib/claude_memory/embeddings/generator.rb +4 -0
  64. data/lib/claude_memory/embeddings/inspector.rb +91 -0
  65. data/lib/claude_memory/embeddings/model_registry.rb +210 -0
  66. data/lib/claude_memory/embeddings/resolver.rb +44 -0
  67. data/lib/claude_memory/hook/context_injector.rb +58 -2
  68. data/lib/claude_memory/hook/distillation_runner.rb +46 -0
  69. data/lib/claude_memory/hook/handler.rb +11 -2
  70. data/lib/claude_memory/index/vector_index.rb +15 -2
  71. data/lib/claude_memory/infrastructure/schema_validator.rb +3 -3
  72. data/lib/claude_memory/ingest/ingester.rb +17 -0
  73. data/lib/claude_memory/mcp/handlers/context_handlers.rb +38 -0
  74. data/lib/claude_memory/mcp/handlers/management_handlers.rb +169 -0
  75. data/lib/claude_memory/mcp/handlers/query_handlers.rb +115 -0
  76. data/lib/claude_memory/mcp/handlers/setup_handlers.rb +211 -0
  77. data/lib/claude_memory/mcp/handlers/shortcut_handlers.rb +37 -0
  78. data/lib/claude_memory/mcp/handlers/stats_handlers.rb +205 -0
  79. data/lib/claude_memory/mcp/instructions_builder.rb +19 -1
  80. data/lib/claude_memory/mcp/query_guide.rb +10 -0
  81. data/lib/claude_memory/mcp/response_formatter.rb +1 -0
  82. data/lib/claude_memory/mcp/server.rb +22 -1
  83. data/lib/claude_memory/mcp/telemetry.rb +86 -0
  84. data/lib/claude_memory/mcp/text_summary.rb +26 -0
  85. data/lib/claude_memory/mcp/tool_definitions.rb +116 -4
  86. data/lib/claude_memory/mcp/tool_helpers.rb +43 -0
  87. data/lib/claude_memory/mcp/tools.rb +50 -679
  88. data/lib/claude_memory/publish.rb +40 -5
  89. data/lib/claude_memory/recall/dual_engine.rb +105 -0
  90. data/lib/claude_memory/recall/legacy_engine.rb +138 -0
  91. data/lib/claude_memory/recall/query_core.rb +371 -0
  92. data/lib/claude_memory/recall.rb +121 -673
  93. data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
  94. data/lib/claude_memory/resolve/resolver.rb +43 -0
  95. data/lib/claude_memory/shortcuts.rb +4 -4
  96. data/lib/claude_memory/store/retry_handler.rb +61 -0
  97. data/lib/claude_memory/store/schema_manager.rb +68 -0
  98. data/lib/claude_memory/store/sqlite_store.rb +334 -201
  99. data/lib/claude_memory/store/store_manager.rb +50 -1
  100. data/lib/claude_memory/sweep/maintenance.rb +115 -1
  101. data/lib/claude_memory/sweep/sweeper.rb +3 -0
  102. data/lib/claude_memory/templates/hooks.example.json +26 -7
  103. data/lib/claude_memory/version.rb +1 -1
  104. data/lib/claude_memory.rb +16 -0
  105. metadata +48 -8
  106. data/.claude/memory.sqlite3-shm +0 -0
  107. data/.claude/memory.sqlite3-wal +0 -0
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ class Recall
5
+ # Shared store-level query logic used by both LegacyEngine and DualEngine.
6
+ # All methods take a `store` parameter — no direct access to @legacy_store or @manager.
7
+ module QueryCore
8
+ private
9
+
10
+ # Augment a query with intent context for disambiguation.
11
+ # Intent is appended to the query to steer search without replacing the original.
12
+ #
13
+ # @param query_text [String] Original search query
14
+ # @param intent [String, nil] Optional intent to disambiguate (e.g., "migration", "performance")
15
+ # @return [String] Augmented query text, or original if no intent
16
+ def intent_augmented_query(query_text, intent)
17
+ return query_text if intent.nil? || intent.to_s.strip.empty?
18
+
19
+ "#{query_text} #{intent.to_s.strip}"
20
+ end
21
+
22
+ def query_single_store(store, query_text, limit:, source:, include_raw_text: false)
23
+ fts = Index::LexicalFTS.new(store)
24
+ content_ids = fts.search(query_text, limit: limit * 3)
25
+ return [] if content_ids.empty?
26
+
27
+ provenance_by_content = store.provenance
28
+ .select(:fact_id, :content_item_id)
29
+ .where(content_item_id: content_ids)
30
+ .all
31
+ .group_by { |p| p[:content_item_id] }
32
+
33
+ ordered_fact_ids = Core::FactCollector.collect_ordered_fact_ids(
34
+ provenance_by_content,
35
+ content_ids,
36
+ limit
37
+ )
38
+
39
+ return [] if ordered_fact_ids.empty?
40
+
41
+ facts_by_id = batch_find_facts(store, ordered_fact_ids)
42
+ receipts_by_fact_id = batch_find_receipts(store, ordered_fact_ids, include_raw_text: include_raw_text)
43
+
44
+ Core::ResultBuilder.build_results(
45
+ ordered_fact_ids,
46
+ facts_by_id: facts_by_id,
47
+ receipts_by_fact_id: receipts_by_fact_id,
48
+ source: source
49
+ )
50
+ end
51
+
52
+ def query_index_single_store(store, query_text, limit:, source:)
53
+ options = Index::QueryOptions.new(
54
+ query_text: query_text,
55
+ limit: limit,
56
+ scope: :all,
57
+ source: source
58
+ )
59
+
60
+ query = Index::IndexQuery.new(store, options)
61
+ query.execute
62
+ end
63
+
64
+ def facts_by_context_single(store, column, value, limit:, source:)
65
+ content_ids = store.content_items
66
+ .where(column => value)
67
+ .select(:id)
68
+ .map { |row| row[:id] }
69
+
70
+ return [] if content_ids.empty?
71
+
72
+ fact_ids = store.provenance
73
+ .where(content_item_id: content_ids)
74
+ .select(:fact_id)
75
+ .distinct
76
+ .map { |row| row[:fact_id] }
77
+
78
+ return [] if fact_ids.empty?
79
+
80
+ facts_by_id = batch_find_facts(store, fact_ids)
81
+ receipts_by_fact_id = batch_find_receipts(store, fact_ids)
82
+
83
+ results = Core::ResultBuilder.build_results(
84
+ fact_ids,
85
+ facts_by_id: facts_by_id,
86
+ receipts_by_fact_id: receipts_by_fact_id,
87
+ source: source
88
+ )
89
+ results.take(limit)
90
+ end
91
+
92
+ def facts_by_tool_single(store, tool_name, limit:, source:)
93
+ content_ids = store.tool_calls
94
+ .where(tool_name: tool_name)
95
+ .select(:content_item_id)
96
+ .distinct
97
+ .map { |row| row[:content_item_id] }
98
+
99
+ return [] if content_ids.empty?
100
+
101
+ fact_ids = store.provenance
102
+ .where(content_item_id: content_ids)
103
+ .select(:fact_id)
104
+ .distinct
105
+ .map { |row| row[:fact_id] }
106
+
107
+ return [] if fact_ids.empty?
108
+
109
+ facts_by_id = batch_find_facts(store, fact_ids)
110
+ receipts_by_fact_id = batch_find_receipts(store, fact_ids)
111
+
112
+ results = Core::ResultBuilder.build_results(
113
+ fact_ids,
114
+ facts_by_id: facts_by_id,
115
+ receipts_by_fact_id: receipts_by_fact_id,
116
+ source: source
117
+ )
118
+ results.take(limit)
119
+ end
120
+
121
+ def query_semantic_single(store, text, limit:, mode:, source:, explain: false, skip_fts_shortcut: false)
122
+ vector_results = []
123
+ text_results = []
124
+
125
+ if mode == :text || mode == :both
126
+ text_results = search_by_fts(store, text, limit, source)
127
+ end
128
+
129
+ if mode == :vector || mode == :both
130
+ # When intent is provided, disable the BM25 shortcut so vector search
131
+ # always runs — the intent may shift relevance beyond what FTS captures.
132
+ skip_vector = !skip_fts_shortcut && mode == :both && strong_fts_signal?(store, text)
133
+ vector_results = search_by_vector(store, text, limit, source) unless skip_vector
134
+ end
135
+
136
+ merge_search_results(vector_results, text_results, limit, explain: explain)
137
+ end
138
+
139
+ def query_concepts_single(store, concepts, limit:, source:)
140
+ concept_results = concepts.map do |concept|
141
+ search_by_vector(store, concept, limit * 5, source)
142
+ end
143
+
144
+ Core::ConceptRanker.rank_by_concepts(concept_results, limit)
145
+ end
146
+
147
+ # Explain / fact lookup helpers
148
+
149
+ def resolve_fact_identifier(store, identifier)
150
+ return identifier if identifier.is_a?(Integer)
151
+
152
+ str = identifier.to_s
153
+ return str.to_i if str.match?(/\A\d+\z/)
154
+
155
+ fact = Core::FactQueryBuilder.find_fact_by_docid(store, str)
156
+ fact ? fact[:id] : nil
157
+ end
158
+
159
+ def explain_from_store(store, fact_id)
160
+ fact = Core::FactQueryBuilder.find_fact(store, fact_id)
161
+ return Core::NullExplanation.new unless fact
162
+
163
+ {
164
+ fact: fact,
165
+ receipts: Core::FactQueryBuilder.find_receipts(store, fact_id),
166
+ superseded_by: Core::FactQueryBuilder.find_superseded_by(store, fact_id),
167
+ supersedes: Core::FactQueryBuilder.find_supersedes(store, fact_id),
168
+ conflicts: Core::FactQueryBuilder.find_conflicts(store, fact_id)
169
+ }
170
+ end
171
+
172
+ def fetch_changes(store, since, limit)
173
+ Core::FactQueryBuilder.fetch_changes(store, since, limit)
174
+ end
175
+
176
+ # Batch helpers
177
+
178
+ def batch_find_facts(store, fact_ids)
179
+ Core::FactQueryBuilder.batch_find_facts(store, fact_ids)
180
+ end
181
+
182
+ def batch_find_receipts(store, fact_ids, include_raw_text: false)
183
+ Core::FactQueryBuilder.batch_find_receipts(store, fact_ids, include_raw_text: include_raw_text)
184
+ end
185
+
186
+ # Vector search helpers
187
+
188
+ def search_by_vector(store, query_text, limit, source)
189
+ query_embedding = @embedding_generator.generate(query_text)
190
+
191
+ vec_index = store.vector_index
192
+ if vec_index.available?
193
+ return search_by_vector_native(store, vec_index, query_embedding, limit, source)
194
+ end
195
+
196
+ search_by_vector_fallback(store, query_embedding, limit, source)
197
+ end
198
+
199
+ def search_by_vector_native(store, vec_index, query_embedding, limit, source)
200
+ matches = vec_index.search(query_embedding, k: limit)
201
+ return [] if matches.empty?
202
+
203
+ fact_ids = matches.map { |m| m[:fact_id] }
204
+ facts_by_id = batch_find_facts(store, fact_ids)
205
+ receipts_by_fact_id = batch_find_receipts(store, fact_ids)
206
+
207
+ Core::ResultBuilder.build_results_with_scores(
208
+ matches,
209
+ facts_by_id: facts_by_id,
210
+ receipts_by_fact_id: receipts_by_fact_id,
211
+ source: source
212
+ )
213
+ end
214
+
215
+ def search_by_vector_fallback(store, query_embedding, limit, source)
216
+ facts_data = store.facts_with_embeddings(limit: 5000)
217
+ return [] if facts_data.empty?
218
+
219
+ unique_candidates, fact_groups = dedup_candidates(facts_data)
220
+ return [] if unique_candidates.empty?
221
+
222
+ top_unique = Embeddings::Similarity.top_k(query_embedding, unique_candidates, limit)
223
+ top_matches = fan_out_matches(top_unique, fact_groups, limit)
224
+
225
+ fact_ids = top_matches.map { |m| m[:candidate][:fact_id] }
226
+ facts_by_id = batch_find_facts(store, fact_ids)
227
+ receipts_by_fact_id = batch_find_receipts(store, fact_ids)
228
+
229
+ Core::ResultBuilder.build_results_with_scores(
230
+ top_matches,
231
+ facts_by_id: facts_by_id,
232
+ receipts_by_fact_id: receipts_by_fact_id,
233
+ source: source
234
+ )
235
+ end
236
+
237
+ def dedup_candidates(facts_data)
238
+ groups = {}
239
+ unique = {}
240
+
241
+ facts_data.each do |row|
242
+ key = row[:embedding_json]
243
+ if unique.key?(key)
244
+ groups[key] << row[:id]
245
+ else
246
+ candidate = Core::EmbeddingCandidateBuilder.parse_candidate(row)
247
+ next unless candidate
248
+ unique[key] = candidate
249
+ groups[key] = [row[:id]]
250
+ end
251
+ end
252
+
253
+ [unique.values, groups]
254
+ end
255
+
256
+ def fan_out_matches(top_unique, fact_groups, limit)
257
+ results = []
258
+ top_unique.each do |match|
259
+ candidate = match[:candidate]
260
+ similarity = match[:similarity]
261
+
262
+ group_key = fact_groups.find { |_key, ids| ids.include?(candidate[:fact_id]) }&.first
263
+ next unless group_key
264
+
265
+ fact_groups[group_key].each do |fact_id|
266
+ results << {
267
+ candidate: candidate.merge(fact_id: fact_id),
268
+ similarity: similarity
269
+ }
270
+ break if results.size >= limit
271
+ end
272
+ break if results.size >= limit
273
+ end
274
+
275
+ results
276
+ end
277
+
278
+ # FTS helpers
279
+
280
+ def search_by_fts(store, query_text, limit, source)
281
+ fts = Index::LexicalFTS.new(store)
282
+ ranked_results = fts.search_with_ranks(query_text, limit: limit * 2)
283
+
284
+ return [] if ranked_results.empty?
285
+
286
+ content_ids = ranked_results.map { |r| r[:content_item_id] }
287
+
288
+ provenance_rows = store.provenance
289
+ .where(content_item_id: content_ids)
290
+ .select(:fact_id, :content_item_id)
291
+ .all
292
+
293
+ content_to_facts = provenance_rows.group_by { |r| r[:content_item_id] }
294
+
295
+ ranks = ranked_results.map { |r| r[:rank] }
296
+ min_rank = ranks.min
297
+ max_rank = ranks.max
298
+ range = (max_rank - min_rank).abs
299
+
300
+ seen_fact_ids = Set.new
301
+ scored_matches = []
302
+
303
+ ranked_results.each do |r|
304
+ similarity = if range > 0
305
+ 0.1 + 0.9 * ((max_rank - r[:rank]).abs / range)
306
+ else
307
+ 0.8
308
+ end
309
+
310
+ fact_ids = content_to_facts[r[:content_item_id]]&.map { |p| p[:fact_id] } || []
311
+ fact_ids.each do |fid|
312
+ next if seen_fact_ids.include?(fid)
313
+ seen_fact_ids.add(fid)
314
+ scored_matches << {fact_id: fid, similarity: similarity}
315
+ end
316
+ end
317
+
318
+ return [] if scored_matches.empty?
319
+
320
+ fact_ids = scored_matches.map { |m| m[:fact_id] }
321
+ facts_by_id = batch_find_facts(store, fact_ids)
322
+ receipts_by_fact_id = batch_find_receipts(store, fact_ids)
323
+
324
+ Core::ResultBuilder.build_results_with_scores(
325
+ scored_matches,
326
+ facts_by_id: facts_by_id,
327
+ receipts_by_fact_id: receipts_by_fact_id,
328
+ source: source
329
+ ).take(limit)
330
+ end
331
+
332
+ def merge_search_results(vector_results, text_results, limit, explain: false)
333
+ Core::FactRanker.merge_search_results(vector_results, text_results, limit, explain: explain)
334
+ end
335
+
336
+ def strong_fts_signal?(store, query_text)
337
+ fts = Index::LexicalFTS.new(store)
338
+ ranked_results = fts.search_with_ranks(query_text, limit: 5)
339
+ Recall::ExpansionDetector.strong_fts_signal?(ranked_results)
340
+ end
341
+
342
+ # Scope helpers
343
+
344
+ def fact_matches_scope?(fact, scope)
345
+ Core::ScopeFilter.matches?(fact, scope, @project_path)
346
+ end
347
+
348
+ def apply_scope_filter(dataset, scope)
349
+ Core::ScopeFilter.apply_to_dataset(dataset, scope, @project_path)
350
+ end
351
+
352
+ def sort_by_scope_priority(facts_with_provenance)
353
+ Core::FactRanker.sort_by_scope_priority(facts_with_provenance, @project_path)
354
+ end
355
+
356
+ # Dedup helpers
357
+
358
+ def dedupe_and_sort(results, limit)
359
+ Core::FactRanker.dedupe_and_sort(results, limit)
360
+ end
361
+
362
+ def dedupe_and_sort_index(results, limit)
363
+ Core::FactRanker.dedupe_and_sort_index(results, limit)
364
+ end
365
+
366
+ def dedupe_by_fact_id(results, limit)
367
+ Core::FactRanker.dedupe_by_fact_id(results, limit)
368
+ end
369
+ end
370
+ end
371
+ end