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
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Pure business logic for ranking facts by concept similarity
6
+ # Follows Functional Core pattern (Gary Bernhardt) - no I/O, just transformations
7
+ class ConceptRanker
8
+ # Rank facts by average similarity across multiple concepts
9
+ # Only returns facts that match ALL concepts
10
+ # @param concept_results [Array<Array<Hash>>] Array of result arrays, one per concept
11
+ # @param limit [Integer] Maximum results to return
12
+ # @return [Array<Hash>] Ranked results with :fact, :receipts, :source, :similarity, :concept_similarities
13
+ def self.rank_by_concepts(concept_results, limit)
14
+ fact_map = build_fact_map(concept_results)
15
+ multi_concept_facts = filter_by_all_concepts(fact_map, concept_results.size)
16
+ return [] if multi_concept_facts.empty?
17
+
18
+ rank_by_average_similarity(multi_concept_facts, limit)
19
+ end
20
+
21
+ # Build a map of fact_id => array of result matches from each concept
22
+ def self.build_fact_map(concept_results)
23
+ fact_map = Hash.new { |h, k| h[k] = [] }
24
+
25
+ concept_results.each_with_index do |results, concept_idx|
26
+ results.each do |result|
27
+ fact_id = result[:fact][:id]
28
+ fact_map[fact_id] << {
29
+ result: result,
30
+ concept_idx: concept_idx,
31
+ similarity: result[:similarity] || 0.0
32
+ }
33
+ end
34
+ end
35
+
36
+ fact_map
37
+ end
38
+ private_class_method :build_fact_map
39
+
40
+ # Filter to only facts that appear in ALL concept result sets
41
+ def self.filter_by_all_concepts(fact_map, expected_concept_count)
42
+ fact_map.select do |_fact_id, matches|
43
+ represented_concepts = matches.map { |m| m[:concept_idx] }.uniq
44
+ represented_concepts.size == expected_concept_count
45
+ end
46
+ end
47
+ private_class_method :filter_by_all_concepts
48
+
49
+ # Rank multi-concept facts by average similarity score
50
+ def self.rank_by_average_similarity(multi_concept_facts, limit)
51
+ ranked = multi_concept_facts.map do |_fact_id, matches|
52
+ similarities = matches.map { |m| m[:similarity] }
53
+ avg_similarity = similarities.sum / similarities.size.to_f
54
+
55
+ # Use the first match for fact and receipts data
56
+ first_match = matches.first[:result]
57
+
58
+ {
59
+ fact: first_match[:fact],
60
+ receipts: first_match[:receipts],
61
+ source: first_match[:source],
62
+ similarity: avg_similarity,
63
+ concept_similarities: similarities
64
+ }
65
+ end
66
+
67
+ # Sort by average similarity (highest first)
68
+ ranked.sort_by { |r| -r[:similarity] }.take(limit)
69
+ end
70
+ private_class_method :rank_by_average_similarity
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeMemory
6
+ module Core
7
+ # Pure logic for building embedding candidates from fact data
8
+ # Follows Functional Core pattern - no I/O, just transformations
9
+ class EmbeddingCandidateBuilder
10
+ # Parse embeddings and prepare candidates for similarity calculation
11
+ # @param facts_data [Array<Hash>] Fact rows with :embedding_json, :id, etc.
12
+ # @return [Array<Hash>] Candidates with parsed :embedding arrays
13
+ def self.build_candidates(facts_data)
14
+ facts_data.map do |row|
15
+ parse_candidate(row)
16
+ end.compact
17
+ end
18
+
19
+ # Parse a single fact row into a candidate
20
+ # @param row [Hash] Fact row with :embedding_json, :id, etc.
21
+ # @return [Hash, nil] Candidate hash or nil if parse fails
22
+ def self.parse_candidate(row)
23
+ embedding = JSON.parse(row[:embedding_json])
24
+ {
25
+ fact_id: row[:id],
26
+ embedding: embedding,
27
+ subject_entity_id: row[:subject_entity_id],
28
+ predicate: row[:predicate],
29
+ object_literal: row[:object_literal],
30
+ scope: row[:scope]
31
+ }
32
+ rescue JSON::ParserError
33
+ nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Pure logic for collecting and ordering fact IDs from provenance records
6
+ # Follows Functional Core pattern - no I/O, just transformations
7
+ class FactCollector
8
+ # Collect fact IDs from provenance records, maintaining content order and deduplicating
9
+ # @param provenance_by_content [Hash] Map of content_id => array of provenance records with :fact_id
10
+ # @param content_ids [Array<Integer>] Ordered content IDs
11
+ # @param limit [Integer] Maximum fact IDs to collect
12
+ # @return [Array<Integer>] Ordered, deduplicated fact IDs
13
+ def self.collect_ordered_fact_ids(provenance_by_content, content_ids, limit)
14
+ return [] if limit <= 0
15
+
16
+ seen_fact_ids = Set.new
17
+ ordered_fact_ids = []
18
+
19
+ content_ids.each do |content_id|
20
+ provenance_records = provenance_by_content[content_id] || []
21
+
22
+ provenance_records.each do |prov|
23
+ fact_id = prov[:fact_id]
24
+ next if seen_fact_ids.include?(fact_id)
25
+
26
+ seen_fact_ids.add(fact_id)
27
+ ordered_fact_ids << fact_id
28
+ break if ordered_fact_ids.size >= limit
29
+ end
30
+ break if ordered_fact_ids.size >= limit
31
+ end
32
+
33
+ ordered_fact_ids
34
+ end
35
+
36
+ # Extract unique fact IDs from array of provenance records
37
+ # @param provenance_records [Array<Hash>] Records with :fact_id
38
+ # @return [Array<Integer>] Unique fact IDs
39
+ def self.extract_fact_ids(provenance_records)
40
+ provenance_records.map { |p| p[:fact_id] }.uniq
41
+ end
42
+
43
+ # Extract unique content IDs from array of provenance records
44
+ # @param provenance_records [Array<Hash>] Records with :content_item_id
45
+ # @return [Array<Integer>] Unique content IDs
46
+ def self.extract_content_ids(provenance_records)
47
+ provenance_records.map { |p| p[:content_item_id] }.uniq
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Query construction logic for fact-related database queries
6
+ # Builds Sequel datasets with appropriate joins and selects
7
+ # Follows Functional Core pattern - pure query building, no execution
8
+ class FactQueryBuilder
9
+ # Build dataset for batch finding facts with entity joins
10
+ # @param store [SQLiteStore] Database store
11
+ # @param fact_ids [Array<Integer>] Fact IDs to load
12
+ # @return [Hash] Hash of fact_id => fact_row
13
+ def self.batch_find_facts(store, fact_ids)
14
+ return {} if fact_ids.empty?
15
+
16
+ results = build_facts_dataset(store)
17
+ .where(Sequel[:facts][:id] => fact_ids)
18
+ .all
19
+
20
+ results.each_with_object({}) { |row, hash| hash[row[:id]] = row }
21
+ end
22
+
23
+ # Build dataset for batch finding receipts (provenance) with content_items join
24
+ # @param store [SQLiteStore] Database store
25
+ # @param fact_ids [Array<Integer>] Fact IDs to find receipts for
26
+ # @return [Hash] Hash of fact_id => [receipt_rows]
27
+ def self.batch_find_receipts(store, fact_ids)
28
+ return {} if fact_ids.empty?
29
+
30
+ results = build_receipts_dataset(store)
31
+ .where(Sequel[:provenance][:fact_id] => fact_ids)
32
+ .all
33
+
34
+ results.group_by { |row| row[:fact_id] }.tap do |grouped|
35
+ # Ensure all requested fact_ids have an entry (empty array if no receipts)
36
+ fact_ids.each { |id| grouped[id] ||= [] }
37
+ end
38
+ end
39
+
40
+ # Find single fact by ID with entity join
41
+ # @param store [SQLiteStore] Database store
42
+ # @param fact_id [Integer] Fact ID
43
+ # @return [Hash, nil] Fact row or nil
44
+ def self.find_fact(store, fact_id)
45
+ build_facts_dataset(store)
46
+ .where(Sequel[:facts][:id] => fact_id)
47
+ .first
48
+ end
49
+
50
+ # Find receipts for a single fact
51
+ # @param store [SQLiteStore] Database store
52
+ # @param fact_id [Integer] Fact ID
53
+ # @return [Array<Hash>] Receipt rows
54
+ def self.find_receipts(store, fact_id)
55
+ build_receipts_dataset(store)
56
+ .where(Sequel[:provenance][:fact_id] => fact_id)
57
+ .all
58
+ end
59
+
60
+ # Find fact IDs that supersede the given fact
61
+ # @param store [SQLiteStore] Database store
62
+ # @param fact_id [Integer] Fact ID
63
+ # @return [Array<Integer>] Fact IDs
64
+ def self.find_superseded_by(store, fact_id)
65
+ store.fact_links
66
+ .where(to_fact_id: fact_id, link_type: "supersedes")
67
+ .select_map(:from_fact_id)
68
+ end
69
+
70
+ # Find fact IDs that are superseded by the given fact
71
+ # @param store [SQLiteStore] Database store
72
+ # @param fact_id [Integer] Fact ID
73
+ # @return [Array<Integer>] Fact IDs
74
+ def self.find_supersedes(store, fact_id)
75
+ store.fact_links
76
+ .where(from_fact_id: fact_id, link_type: "supersedes")
77
+ .select_map(:to_fact_id)
78
+ end
79
+
80
+ # Find conflicts involving the given fact
81
+ # @param store [SQLiteStore] Database store
82
+ # @param fact_id [Integer] Fact ID
83
+ # @return [Array<Hash>] Conflict rows
84
+ def self.find_conflicts(store, fact_id)
85
+ store.conflicts
86
+ .select(:id, :fact_a_id, :fact_b_id, :status)
87
+ .where(Sequel.or(fact_a_id: fact_id, fact_b_id: fact_id))
88
+ .all
89
+ end
90
+
91
+ # Find facts created since a given timestamp
92
+ # @param store [SQLiteStore] Database store
93
+ # @param since [Time, String] Timestamp
94
+ # @param limit [Integer] Maximum results
95
+ # @return [Array<Hash>] Fact rows
96
+ def self.fetch_changes(store, since, limit)
97
+ store.facts
98
+ .select(:id, :subject_entity_id, :predicate, :object_literal, :status, :created_at, :scope, :project_path)
99
+ .where { created_at >= since }
100
+ .order(Sequel.desc(:created_at))
101
+ .limit(limit)
102
+ .all
103
+ end
104
+
105
+ # Find provenance records for a content item
106
+ # @param store [SQLiteStore] Database store
107
+ # @param content_id [Integer] Content item ID
108
+ # @return [Array<Hash>] Provenance rows
109
+ def self.find_provenance_by_content(store, content_id)
110
+ store.provenance
111
+ .select(:id, :fact_id, :content_item_id, :quote, :strength)
112
+ .where(content_item_id: content_id)
113
+ .all
114
+ end
115
+
116
+ # Build standard facts dataset with entity join and all necessary columns
117
+ # @param store [SQLiteStore] Database store
118
+ # @return [Sequel::Dataset] Configured dataset
119
+ def self.build_facts_dataset(store)
120
+ store.facts
121
+ .left_join(:entities, id: :subject_entity_id)
122
+ .select(
123
+ Sequel[:facts][:id],
124
+ Sequel[:facts][:predicate],
125
+ Sequel[:facts][:object_literal],
126
+ Sequel[:facts][:status],
127
+ Sequel[:facts][:confidence],
128
+ Sequel[:facts][:valid_from],
129
+ Sequel[:facts][:valid_to],
130
+ Sequel[:facts][:created_at],
131
+ Sequel[:entities][:canonical_name].as(:subject_name),
132
+ Sequel[:facts][:scope],
133
+ Sequel[:facts][:project_path]
134
+ )
135
+ end
136
+
137
+ # Build standard receipts dataset with content_items join
138
+ # @param store [SQLiteStore] Database store
139
+ # @return [Sequel::Dataset] Configured dataset
140
+ def self.build_receipts_dataset(store)
141
+ store.provenance
142
+ .left_join(:content_items, id: :content_item_id)
143
+ .select(
144
+ Sequel[:provenance][:id],
145
+ Sequel[:provenance][:fact_id],
146
+ Sequel[:provenance][:quote],
147
+ Sequel[:provenance][:strength],
148
+ Sequel[:content_items][:session_id],
149
+ Sequel[:content_items][:occurred_at]
150
+ )
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Pure business logic for ranking, sorting, and deduplicating facts
6
+ # Follows Functional Core pattern (Gary Bernhardt) - no I/O, just transformations
7
+ class FactRanker
8
+ # Deduplicate index results by fact signature and sort by source priority
9
+ # @param results [Array<Hash>] Results with :subject, :predicate, :object_preview, :source
10
+ # @param limit [Integer] Maximum results to return
11
+ # @return [Array<Hash>] Deduplicated and sorted results
12
+ def self.dedupe_and_sort_index(results, limit)
13
+ seen_signatures = Set.new
14
+ unique_results = []
15
+
16
+ results.each do |result|
17
+ sig = "#{result[:subject]}:#{result[:predicate]}:#{result[:object_preview]}"
18
+ next if seen_signatures.include?(sig)
19
+
20
+ seen_signatures.add(sig)
21
+ unique_results << result
22
+ end
23
+
24
+ # Sort by source priority (project first)
25
+ unique_results.sort_by do |item|
26
+ source_priority = (item[:source] == :project) ? 0 : 1
27
+ [source_priority]
28
+ end.first(limit)
29
+ end
30
+
31
+ # Deduplicate full fact results by signature and sort by source + creation time
32
+ # @param results [Array<Hash>] Results with :fact hash containing fact data, :source symbol
33
+ # @param limit [Integer] Maximum results to return
34
+ # @return [Array<Hash>] Deduplicated and sorted results
35
+ def self.dedupe_and_sort(results, limit)
36
+ seen_signatures = Set.new
37
+ unique_results = []
38
+
39
+ results.each do |result|
40
+ fact = result[:fact]
41
+ sig = "#{fact[:subject_name]}:#{fact[:predicate]}:#{fact[:object_literal]}"
42
+ next if seen_signatures.include?(sig)
43
+
44
+ seen_signatures.add(sig)
45
+ unique_results << result
46
+ end
47
+
48
+ unique_results.sort_by do |item|
49
+ source_priority = (item[:source] == :project) ? 0 : 1
50
+ [source_priority, item[:fact][:created_at]]
51
+ end.first(limit)
52
+ end
53
+
54
+ # Sort facts by scope priority: current project > global > other projects
55
+ # @param facts_with_provenance [Array<Hash>] Facts with :fact hash containing scope and project_path
56
+ # @param project_path [String] Current project path for comparison
57
+ # @return [Array<Hash>] Sorted facts
58
+ def self.sort_by_scope_priority(facts_with_provenance, project_path)
59
+ facts_with_provenance.sort_by do |item|
60
+ fact = item[:fact]
61
+ is_current_project = fact[:project_path] == project_path
62
+ is_global = fact[:scope] == "global"
63
+
64
+ [is_current_project ? 0 : 1, is_global ? 0 : 1]
65
+ end
66
+ end
67
+
68
+ # Deduplicate semantic search results by fact_id, keeping highest similarity
69
+ # @param results [Array<Hash>] Results with :fact hash containing :id and :similarity score
70
+ # @param limit [Integer] Maximum results to return
71
+ # @return [Array<Hash>] Deduplicated results sorted by similarity descending
72
+ def self.dedupe_by_fact_id(results, limit)
73
+ seen = {}
74
+
75
+ results.each do |result|
76
+ fact_id = result[:fact][:id]
77
+ # Keep the result with highest similarity for each fact
78
+ if !seen[fact_id] || seen[fact_id][:similarity] < result[:similarity]
79
+ seen[fact_id] = result
80
+ end
81
+ end
82
+
83
+ seen.values.sort_by { |r| -r[:similarity] }.take(limit)
84
+ end
85
+
86
+ # Merge vector and text search results, preferring vector similarity scores
87
+ # @param vector_results [Array<Hash>] Results from vector search with :fact and :similarity
88
+ # @param text_results [Array<Hash>] Results from text search with :fact and :similarity
89
+ # @param limit [Integer] Maximum results to return
90
+ # @return [Array<Hash>] Merged results sorted by similarity descending
91
+ def self.merge_search_results(vector_results, text_results, limit)
92
+ # Combine results, preferring vector similarity scores
93
+ combined = {}
94
+
95
+ vector_results.each do |result|
96
+ fact_id = result[:fact][:id]
97
+ combined[fact_id] = result
98
+ end
99
+
100
+ text_results.each do |result|
101
+ fact_id = result[:fact][:id]
102
+ # Only add if not already present from vector search
103
+ combined[fact_id] ||= result
104
+ end
105
+
106
+ # Sort by similarity score (highest first)
107
+ combined.values
108
+ .sort_by { |r| -(r[:similarity] || 0) }
109
+ .take(limit)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Pure utility for building fact result hashes from batch-fetched data
6
+ # Follows Functional Core pattern - no I/O, just transformations
7
+ class ResultBuilder
8
+ # Build fact results from batch-fetched facts and receipts
9
+ # @param fact_ids [Array<Integer>] Fact IDs to build results for
10
+ # @param facts_by_id [Hash] Map of fact_id => fact_hash
11
+ # @param receipts_by_fact_id [Hash] Map of fact_id => array of receipts
12
+ # @param source [Symbol] Source identifier (:project, :global, :legacy)
13
+ # @param similarity [Float, nil] Optional similarity score
14
+ # @return [Array<Hash>] Array of result hashes with :fact, :receipts, :source, :similarity
15
+ def self.build_results(fact_ids, facts_by_id:, receipts_by_fact_id:, source:, similarity: nil)
16
+ fact_ids.map do |fact_id|
17
+ fact = facts_by_id[fact_id]
18
+ next unless fact
19
+
20
+ result = {
21
+ fact: fact,
22
+ receipts: receipts_by_fact_id[fact_id] || [],
23
+ source: source
24
+ }
25
+ result[:similarity] = similarity if similarity
26
+ result
27
+ end.compact
28
+ end
29
+
30
+ # Build results with variable similarity scores
31
+ # @param matches [Array<Hash>] Array of matches with :fact_id and :similarity
32
+ # @param facts_by_id [Hash] Map of fact_id => fact_hash
33
+ # @param receipts_by_fact_id [Hash] Map of fact_id => array of receipts
34
+ # @param source [Symbol] Source identifier
35
+ # @return [Array<Hash>] Array of result hashes with varying similarity scores
36
+ def self.build_results_with_scores(matches, facts_by_id:, receipts_by_fact_id:, source:)
37
+ matches.map do |match|
38
+ fact_id = match[:fact_id] || match[:candidate]&.[](:fact_id)
39
+ next unless fact_id
40
+
41
+ fact = facts_by_id[fact_id]
42
+ next unless fact
43
+
44
+ {
45
+ fact: fact,
46
+ receipts: receipts_by_fact_id[fact_id] || [],
47
+ source: source,
48
+ similarity: match[:similarity]
49
+ }
50
+ end.compact
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Pure logic for sorting and limiting result collections
6
+ # Follows Functional Core pattern - no I/O, just transformations
7
+ class ResultSorter
8
+ # Sort results by timestamp (created_at) in descending order and apply limit
9
+ # @param results [Array<Hash>] Results with :created_at keys
10
+ # @param limit [Integer] Maximum results to return
11
+ # @return [Array<Hash>] Sorted, limited results (most recent first)
12
+ def self.sort_by_timestamp(results, limit)
13
+ results.sort_by { |r| r[:created_at] }.reverse.first(limit)
14
+ end
15
+
16
+ # Add source annotation to each result in collection
17
+ # @param results [Array<Hash>] Results to annotate
18
+ # @param source [Symbol] Source identifier (:project, :global, :legacy)
19
+ # @return [Array<Hash>] Results with :source key added (mutates in place)
20
+ def self.annotate_source(results, source)
21
+ results.each { |r| r[:source] = source }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Pure business logic for scope filtering and matching
6
+ # Follows Functional Core pattern - no I/O, just transformations
7
+ class ScopeFilter
8
+ SCOPE_ALL = "all"
9
+ SCOPE_PROJECT = "project"
10
+ SCOPE_GLOBAL = "global"
11
+
12
+ # Check if a fact matches the given scope
13
+ # @param fact [Hash] Fact record with :scope and :project_path
14
+ # @param scope [String] Scope to match against ("all", "project", "global")
15
+ # @param project_path [String] Current project path for project scope matching
16
+ # @return [Boolean] True if fact matches scope
17
+ def self.matches?(fact, scope, project_path)
18
+ return true if scope == SCOPE_ALL
19
+
20
+ fact_scope = fact[:scope] || "project"
21
+ fact_project = fact[:project_path]
22
+
23
+ case scope
24
+ when SCOPE_PROJECT
25
+ fact_scope == "project" && fact_project == project_path
26
+ when SCOPE_GLOBAL
27
+ fact_scope == "global"
28
+ else
29
+ true
30
+ end
31
+ end
32
+
33
+ # Apply scope filter to a Sequel dataset
34
+ # @param dataset [Sequel::Dataset] Dataset to filter
35
+ # @param scope [String] Scope to filter by
36
+ # @param project_path [String] Current project path for project scope
37
+ # @return [Sequel::Dataset] Filtered dataset
38
+ def self.apply_to_dataset(dataset, scope, project_path)
39
+ case scope
40
+ when SCOPE_PROJECT
41
+ dataset.where(scope: "project", project_path: project_path)
42
+ when SCOPE_GLOBAL
43
+ dataset.where(scope: "global")
44
+ else
45
+ dataset
46
+ end
47
+ end
48
+
49
+ # Filter array of facts by scope
50
+ # @param facts [Array<Hash>] Facts to filter
51
+ # @param scope [String] Scope to filter by
52
+ # @param project_path [String] Current project path
53
+ # @return [Array<Hash>] Filtered facts
54
+ def self.filter_facts(facts, scope, project_path)
55
+ return facts if scope == SCOPE_ALL
56
+
57
+ facts.select { |fact| matches?(fact, scope, project_path) }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Pure logic for building searchable text from structured data
6
+ # Follows Functional Core pattern - no I/O, just transformations
7
+ class TextBuilder
8
+ # Build searchable text from entities, facts, and decisions
9
+ # @param entities [Array<Hash>] Entities with :type and :name
10
+ # @param facts [Array<Hash>] Facts with :subject, :predicate, :object, :quote
11
+ # @param decisions [Array<Hash>] Decisions with :title and :summary
12
+ # @return [String] Concatenated searchable text
13
+ def self.build_searchable_text(entities, facts, decisions)
14
+ parts = []
15
+ entities.each { |e| parts << "#{e[:type]}: #{e[:name]}" }
16
+ facts.each { |f| parts << "#{f[:subject]} #{f[:predicate]} #{f[:object]} #{f[:quote]}" }
17
+ decisions.each { |d| parts << "#{d[:title]} #{d[:summary]}" }
18
+ parts.join(" ").strip
19
+ end
20
+
21
+ # Transform hash keys from strings to symbols
22
+ # @param hash [Hash] Hash with string or symbol keys
23
+ # @return [Hash] Hash with symbolized keys
24
+ def self.symbolize_keys(hash)
25
+ hash.transform_keys(&:to_sym)
26
+ end
27
+ end
28
+ end
29
+ end