claude_memory 0.2.0 → 0.3.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/.mind.mv2.o2N83S +0 -0
  3. data/.claude/CLAUDE.md +1 -0
  4. data/.claude/rules/claude_memory.generated.md +28 -9
  5. data/.claude/settings.local.json +9 -1
  6. data/.claude/skills/check-memory/SKILL.md +77 -0
  7. data/.claude/skills/improve/SKILL.md +532 -0
  8. data/.claude/skills/improve/feature-patterns.md +1221 -0
  9. data/.claude/skills/quality-update/SKILL.md +229 -0
  10. data/.claude/skills/quality-update/implementation-guide.md +346 -0
  11. data/.claude/skills/review-commit/SKILL.md +199 -0
  12. data/.claude/skills/review-for-quality/SKILL.md +154 -0
  13. data/.claude/skills/review-for-quality/expert-checklists.md +79 -0
  14. data/.claude/skills/setup-memory/SKILL.md +168 -0
  15. data/.claude/skills/study-repo/SKILL.md +307 -0
  16. data/.claude/skills/study-repo/analysis-template.md +323 -0
  17. data/.claude/skills/study-repo/focus-examples.md +327 -0
  18. data/CHANGELOG.md +133 -0
  19. data/CLAUDE.md +130 -11
  20. data/README.md +117 -10
  21. data/db/migrations/001_create_initial_schema.rb +117 -0
  22. data/db/migrations/002_add_project_scoping.rb +33 -0
  23. data/db/migrations/003_add_session_metadata.rb +42 -0
  24. data/db/migrations/004_add_fact_embeddings.rb +20 -0
  25. data/db/migrations/005_add_incremental_sync.rb +21 -0
  26. data/db/migrations/006_add_operation_tracking.rb +40 -0
  27. data/db/migrations/007_add_ingestion_metrics.rb +26 -0
  28. data/docs/.claude/mind.mv2.lock +0 -0
  29. data/docs/GETTING_STARTED.md +587 -0
  30. data/docs/RELEASE_NOTES_v0.2.0.md +0 -1
  31. data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +0 -2
  32. data/docs/architecture.md +9 -8
  33. data/docs/auto_init_design.md +230 -0
  34. data/docs/improvements.md +557 -731
  35. data/docs/influence/.gitkeep +13 -0
  36. data/docs/influence/grepai.md +933 -0
  37. data/docs/influence/qmd.md +2195 -0
  38. data/docs/plugin.md +257 -11
  39. data/docs/quality_review.md +472 -1273
  40. data/docs/remaining_improvements.md +330 -0
  41. data/lefthook.yml +13 -0
  42. data/lib/claude_memory/commands/checks/claude_md_check.rb +41 -0
  43. data/lib/claude_memory/commands/checks/database_check.rb +120 -0
  44. data/lib/claude_memory/commands/checks/hooks_check.rb +112 -0
  45. data/lib/claude_memory/commands/checks/reporter.rb +110 -0
  46. data/lib/claude_memory/commands/checks/snapshot_check.rb +30 -0
  47. data/lib/claude_memory/commands/doctor_command.rb +12 -129
  48. data/lib/claude_memory/commands/help_command.rb +1 -0
  49. data/lib/claude_memory/commands/hook_command.rb +9 -2
  50. data/lib/claude_memory/commands/index_command.rb +169 -0
  51. data/lib/claude_memory/commands/ingest_command.rb +1 -1
  52. data/lib/claude_memory/commands/init_command.rb +5 -197
  53. data/lib/claude_memory/commands/initializers/database_ensurer.rb +30 -0
  54. data/lib/claude_memory/commands/initializers/global_initializer.rb +85 -0
  55. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +156 -0
  56. data/lib/claude_memory/commands/initializers/mcp_configurator.rb +56 -0
  57. data/lib/claude_memory/commands/initializers/memory_instructions_writer.rb +135 -0
  58. data/lib/claude_memory/commands/initializers/project_initializer.rb +111 -0
  59. data/lib/claude_memory/commands/recover_command.rb +75 -0
  60. data/lib/claude_memory/commands/registry.rb +5 -1
  61. data/lib/claude_memory/commands/stats_command.rb +239 -0
  62. data/lib/claude_memory/commands/uninstall_command.rb +226 -0
  63. data/lib/claude_memory/core/batch_loader.rb +32 -0
  64. data/lib/claude_memory/core/concept_ranker.rb +73 -0
  65. data/lib/claude_memory/core/embedding_candidate_builder.rb +37 -0
  66. data/lib/claude_memory/core/fact_collector.rb +51 -0
  67. data/lib/claude_memory/core/fact_query_builder.rb +154 -0
  68. data/lib/claude_memory/core/fact_ranker.rb +113 -0
  69. data/lib/claude_memory/core/result_builder.rb +54 -0
  70. data/lib/claude_memory/core/result_sorter.rb +25 -0
  71. data/lib/claude_memory/core/scope_filter.rb +61 -0
  72. data/lib/claude_memory/core/text_builder.rb +29 -0
  73. data/lib/claude_memory/embeddings/generator.rb +161 -0
  74. data/lib/claude_memory/embeddings/similarity.rb +69 -0
  75. data/lib/claude_memory/hook/handler.rb +4 -3
  76. data/lib/claude_memory/index/lexical_fts.rb +7 -2
  77. data/lib/claude_memory/infrastructure/operation_tracker.rb +158 -0
  78. data/lib/claude_memory/infrastructure/schema_validator.rb +206 -0
  79. data/lib/claude_memory/ingest/content_sanitizer.rb +6 -7
  80. data/lib/claude_memory/ingest/ingester.rb +99 -15
  81. data/lib/claude_memory/ingest/metadata_extractor.rb +57 -0
  82. data/lib/claude_memory/ingest/tool_extractor.rb +71 -0
  83. data/lib/claude_memory/mcp/response_formatter.rb +331 -0
  84. data/lib/claude_memory/mcp/server.rb +19 -0
  85. data/lib/claude_memory/mcp/setup_status_analyzer.rb +73 -0
  86. data/lib/claude_memory/mcp/tool_definitions.rb +279 -0
  87. data/lib/claude_memory/mcp/tool_helpers.rb +80 -0
  88. data/lib/claude_memory/mcp/tools.rb +330 -320
  89. data/lib/claude_memory/recall/dual_query_template.rb +63 -0
  90. data/lib/claude_memory/recall.rb +304 -237
  91. data/lib/claude_memory/resolve/resolver.rb +52 -49
  92. data/lib/claude_memory/store/sqlite_store.rb +210 -144
  93. data/lib/claude_memory/store/store_manager.rb +6 -6
  94. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  95. data/lib/claude_memory/version.rb +1 -1
  96. data/lib/claude_memory.rb +35 -3
  97. metadata +71 -11
  98. data/.claude/.mind.mv2.aLCUZd +0 -0
  99. data/.claude/memory.sqlite3 +0 -0
  100. data/.mcp.json +0 -11
  101. /data/docs/{feature_adoption_plan.md → plans/feature_adoption_plan.md} +0 -0
  102. /data/docs/{feature_adoption_plan_revised.md → plans/feature_adoption_plan_revised.md} +0 -0
  103. /data/docs/{plan.md → plans/plan.md} +0 -0
  104. /data/docs/{updated_plan.md → plans/updated_plan.md} +0 -0
