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.
- checksums.yaml +4 -4
- data/.claude/.mind.mv2.o2N83S +0 -0
- data/.claude/CLAUDE.md +1 -0
- data/.claude/rules/claude_memory.generated.md +28 -9
- data/.claude/settings.local.json +9 -1
- data/.claude/skills/check-memory/SKILL.md +77 -0
- data/.claude/skills/improve/SKILL.md +532 -0
- data/.claude/skills/improve/feature-patterns.md +1221 -0
- data/.claude/skills/quality-update/SKILL.md +229 -0
- data/.claude/skills/quality-update/implementation-guide.md +346 -0
- data/.claude/skills/review-commit/SKILL.md +199 -0
- data/.claude/skills/review-for-quality/SKILL.md +154 -0
- data/.claude/skills/review-for-quality/expert-checklists.md +79 -0
- data/.claude/skills/setup-memory/SKILL.md +168 -0
- data/.claude/skills/study-repo/SKILL.md +307 -0
- data/.claude/skills/study-repo/analysis-template.md +323 -0
- data/.claude/skills/study-repo/focus-examples.md +327 -0
- data/CHANGELOG.md +133 -0
- data/CLAUDE.md +130 -11
- data/README.md +117 -10
- data/db/migrations/001_create_initial_schema.rb +117 -0
- data/db/migrations/002_add_project_scoping.rb +33 -0
- data/db/migrations/003_add_session_metadata.rb +42 -0
- data/db/migrations/004_add_fact_embeddings.rb +20 -0
- data/db/migrations/005_add_incremental_sync.rb +21 -0
- data/db/migrations/006_add_operation_tracking.rb +40 -0
- data/db/migrations/007_add_ingestion_metrics.rb +26 -0
- data/docs/.claude/mind.mv2.lock +0 -0
- data/docs/GETTING_STARTED.md +587 -0
- data/docs/RELEASE_NOTES_v0.2.0.md +0 -1
- data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +0 -2
- data/docs/architecture.md +9 -8
- data/docs/auto_init_design.md +230 -0
- data/docs/improvements.md +557 -731
- data/docs/influence/.gitkeep +13 -0
- data/docs/influence/grepai.md +933 -0
- data/docs/influence/qmd.md +2195 -0
- data/docs/plugin.md +257 -11
- data/docs/quality_review.md +472 -1273
- data/docs/remaining_improvements.md +330 -0
- data/lefthook.yml +13 -0
- data/lib/claude_memory/commands/checks/claude_md_check.rb +41 -0
- data/lib/claude_memory/commands/checks/database_check.rb +120 -0
- data/lib/claude_memory/commands/checks/hooks_check.rb +112 -0
- data/lib/claude_memory/commands/checks/reporter.rb +110 -0
- data/lib/claude_memory/commands/checks/snapshot_check.rb +30 -0
- data/lib/claude_memory/commands/doctor_command.rb +12 -129
- data/lib/claude_memory/commands/help_command.rb +1 -0
- data/lib/claude_memory/commands/hook_command.rb +9 -2
- data/lib/claude_memory/commands/index_command.rb +169 -0
- data/lib/claude_memory/commands/ingest_command.rb +1 -1
- data/lib/claude_memory/commands/init_command.rb +5 -197
- data/lib/claude_memory/commands/initializers/database_ensurer.rb +30 -0
- data/lib/claude_memory/commands/initializers/global_initializer.rb +85 -0
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +156 -0
- data/lib/claude_memory/commands/initializers/mcp_configurator.rb +56 -0
- data/lib/claude_memory/commands/initializers/memory_instructions_writer.rb +135 -0
- data/lib/claude_memory/commands/initializers/project_initializer.rb +111 -0
- data/lib/claude_memory/commands/recover_command.rb +75 -0
- data/lib/claude_memory/commands/registry.rb +5 -1
- data/lib/claude_memory/commands/stats_command.rb +239 -0
- data/lib/claude_memory/commands/uninstall_command.rb +226 -0
- data/lib/claude_memory/core/batch_loader.rb +32 -0
- data/lib/claude_memory/core/concept_ranker.rb +73 -0
- data/lib/claude_memory/core/embedding_candidate_builder.rb +37 -0
- data/lib/claude_memory/core/fact_collector.rb +51 -0
- data/lib/claude_memory/core/fact_query_builder.rb +154 -0
- data/lib/claude_memory/core/fact_ranker.rb +113 -0
- data/lib/claude_memory/core/result_builder.rb +54 -0
- data/lib/claude_memory/core/result_sorter.rb +25 -0
- data/lib/claude_memory/core/scope_filter.rb +61 -0
- data/lib/claude_memory/core/text_builder.rb +29 -0
- data/lib/claude_memory/embeddings/generator.rb +161 -0
- data/lib/claude_memory/embeddings/similarity.rb +69 -0
- data/lib/claude_memory/hook/handler.rb +4 -3
- data/lib/claude_memory/index/lexical_fts.rb +7 -2
- data/lib/claude_memory/infrastructure/operation_tracker.rb +158 -0
- data/lib/claude_memory/infrastructure/schema_validator.rb +206 -0
- data/lib/claude_memory/ingest/content_sanitizer.rb +6 -7
- data/lib/claude_memory/ingest/ingester.rb +99 -15
- data/lib/claude_memory/ingest/metadata_extractor.rb +57 -0
- data/lib/claude_memory/ingest/tool_extractor.rb +71 -0
- data/lib/claude_memory/mcp/response_formatter.rb +331 -0
- data/lib/claude_memory/mcp/server.rb +19 -0
- data/lib/claude_memory/mcp/setup_status_analyzer.rb +73 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +279 -0
- data/lib/claude_memory/mcp/tool_helpers.rb +80 -0
- data/lib/claude_memory/mcp/tools.rb +330 -320
- data/lib/claude_memory/recall/dual_query_template.rb +63 -0
- data/lib/claude_memory/recall.rb +304 -237
- data/lib/claude_memory/resolve/resolver.rb +52 -49
- data/lib/claude_memory/store/sqlite_store.rb +210 -144
- data/lib/claude_memory/store/store_manager.rb +6 -6
- data/lib/claude_memory/sweep/sweeper.rb +6 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +35 -3
- metadata +71 -11
- data/.claude/.mind.mv2.aLCUZd +0 -0
- data/.claude/memory.sqlite3 +0 -0
- data/.mcp.json +0 -11
- /data/docs/{feature_adoption_plan.md → plans/feature_adoption_plan.md} +0 -0
- /data/docs/{feature_adoption_plan_revised.md → plans/feature_adoption_plan_revised.md} +0 -0
- /data/docs/{plan.md → plans/plan.md} +0 -0
- /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
|