@@ -24,8 +24,10 @@ module ClaudeMemory
24
24
  end
25
25
  end
26
26
 
27
- def initialize(store_or_manager, fts: nil, project_path: nil, env: ENV)
28
- @project_path = project_path || env["CLAUDE_PROJECT_DIR"] || Dir.pwd
27
+ def initialize(store_or_manager, fts: nil, project_path: nil, env: ENV, embedding_generator: nil)
28
+ config = Configuration.new(env)
29
+ @project_path = project_path || config.project_dir
30
+ @embedding_generator = embedding_generator || Embeddings::Generator.new
29
31
 
30
32
  if store_or_manager.is_a?(Store::StoreManager)
31
33
  @manager = store_or_manager
@@ -79,49 +81,63 @@ module ClaudeMemory
79
81
  end
80
82
  end
81
83
 
82
- private
83
-
84
- def query_dual(query_text, limit:, scope:)
85
- results = []
84
+ def facts_by_branch(branch_name, limit: 20, scope: SCOPE_ALL)
85
+ if @legacy_mode
86
+ facts_by_context_legacy(:git_branch, branch_name, limit: limit, scope: scope)
87
+ else
88
+ facts_by_context_dual(:git_branch, branch_name, limit: limit, scope: scope)
89
+ end
90
+ end
86
91
 
87
- if scope == SCOPE_ALL || scope == SCOPE_PROJECT
88
- @manager.ensure_project! if @manager.project_exists?
89
- if @manager.project_store
90
- project_results = query_single_store(@manager.project_store, query_text, limit: limit, source: :project)
91
- results.concat(project_results)
92
- end
92
+ def facts_by_directory(cwd, limit: 20, scope: SCOPE_ALL)
93
+ if @legacy_mode
94
+ facts_by_context_legacy(:cwd, cwd, limit: limit, scope: scope)
95
+ else
96
+ facts_by_context_dual(:cwd, cwd, limit: limit, scope: scope)
93
97
  end
98
+ end
94
99
 
95
- if scope == SCOPE_ALL || scope == SCOPE_GLOBAL
96
- @manager.ensure_global! if @manager.global_exists?
97
- if @manager.global_store
98
- global_results = query_single_store(@manager.global_store, query_text, limit: limit, source: :global)
99
- results.concat(global_results)
100
- end
100
+ def facts_by_tool(tool_name, limit: 20, scope: SCOPE_ALL)
101
+ if @legacy_mode
102
+ facts_by_tool_legacy(tool_name, limit: limit, scope: scope)
103
+ else
104
+ facts_by_tool_dual(tool_name, limit: limit, scope: scope)
101
105
  end
106
+ end
102
107
 
103
- dedupe_and_sort(results, limit)
108
+ def query_semantic(text, limit: 10, scope: SCOPE_ALL, mode: :both)
109
+ if @legacy_mode
110
+ query_semantic_legacy(text, limit: limit, scope: scope, mode: mode)
111
+ else
112
+ query_semantic_dual(text, limit: limit, scope: scope, mode: mode)
113
+ end
104
114
  end
105
115
 
106
- def query_index_dual(query_text, limit:, scope:)
107
- results = []
116
+ def query_concepts(concepts, limit: 10, scope: SCOPE_ALL)
117
+ raise ArgumentError, "Must provide 2-5 concepts" unless (2..5).cover?(concepts.size)
108
118
 
109
- if scope == SCOPE_ALL || scope == SCOPE_PROJECT
110
- @manager.ensure_project! if @manager.project_exists?
111
- if @manager.project_store
112
- project_results = query_index_single_store(@manager.project_store, query_text, limit: limit, source: :project)
113
- results.concat(project_results)
114
- end
119
+ if @legacy_mode
120
+ query_concepts_legacy(concepts, limit: limit, scope: scope)
121
+ else
122
+ query_concepts_dual(concepts, limit: limit, scope: scope)
115
123
  end
124
+ end
116
125
 
117
- if scope == SCOPE_ALL || scope == SCOPE_GLOBAL
118
- @manager.ensure_global! if @manager.global_exists?
119
- if @manager.global_store
120
- global_results = query_index_single_store(@manager.global_store, query_text, limit: limit, source: :global)
121
- results.concat(global_results)
122
- end
126
+ private
127
+
128
+ def query_dual(query_text, limit:, scope:)
129
+ template = Recall::DualQueryTemplate.new(@manager)
130
+ results = template.execute(scope: scope, limit: limit) do |store, source|
131
+ query_single_store(store, query_text, limit: limit, source: source)
123
132
  end
133
+ dedupe_and_sort(results, limit)
134
+ end
124
135
 
136
+ def query_index_dual(query_text, limit:, scope:)
137
+ template = Recall::DualQueryTemplate.new(@manager)
138
+ results = template.execute(scope: scope, limit: limit) do |store, source|
139
+ query_index_single_store(store, query_text, limit: limit, source: source)
140
+ end
125
141
  dedupe_and_sort_index(results, limit)
126
142
  end
127
143
 
@@ -138,22 +154,7 @@ module ClaudeMemory
138
154
  end
139
155
 
140
156
  def dedupe_and_sort_index(results, limit)
141
- seen_signatures = Set.new
142
- unique_results = []
143
-
144
- results.each do |result|
145
- sig = "#{result[:subject]}:#{result[:predicate]}:#{result[:object_preview]}"
146
- next if seen_signatures.include?(sig)
147
-
148
- seen_signatures.add(sig)
149
- unique_results << result
150
- end
151
-
152
- # Sort by source priority (project first)
153
- unique_results.sort_by do |item|
154
- source_priority = (item[:source] == :project) ? 0 : 1
155
- [source_priority]
156
- end.first(limit)
157
+ Core::FactRanker.dedupe_and_sort_index(results, limit)
157
158
  end
158
159
 
159
160
  def query_single_store(store, query_text, limit:, source:)
@@ -161,27 +162,22 @@ module ClaudeMemory
161
162
  content_ids = fts.search(query_text, limit: limit * 3)
162
163
  return [] if content_ids.empty?
163
164
 
164
- # Collect all fact_ids first
165
- seen_fact_ids = Set.new
166
- ordered_fact_ids = []
167
-
165
+ # Build provenance map for ordered collection
166
+ provenance_by_content = {}
168
167
  content_ids.each do |content_id|
169
- provenance_records = store.provenance
168
+ provenance_by_content[content_id] = store.provenance
170
169
  .select(:fact_id)
171
170
  .where(content_item_id: content_id)
172
171
  .all
173
-
174
- provenance_records.each do |prov|
175
- fact_id = prov[:fact_id]
176
- next if seen_fact_ids.include?(fact_id)
177
-
178
- seen_fact_ids.add(fact_id)
179
- ordered_fact_ids << fact_id
180
- break if ordered_fact_ids.size >= limit
181
- end
182
- break if ordered_fact_ids.size >= limit
183
172
  end
184
173
 
174
+ # Collect fact IDs in content order, deduplicated
175
+ ordered_fact_ids = Core::FactCollector.collect_ordered_fact_ids(
176
+ provenance_by_content,
177
+ content_ids,
178
+ limit
179
+ )
180
+
185
181
  return [] if ordered_fact_ids.empty?
186
182
 
187
183
  # Batch query all facts at once
@@ -191,129 +187,47 @@ module ClaudeMemory
191
187
  receipts_by_fact_id = batch_find_receipts(store, ordered_fact_ids)
192
188
 
193
189
  # Build results maintaining order
194
- ordered_fact_ids.map do |fact_id|
195
- fact = facts_by_id[fact_id]
196
- next unless fact
197
-
198
- {
199
- fact: fact,
200
- receipts: receipts_by_fact_id[fact_id] || [],
201
- source: source
202
- }
203
- end.compact
190
+ Core::ResultBuilder.build_results(
191
+ ordered_fact_ids,
192
+ facts_by_id: facts_by_id,
193
+ receipts_by_fact_id: receipts_by_fact_id,
194
+ source: source
195
+ )
204
196
  end
205
197
 
206
198
  def batch_find_facts(store, fact_ids)
207
- store.facts
208
- .left_join(:entities, id: :subject_entity_id)
209
- .select(
210
- Sequel[:facts][:id],
211
- Sequel[:facts][:predicate],
212
- Sequel[:facts][:object_literal],
213
- Sequel[:facts][:status],
214
- Sequel[:facts][:confidence],
215
- Sequel[:facts][:valid_from],
216
- Sequel[:facts][:valid_to],
217
- Sequel[:facts][:created_at],
218
- Sequel[:entities][:canonical_name].as(:subject_name),
219
- Sequel[:facts][:scope],
220
- Sequel[:facts][:project_path]
221
- )
222
- .where(Sequel[:facts][:id] => fact_ids)
223
- .all
224
- .each_with_object({}) { |fact, hash| hash[fact[:id]] = fact }
199
+ Core::FactQueryBuilder.batch_find_facts(store, fact_ids)
225
200
  end
226
201
 
227
202
  def batch_find_receipts(store, fact_ids)
228
- store.provenance
229
- .left_join(:content_items, id: :content_item_id)
230
- .select(
231
- Sequel[:provenance][:id],
232
- Sequel[:provenance][:fact_id],
233
- Sequel[:provenance][:quote],
234
- Sequel[:provenance][:strength],
235
- Sequel[:content_items][:session_id],
236
- Sequel[:content_items][:occurred_at]
237
- )
238
- .where(Sequel[:provenance][:fact_id] => fact_ids)
239
- .all
240
- .group_by { |receipt| receipt[:fact_id] }
203
+ Core::FactQueryBuilder.batch_find_receipts(store, fact_ids)
241
204
  end
242
205
 
243
206
  def dedupe_and_sort(results, limit)
244
- seen_signatures = Set.new
245
- unique_results = []
246
-
247
- results.each do |result|
248
- fact = result[:fact]
249
- sig = "#{fact[:subject_name]}:#{fact[:predicate]}:#{fact[:object_literal]}"
250
- next if seen_signatures.include?(sig)
251
-
252
- seen_signatures.add(sig)
253
- unique_results << result
254
- end
255
-
256
- unique_results.sort_by do |item|
257
- source_priority = (item[:source] == :project) ? 0 : 1
258
- [source_priority, item[:fact][:created_at]]
259
- end.first(limit)
207
+ Core::FactRanker.dedupe_and_sort(results, limit)
260
208
  end
261
209
 
262
210
  def changes_dual(since:, limit:, scope:)
263
- results = []
264
-
265
- if scope == SCOPE_ALL || scope == SCOPE_PROJECT
266
- @manager.ensure_project! if @manager.project_exists?
267
- if @manager.project_store
268
- project_changes = fetch_changes(@manager.project_store, since, limit)
269
- project_changes.each { |c| c[:source] = :project }
270
- results.concat(project_changes)
271
- end
211
+ template = Recall::DualQueryTemplate.new(@manager)
212
+ results = template.execute(scope: scope, limit: limit) do |store, source|
213
+ changes = fetch_changes(store, since, limit)
214
+ Core::ResultSorter.annotate_source(changes, source)
215
+ changes
272
216
  end
273
-
274
- if scope == SCOPE_ALL || scope == SCOPE_GLOBAL
275
- @manager.ensure_global! if @manager.global_exists?
276
- if @manager.global_store
277
- global_changes = fetch_changes(@manager.global_store, since, limit)
278
- global_changes.each { |c| c[:source] = :global }
279
- results.concat(global_changes)
280
- end
281
- end
282
-
283
- results.sort_by { |c| c[:created_at] }.reverse.first(limit)
217
+ Core::ResultSorter.sort_by_timestamp(results, limit)
284
218
  end
285
219
 
286
220
  def fetch_changes(store, since, limit)
287
- store.facts
288
- .select(:id, :subject_entity_id, :predicate, :object_literal, :status, :created_at, :scope, :project_path)
289
- .where { created_at >= since }
290
- .order(Sequel.desc(:created_at))
291
- .limit(limit)
292
- .all
221
+ Core::FactQueryBuilder.fetch_changes(store, since, limit)
293
222
  end
294
223
 
295
224
  def conflicts_dual(scope:)
296
- results = []
297
-
298
- if scope == SCOPE_ALL || scope == SCOPE_PROJECT
299
- @manager.ensure_project! if @manager.project_exists?
300
- if @manager.project_store
301
- project_conflicts = @manager.project_store.open_conflicts
302
- project_conflicts.each { |c| c[:source] = :project }
303
- results.concat(project_conflicts)
304
- end
305
- end
306
-
307
- if scope == SCOPE_ALL || scope == SCOPE_GLOBAL
308
- @manager.ensure_global! if @manager.global_exists?
309
- if @manager.global_store
310
- global_conflicts = @manager.global_store.open_conflicts
311
- global_conflicts.each { |c| c[:source] = :global }
312
- results.concat(global_conflicts)
313
- end
225
+ template = Recall::DualQueryTemplate.new(@manager)
226
+ template.execute(scope: scope) do |store, source|
227
+ conflicts = store.open_conflicts
228
+ Core::ResultSorter.annotate_source(conflicts, source)
229
+ conflicts
314
230
  end
315
-
316
- results
317
231
  end
318
232
 
319
233
  def explain_from_store(store, fact_id)
@@ -330,56 +244,23 @@ module ClaudeMemory
330
244
  end
331
245
 
332
246
  def find_fact_from_store(store, fact_id)
333
- store.facts
334
- .left_join(:entities, id: :subject_entity_id)
335
- .select(
336
- Sequel[:facts][:id],
337
- Sequel[:facts][:predicate],
338
- Sequel[:facts][:object_literal],
339
- Sequel[:facts][:status],
340
- Sequel[:facts][:confidence],
341
- Sequel[:facts][:valid_from],
342
- Sequel[:facts][:valid_to],
343
- Sequel[:facts][:created_at],
344
- Sequel[:entities][:canonical_name].as(:subject_name),
345
- Sequel[:facts][:scope],
346
- Sequel[:facts][:project_path]
347
- )
348
- .where(Sequel[:facts][:id] => fact_id)
349
- .first
247
+ Core::FactQueryBuilder.find_fact(store, fact_id)
350
248
  end
351
249
 
352
250
  def find_receipts_from_store(store, fact_id)
353
- store.provenance
354
- .left_join(:content_items, id: :content_item_id)
355
- .select(
356
- Sequel[:provenance][:id],
357
- Sequel[:provenance][:quote],
358
- Sequel[:provenance][:strength],
359
- Sequel[:content_items][:session_id],
360
- Sequel[:content_items][:occurred_at]
361
- )
362
- .where(Sequel[:provenance][:fact_id] => fact_id)
363
- .all
251
+ Core::FactQueryBuilder.find_receipts(store, fact_id)
364
252
  end
365
253
 
366
254
  def find_superseded_by_from_store(store, fact_id)
367
- store.fact_links
368
- .where(to_fact_id: fact_id, link_type: "supersedes")
369
- .select_map(:from_fact_id)
255
+ Core::FactQueryBuilder.find_superseded_by(store, fact_id)
370
256
  end
371
257
 
372
258
  def find_supersedes_from_store(store, fact_id)
373
- store.fact_links
374
- .where(from_fact_id: fact_id, link_type: "supersedes")
375
- .select_map(:to_fact_id)
259
+ Core::FactQueryBuilder.find_supersedes(store, fact_id)
376
260
  end
377
261
 
378
262
  def find_conflicts_from_store(store, fact_id)
379
- store.conflicts
380
- .select(:id, :fact_a_id, :fact_b_id, :status)
381
- .where(Sequel.or(fact_a_id: fact_id, fact_b_id: fact_id))
382
- .all
263
+ Core::FactQueryBuilder.find_conflicts(store, fact_id)
383
264
  end
384
265
 
385
266
  def query_legacy(query_text, limit:, scope:)
@@ -454,47 +335,19 @@ module ClaudeMemory
454
335
  end
455
336
 
456
337
  def fact_matches_scope?(fact, scope)
457
- return true if scope == SCOPE_ALL
458
-
459
- fact_scope = fact[:scope] || "project"
460
- fact_project = fact[:project_path]
461
-
462
- case scope
463
- when SCOPE_PROJECT
464
- fact_scope == "project" && fact_project == @project_path
465
- when SCOPE_GLOBAL
466
- fact_scope == "global"
467
- else
468
- true
469
- end
338
+ Core::ScopeFilter.matches?(fact, scope, @project_path)
470
339
  end
471
340
 
472
341
  def apply_scope_filter(dataset, scope)
473
- case scope
474
- when SCOPE_PROJECT
475
- dataset.where(scope: "project", project_path: @project_path)
476
- when SCOPE_GLOBAL
477
- dataset.where(scope: "global")
478
- else
479
- dataset
480
- end
342
+ Core::ScopeFilter.apply_to_dataset(dataset, scope, @project_path)
481
343
  end
482
344
 
483
345
  def sort_by_scope_priority(facts_with_provenance)
484
- facts_with_provenance.sort_by do |item|
485
- fact = item[:fact]
486
- is_current_project = fact[:project_path] == @project_path
487
- is_global = fact[:scope] == "global"
488
-
489
- [is_current_project ? 0 : 1, is_global ? 0 : 1]
490
- end
346
+ Core::FactRanker.sort_by_scope_priority(facts_with_provenance, @project_path)
491
347
  end
492
348
 
493
349
  def find_provenance_by_content(content_id)
494
- @legacy_store.provenance
495
- .select(:id, :fact_id, :content_item_id, :quote, :strength)
496
- .where(content_item_id: content_id)
497
- .all
350
+ Core::FactQueryBuilder.find_provenance_by_content(@legacy_store, content_id)
498
351
  end
499
352
 
500
353
  def find_fact(fact_id)
@@ -504,5 +357,219 @@ module ClaudeMemory
504
357
  def find_receipts(fact_id)
505
358
  find_receipts_from_store(@legacy_store, fact_id)
506
359
  end
360
+
361
+ # Context-aware query helpers
362
+
363
+ def facts_by_context_dual(column, value, limit:, scope:)
364
+ template = Recall::DualQueryTemplate.new(@manager)
365
+ results = template.execute(scope: scope, limit: limit) do |store, source|
366
+ facts_by_context_single(store, column, value, limit: limit, source: source)
367
+ end
368
+ dedupe_and_sort(results, limit)
369
+ end
370
+
371
+ def facts_by_context_legacy(column, value, limit:, scope:)
372
+ facts_by_context_single(@legacy_store, column, value, limit: limit, source: :legacy)
373
+ end
374
+
375
+ def facts_by_context_single(store, column, value, limit:, source:)
376
+ # Find content items matching the context
377
+ content_ids = store.content_items
378
+ .where(column => value)
379
+ .select(:id)
380
+ .map { |row| row[:id] }
381
+
382
+ return [] if content_ids.empty?
383
+
384
+ # Find facts linked to those content items via provenance
385
+ fact_ids = store.provenance
386
+ .where(content_item_id: content_ids)
387
+ .select(:fact_id)
388
+ .distinct
389
+ .map { |row| row[:fact_id] }
390
+
391
+ return [] if fact_ids.empty?
392
+
393
+ # Batch fetch facts and their provenance
394
+ facts_by_id = batch_find_facts(store, fact_ids)
395
+ receipts_by_fact_id = batch_find_receipts(store, fact_ids)
396
+
397
+ results = Core::ResultBuilder.build_results(
398
+ fact_ids,
399
+ facts_by_id: facts_by_id,
400
+ receipts_by_fact_id: receipts_by_fact_id,
401
+ source: source
402
+ )
403
+ results.take(limit)
404
+ end
405
+
406
+ def facts_by_tool_dual(tool_name, limit:, scope:)
407
+ template = Recall::DualQueryTemplate.new(@manager)
408
+ results = template.execute(scope: scope, limit: limit) do |store, source|
409
+ facts_by_tool_single(store, tool_name, limit: limit, source: source)
410
+ end
411
+ dedupe_and_sort(results, limit)
412
+ end
413
+
414
+ def facts_by_tool_legacy(tool_name, limit:, scope:)
415
+ facts_by_tool_single(@legacy_store, tool_name, limit: limit, source: :legacy)
416
+ end
417
+
418
+ def facts_by_tool_single(store, tool_name, limit:, source:)
419
+ # Find content items where the tool was used
420
+ content_ids = store.tool_calls
421
+ .where(tool_name: tool_name)
422
+ .select(:content_item_id)
423
+ .distinct
424
+ .map { |row| row[:content_item_id] }
425
+
426
+ return [] if content_ids.empty?
427
+
428
+ # Find facts linked to those content items via provenance
429
+ fact_ids = store.provenance
430
+ .where(content_item_id: content_ids)
431
+ .select(:fact_id)
432
+ .distinct
433
+ .map { |row| row[:fact_id] }
434
+
435
+ return [] if fact_ids.empty?
436
+
437
+ # Batch fetch facts and their provenance
438
+ facts_by_id = batch_find_facts(store, fact_ids)
439
+ receipts_by_fact_id = batch_find_receipts(store, fact_ids)
440
+
441
+ results = Core::ResultBuilder.build_results(
442
+ fact_ids,
443
+ facts_by_id: facts_by_id,
444
+ receipts_by_fact_id: receipts_by_fact_id,
445
+ source: source
446
+ )
447
+ results.take(limit)
448
+ end
449
+
450
+ # Semantic search helpers
451
+
452
+ def query_semantic_dual(text, limit:, scope:, mode:)
453
+ template = Recall::DualQueryTemplate.new(@manager)
454
+ results = template.execute(scope: scope, limit: limit) do |store, source|
455
+ query_semantic_single(store, text, limit: limit * 3, mode: mode, source: source)
456
+ end
457
+ dedupe_and_sort(results, limit)
458
+ end
459
+
460
+ def query_semantic_legacy(text, limit:, scope:, mode:)
461
+ query_semantic_single(@legacy_store, text, limit: limit, mode: mode, source: :legacy)
462
+ end
463
+
464
+ def query_semantic_single(store, text, limit:, mode:, source:)
465
+ vector_results = []
466
+ text_results = []
467
+
468
+ # Vector search mode
469
+ if mode == :vector || mode == :both
470
+ vector_results = search_by_vector(store, text, limit, source)
471
+ end
472
+
473
+ # Text search mode (FTS)
474
+ if mode == :text || mode == :both
475
+ text_results = search_by_fts(store, text, limit, source)
476
+ end
477
+
478
+ # Merge and deduplicate
479
+ merge_search_results(vector_results, text_results, limit)
480
+ end
481
+
482
+ def search_by_vector(store, query_text, limit, source)
483
+ # Generate query embedding
484
+ query_embedding = @embedding_generator.generate(query_text)
485
+
486
+ # Load facts with embeddings
487
+ facts_data = store.facts_with_embeddings(limit: 5000)
488
+ return [] if facts_data.empty?
489
+
490
+ # Parse embeddings and prepare candidates
491
+ candidates = Core::EmbeddingCandidateBuilder.build_candidates(facts_data)
492
+
493
+ return [] if candidates.empty?
494
+
495
+ # Calculate similarities and rank
496
+ top_matches = Embeddings::Similarity.top_k(query_embedding, candidates, limit)
497
+
498
+ # Batch fetch full fact details
499
+ fact_ids = top_matches.map { |m| m[:candidate][:fact_id] }
500
+ facts_by_id = batch_find_facts(store, fact_ids)
501
+ receipts_by_fact_id = batch_find_receipts(store, fact_ids)
502
+
503
+ # Build results with similarity scores
504
+ Core::ResultBuilder.build_results_with_scores(
505
+ top_matches,
506
+ facts_by_id: facts_by_id,
507
+ receipts_by_fact_id: receipts_by_fact_id,
508
+ source: source
509
+ )
510
+ end
511
+
512
+ def search_by_fts(store, query_text, limit, source)
513
+ # Use existing FTS search infrastructure
514
+ fts = Index::LexicalFTS.new(store)
515
+ content_ids = fts.search(query_text, limit: limit * 2)
516
+
517
+ return [] if content_ids.empty?
518
+
519
+ # Find facts from content items
520
+ fact_ids = store.provenance
521
+ .where(content_item_id: content_ids)
522
+ .select(:fact_id)
523
+ .distinct
524
+ .map { |row| row[:fact_id] }
525
+
526
+ return [] if fact_ids.empty?
527
+
528
+ # Batch fetch facts
529
+ facts_by_id = batch_find_facts(store, fact_ids)
530
+ receipts_by_fact_id = batch_find_receipts(store, fact_ids)
531
+
532
+ results = Core::ResultBuilder.build_results(
533
+ fact_ids,
534
+ facts_by_id: facts_by_id,
535
+ receipts_by_fact_id: receipts_by_fact_id,
536
+ source: source,
537
+ similarity: 0.5 # Default score for FTS results
538
+ )
539
+ results.take(limit)
540
+ end
541
+
542
+ def merge_search_results(vector_results, text_results, limit)
543
+ Core::FactRanker.merge_search_results(vector_results, text_results, limit)
544
+ end
545
+
546
+ # Multi-concept search helpers
547
+
548
+ def query_concepts_dual(concepts, limit:, scope:)
549
+ template = Recall::DualQueryTemplate.new(@manager)
550
+ results = template.execute(scope: scope, limit: limit) do |store, source|
551
+ query_concepts_single(store, concepts, limit: limit * 2, source: source)
552
+ end
553
+ # Deduplicate and sort by average similarity
554
+ dedupe_by_fact_id(results, limit)
555
+ end
556
+
557
+ def query_concepts_legacy(concepts, limit:, scope:)
558
+ query_concepts_single(@legacy_store, concepts, limit: limit, source: :legacy)
559
+ end
560
+
561
+ def query_concepts_single(store, concepts, limit:, source:)
562
+ # I/O: Search each concept independently with higher limit for intersection
563
+ concept_results = concepts.map do |concept|
564
+ search_by_vector(store, concept, limit * 5, source)
565
+ end
566
+
567
+ # Pure logic: Rank by average similarity across all concepts
568
+ Core::ConceptRanker.rank_by_concepts(concept_results, limit)
569
+ end
570
+
571
+ def dedupe_by_fact_id(results, limit)
572
+ Core::FactRanker.dedupe_by_fact_id(results, limit)
573
+ end
507
574
  end
508
575
  end