claude_memory 0.1.0 → 0.2.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/.mind.mv2.aLCUZd +0 -0
  3. data/.claude/memory.sqlite3 +0 -0
  4. data/.claude/rules/claude_memory.generated.md +7 -1
  5. data/.claude/settings.json +0 -4
  6. data/.claude/settings.local.json +4 -1
  7. data/.claude-plugin/plugin.json +1 -1
  8. data/.claude.json +11 -0
  9. data/.ruby-version +1 -0
  10. data/CHANGELOG.md +62 -11
  11. data/CLAUDE.md +87 -24
  12. data/README.md +76 -159
  13. data/docs/EXAMPLES.md +436 -0
  14. data/docs/RELEASE_NOTES_v0.2.0.md +179 -0
  15. data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +582 -0
  16. data/docs/SOCIAL_MEDIA_v0.2.0.md +420 -0
  17. data/docs/architecture.md +360 -0
  18. data/docs/expert_review.md +1718 -0
  19. data/docs/feature_adoption_plan.md +1241 -0
  20. data/docs/feature_adoption_plan_revised.md +2374 -0
  21. data/docs/improvements.md +1325 -0
  22. data/docs/quality_review.md +1544 -0
  23. data/docs/review_summary.md +480 -0
  24. data/lefthook.yml +10 -0
  25. data/lib/claude_memory/cli.rb +16 -844
  26. data/lib/claude_memory/commands/base_command.rb +95 -0
  27. data/lib/claude_memory/commands/changes_command.rb +39 -0
  28. data/lib/claude_memory/commands/conflicts_command.rb +37 -0
  29. data/lib/claude_memory/commands/db_init_command.rb +40 -0
  30. data/lib/claude_memory/commands/doctor_command.rb +147 -0
  31. data/lib/claude_memory/commands/explain_command.rb +65 -0
  32. data/lib/claude_memory/commands/help_command.rb +37 -0
  33. data/lib/claude_memory/commands/hook_command.rb +106 -0
  34. data/lib/claude_memory/commands/ingest_command.rb +47 -0
  35. data/lib/claude_memory/commands/init_command.rb +218 -0
  36. data/lib/claude_memory/commands/promote_command.rb +30 -0
  37. data/lib/claude_memory/commands/publish_command.rb +36 -0
  38. data/lib/claude_memory/commands/recall_command.rb +61 -0
  39. data/lib/claude_memory/commands/registry.rb +55 -0
  40. data/lib/claude_memory/commands/search_command.rb +43 -0
  41. data/lib/claude_memory/commands/serve_mcp_command.rb +16 -0
  42. data/lib/claude_memory/commands/sweep_command.rb +36 -0
  43. data/lib/claude_memory/commands/version_command.rb +13 -0
  44. data/lib/claude_memory/configuration.rb +38 -0
  45. data/lib/claude_memory/core/fact_id.rb +41 -0
  46. data/lib/claude_memory/core/null_explanation.rb +47 -0
  47. data/lib/claude_memory/core/null_fact.rb +30 -0
  48. data/lib/claude_memory/core/result.rb +143 -0
  49. data/lib/claude_memory/core/session_id.rb +37 -0
  50. data/lib/claude_memory/core/token_estimator.rb +33 -0
  51. data/lib/claude_memory/core/transcript_path.rb +37 -0
  52. data/lib/claude_memory/domain/conflict.rb +51 -0
  53. data/lib/claude_memory/domain/entity.rb +51 -0
  54. data/lib/claude_memory/domain/fact.rb +70 -0
  55. data/lib/claude_memory/domain/provenance.rb +48 -0
  56. data/lib/claude_memory/hook/exit_codes.rb +18 -0
  57. data/lib/claude_memory/hook/handler.rb +7 -2
  58. data/lib/claude_memory/index/index_query.rb +89 -0
  59. data/lib/claude_memory/index/index_query_logic.rb +41 -0
  60. data/lib/claude_memory/index/query_options.rb +67 -0
  61. data/lib/claude_memory/infrastructure/file_system.rb +29 -0
  62. data/lib/claude_memory/infrastructure/in_memory_file_system.rb +32 -0
  63. data/lib/claude_memory/ingest/content_sanitizer.rb +42 -0
  64. data/lib/claude_memory/ingest/ingester.rb +3 -0
  65. data/lib/claude_memory/ingest/privacy_tag.rb +48 -0
  66. data/lib/claude_memory/mcp/tools.rb +174 -1
  67. data/lib/claude_memory/publish.rb +29 -20
  68. data/lib/claude_memory/recall.rb +164 -16
  69. data/lib/claude_memory/resolve/resolver.rb +41 -37
  70. data/lib/claude_memory/shortcuts.rb +56 -0
  71. data/lib/claude_memory/store/store_manager.rb +35 -32
  72. data/lib/claude_memory/templates/hooks.example.json +0 -4
  73. data/lib/claude_memory/version.rb +1 -1
  74. data/lib/claude_memory.rb +59 -21
  75. metadata +55 -1
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Result type for consistent return values across the codebase.
6
+ # Replaces inconsistent nil/integer/hash returns with explicit Success/Failure types.
7
+ #
8
+ # @example Success case
9
+ # result = Result.success(42)
10
+ # result.success? # => true
11
+ # result.value # => 42
12
+ #
13
+ # @example Failure case
14
+ # result = Result.failure("Something went wrong")
15
+ # result.failure? # => true
16
+ # result.error # => "Something went wrong"
17
+ #
18
+ # @example Chaining operations
19
+ # Result.success(5)
20
+ # .map { |v| v * 2 }
21
+ # .flat_map { |v| Result.success(v + 1) }
22
+ # .value # => 11
23
+ class Result
24
+ # Creates a successful result
25
+ # @param value [Object] the success value
26
+ # @return [Success] a success result
27
+ def self.success(value = nil)
28
+ Success.new(value)
29
+ end
30
+
31
+ # Creates a failed result
32
+ # @param error [String, Exception] the error
33
+ # @return [Failure] a failure result
34
+ def self.failure(error)
35
+ Failure.new(error)
36
+ end
37
+
38
+ # @return [Boolean] true if this is a success result
39
+ def success?
40
+ raise NotImplementedError
41
+ end
42
+
43
+ # @return [Boolean] true if this is a failure result
44
+ def failure?
45
+ !success?
46
+ end
47
+
48
+ # @return [Object] the success value
49
+ # @raise [RuntimeError] if called on a failure
50
+ def value
51
+ raise NotImplementedError
52
+ end
53
+
54
+ # @return [String, Exception, nil] the error, or nil for success
55
+ def error
56
+ raise NotImplementedError
57
+ end
58
+
59
+ # Transforms the value if success, otherwise returns self
60
+ # @yield [Object] the success value
61
+ # @return [Result] a new result with the transformed value
62
+ def map
63
+ raise NotImplementedError
64
+ end
65
+
66
+ # Chains another result-returning operation if success
67
+ # @yield [Object] the success value
68
+ # @return [Result] the result from the block, or self if failure
69
+ def flat_map
70
+ raise NotImplementedError
71
+ end
72
+
73
+ # Returns the value if success, otherwise returns the default
74
+ # @param default [Object] the default value
75
+ # @return [Object] the value or default
76
+ def or_else(default)
77
+ raise NotImplementedError
78
+ end
79
+ end
80
+
81
+ # Success result type
82
+ class Success < Result
83
+ attr_reader :value
84
+
85
+ def initialize(value)
86
+ @value = value
87
+ freeze
88
+ end
89
+
90
+ def success?
91
+ true
92
+ end
93
+
94
+ def error
95
+ nil
96
+ end
97
+
98
+ def map
99
+ return self unless block_given?
100
+ Success.new(yield(value))
101
+ end
102
+
103
+ def flat_map
104
+ return self unless block_given?
105
+ yield(value)
106
+ end
107
+
108
+ def or_else(_default)
109
+ value
110
+ end
111
+ end
112
+
113
+ # Failure result type
114
+ class Failure < Result
115
+ attr_reader :error
116
+
117
+ def initialize(error)
118
+ @error = error
119
+ freeze
120
+ end
121
+
122
+ def success?
123
+ false
124
+ end
125
+
126
+ def value
127
+ raise "Cannot get value from Failure: #{error}"
128
+ end
129
+
130
+ def map
131
+ self
132
+ end
133
+
134
+ def flat_map
135
+ self
136
+ end
137
+
138
+ def or_else(default)
139
+ default
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Value object representing a session identifier
6
+ # Provides type safety and validation for session IDs
7
+ class SessionId
8
+ attr_reader :value
9
+
10
+ def initialize(value)
11
+ @value = value.to_s
12
+ validate!
13
+ freeze
14
+ end
15
+
16
+ def to_s
17
+ value
18
+ end
19
+
20
+ def ==(other)
21
+ other.is_a?(SessionId) && other.value == value
22
+ end
23
+
24
+ alias_method :eql?, :==
25
+
26
+ def hash
27
+ value.hash
28
+ end
29
+
30
+ private
31
+
32
+ def validate!
33
+ raise ArgumentError, "Session ID cannot be empty" if value.empty?
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ class TokenEstimator
6
+ # Approximation: ~4 characters per token for English text
7
+ # More accurate for Claude's tokenizer than simple word count
8
+ CHARS_PER_TOKEN = 4.0
9
+
10
+ def self.estimate(text)
11
+ return 0 if text.nil? || text.empty?
12
+
13
+ # Remove extra whitespace and count characters
14
+ normalized = text.strip.gsub(/\s+/, " ")
15
+ chars = normalized.length
16
+
17
+ # Return ceiling to avoid underestimation
18
+ (chars / CHARS_PER_TOKEN).ceil
19
+ end
20
+
21
+ def self.estimate_fact(fact)
22
+ # Estimate tokens for a fact record
23
+ text = [
24
+ fact[:subject_name],
25
+ fact[:predicate],
26
+ fact[:object_literal]
27
+ ].compact.join(" ")
28
+
29
+ estimate(text)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Value object representing a transcript file path
6
+ # Provides type safety and validation for file paths
7
+ class TranscriptPath
8
+ attr_reader :value
9
+
10
+ def initialize(value)
11
+ @value = value.to_s
12
+ validate!
13
+ freeze
14
+ end
15
+
16
+ def to_s
17
+ value
18
+ end
19
+
20
+ def ==(other)
21
+ other.is_a?(TranscriptPath) && other.value == value
22
+ end
23
+
24
+ alias_method :eql?, :==
25
+
26
+ def hash
27
+ value.hash
28
+ end
29
+
30
+ private
31
+
32
+ def validate!
33
+ raise ArgumentError, "Transcript path cannot be empty" if value.empty?
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Domain
5
+ # Domain model representing a conflict between two facts
6
+ class Conflict
7
+ attr_reader :id, :fact_a_id, :fact_b_id, :status, :notes,
8
+ :detected_at, :resolved_at
9
+
10
+ def initialize(attributes)
11
+ @id = attributes[:id]
12
+ @fact_a_id = attributes[:fact_a_id]
13
+ @fact_b_id = attributes[:fact_b_id]
14
+ @status = attributes[:status] || "open"
15
+ @notes = attributes[:notes]
16
+ @detected_at = attributes[:detected_at]
17
+ @resolved_at = attributes[:resolved_at]
18
+
19
+ validate!
20
+ freeze
21
+ end
22
+
23
+ def open?
24
+ status == "open"
25
+ end
26
+
27
+ def resolved?
28
+ status == "resolved"
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ id: id,
34
+ fact_a_id: fact_a_id,
35
+ fact_b_id: fact_b_id,
36
+ status: status,
37
+ notes: notes,
38
+ detected_at: detected_at,
39
+ resolved_at: resolved_at
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def validate!
46
+ raise ArgumentError, "fact_a_id required" if fact_a_id.nil?
47
+ raise ArgumentError, "fact_b_id required" if fact_b_id.nil?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Domain
5
+ # Domain model representing an entity (database, framework, person, etc.)
6
+ class Entity
7
+ attr_reader :id, :type, :canonical_name, :slug, :created_at
8
+
9
+ def initialize(attributes)
10
+ @id = attributes[:id]
11
+ @type = attributes[:type]
12
+ @canonical_name = attributes[:canonical_name]
13
+ @slug = attributes[:slug]
14
+ @created_at = attributes[:created_at]
15
+
16
+ validate!
17
+ freeze
18
+ end
19
+
20
+ def database?
21
+ type == "database"
22
+ end
23
+
24
+ def framework?
25
+ type == "framework"
26
+ end
27
+
28
+ def person?
29
+ type == "person"
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ id: id,
35
+ type: type,
36
+ canonical_name: canonical_name,
37
+ slug: slug,
38
+ created_at: created_at
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def validate!
45
+ raise ArgumentError, "type required" if type.nil? || type.empty?
46
+ raise ArgumentError, "canonical_name required" if canonical_name.nil? || canonical_name.empty?
47
+ raise ArgumentError, "slug required" if slug.nil? || slug.empty?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Domain
5
+ # Domain model representing a fact in the memory system
6
+ # Encapsulates business logic and validation
7
+ class Fact
8
+ attr_reader :id, :subject_name, :predicate, :object_literal,
9
+ :status, :confidence, :scope, :project_path,
10
+ :valid_from, :valid_to, :created_at
11
+
12
+ def initialize(attributes)
13
+ @id = attributes[:id]
14
+ @subject_name = attributes[:subject_name]
15
+ @predicate = attributes[:predicate]
16
+ @object_literal = attributes[:object_literal]
17
+ @status = attributes[:status] || "active"
18
+ @confidence = attributes[:confidence] || 1.0
19
+ @scope = attributes[:scope] || "project"
20
+ @project_path = attributes[:project_path]
21
+ @valid_from = attributes[:valid_from]
22
+ @valid_to = attributes[:valid_to]
23
+ @created_at = attributes[:created_at]
24
+
25
+ validate!
26
+ freeze
27
+ end
28
+
29
+ def active?
30
+ status == "active"
31
+ end
32
+
33
+ def superseded?
34
+ status == "superseded"
35
+ end
36
+
37
+ def global?
38
+ scope == "global"
39
+ end
40
+
41
+ def project?
42
+ scope == "project"
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ id: id,
48
+ subject_name: subject_name,
49
+ predicate: predicate,
50
+ object_literal: object_literal,
51
+ status: status,
52
+ confidence: confidence,
53
+ scope: scope,
54
+ project_path: project_path,
55
+ valid_from: valid_from,
56
+ valid_to: valid_to,
57
+ created_at: created_at
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def validate!
64
+ raise ArgumentError, "predicate required" if predicate.nil? || predicate.empty?
65
+ raise ArgumentError, "object_literal required" if object_literal.nil? || object_literal.empty?
66
+ raise ArgumentError, "confidence must be between 0 and 1" unless (0..1).cover?(confidence)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Domain
5
+ # Domain model representing provenance (evidence) for a fact
6
+ class Provenance
7
+ attr_reader :id, :fact_id, :content_item_id, :quote, :strength, :created_at
8
+
9
+ def initialize(attributes)
10
+ @id = attributes[:id]
11
+ @fact_id = attributes[:fact_id]
12
+ @content_item_id = attributes[:content_item_id]
13
+ @quote = attributes[:quote]
14
+ @strength = attributes[:strength] || "stated"
15
+ @created_at = attributes[:created_at]
16
+
17
+ validate!
18
+ freeze
19
+ end
20
+
21
+ def stated?
22
+ strength == "stated"
23
+ end
24
+
25
+ def inferred?
26
+ strength == "inferred"
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ id: id,
32
+ fact_id: fact_id,
33
+ content_item_id: content_item_id,
34
+ quote: quote,
35
+ strength: strength,
36
+ created_at: created_at
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ def validate!
43
+ raise ArgumentError, "fact_id required" if fact_id.nil?
44
+ raise ArgumentError, "content_item_id required" if content_item_id.nil?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Hook
5
+ module ExitCodes
6
+ # Success or graceful shutdown
7
+ SUCCESS = 0
8
+
9
+ # Non-blocking error (shown to user, session continues)
10
+ # Example: Missing transcript file, database not initialized
11
+ WARNING = 1
12
+
13
+ # Blocking error (fed to Claude for processing)
14
+ # Example: Database corruption, schema mismatch
15
+ ERROR = 2
16
+ end
17
+ end
18
+ end
@@ -3,7 +3,7 @@
3
3
  module ClaudeMemory
4
4
  module Hook
5
5
  class Handler
6
- class PayloadError < Error; end
6
+ class PayloadError < ClaudeMemory::Error; end
7
7
 
8
8
  DEFAULT_SWEEP_BUDGET = 5
9
9
 
@@ -27,6 +27,10 @@ module ClaudeMemory
27
27
  transcript_path: transcript_path,
28
28
  project_path: project_path
29
29
  )
30
+ rescue Ingest::TranscriptReader::FileNotFoundError => e
31
+ # Transcript file doesn't exist (e.g., headless Claude session)
32
+ # This is expected, not an error - return success with no-op status
33
+ {status: :skipped, reason: "transcript_not_found", message: e.message}
30
34
  end
31
35
 
32
36
  def sweep(payload)
@@ -40,9 +44,10 @@ module ClaudeMemory
40
44
  def publish(payload)
41
45
  mode = payload.fetch("mode", "shared").to_sym
42
46
  since = payload["since"]
47
+ rules_dir = payload["rules_dir"]
43
48
 
44
49
  publisher = Publish.new(@store)
45
- publisher.publish!(mode: mode, since: since)
50
+ publisher.publish!(mode: mode, since: since, rules_dir: rules_dir)
46
51
  end
47
52
  end
48
53
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Index
5
+ class IndexQuery
6
+ def initialize(store, options)
7
+ @store = store
8
+ @options = options
9
+ @fts = LexicalFTS.new(store)
10
+ end
11
+
12
+ def execute
13
+ # Query 1: Search FTS for content IDs (1 query)
14
+ content_ids = search_content
15
+
16
+ return [] if content_ids.empty?
17
+
18
+ # Query 2: Batch fetch ALL provenance records (1 query, not N!)
19
+ provenance_by_content = fetch_all_provenance(content_ids)
20
+
21
+ # Pure logic: collect fact IDs (no I/O)
22
+ fact_ids = IndexQueryLogic.collect_fact_ids(
23
+ provenance_by_content,
24
+ content_ids,
25
+ @options.limit
26
+ )
27
+
28
+ return [] if fact_ids.empty?
29
+
30
+ # Query 3: Batch fetch facts with entities (1 query, not N!)
31
+ fetch_facts(fact_ids)
32
+ end
33
+
34
+ private
35
+
36
+ def search_content
37
+ # Fetch 3x limit of content to ensure enough facts after deduplication
38
+ @fts.search(@options.query_text, limit: @options.limit * 3)
39
+ end
40
+
41
+ def fetch_all_provenance(content_ids)
42
+ # Batch query: fetch ALL provenance records at once using WHERE IN
43
+ # This replaces N individual queries (one per content_id)
44
+ @store.provenance
45
+ .select(:fact_id, :content_item_id)
46
+ .where(content_item_id: content_ids)
47
+ .all
48
+ .group_by { |p| p[:content_item_id] }
49
+ end
50
+
51
+ def fetch_facts(fact_ids)
52
+ # Batch query: fetch ALL facts at once using WHERE IN
53
+ # This replaces N individual queries (one per fact_id)
54
+ @store.facts
55
+ .left_join(:entities, id: :subject_entity_id)
56
+ .select(
57
+ Sequel[:facts][:id],
58
+ Sequel[:facts][:predicate],
59
+ Sequel[:facts][:object_literal],
60
+ Sequel[:facts][:status],
61
+ Sequel[:facts][:scope],
62
+ Sequel[:facts][:confidence],
63
+ Sequel[:entities][:canonical_name].as(:subject_name)
64
+ )
65
+ .where(Sequel[:facts][:id] => fact_ids)
66
+ .all
67
+ .map do |fact|
68
+ {
69
+ id: fact[:id],
70
+ subject: fact[:subject_name],
71
+ predicate: fact[:predicate],
72
+ object_preview: truncate_preview(fact[:object_literal]),
73
+ status: fact[:status],
74
+ scope: fact[:scope],
75
+ confidence: fact[:confidence],
76
+ token_estimate: Core::TokenEstimator.estimate_fact(fact),
77
+ source: @options.source
78
+ }
79
+ end
80
+ end
81
+
82
+ def truncate_preview(text)
83
+ return nil if text.nil?
84
+ return text if text.length <= 50
85
+ text[0, 50]
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Index
5
+ module IndexQueryLogic
6
+ # Pure function: collects fact IDs from provenance records
7
+ # No I/O, no side effects - fully testable
8
+ #
9
+ # @param provenance_by_content [Hash] Map of content_item_id => array of provenance records
10
+ # @param content_ids [Array<Integer>] Content IDs in FTS relevance order
11
+ # @param limit [Integer] Maximum number of fact IDs to collect
12
+ # @return [Array<Integer>] Ordered, deduplicated fact IDs
13
+ def self.collect_fact_ids(provenance_by_content, content_ids, limit)
14
+ return [] if limit <= 0
15
+ return [] if content_ids.empty?
16
+
17
+ seen_fact_ids = Set.new
18
+ ordered_fact_ids = []
19
+
20
+ content_ids.each do |content_id|
21
+ provenance_records = provenance_by_content[content_id]
22
+ next unless provenance_records
23
+
24
+ provenance_records.each do |prov|
25
+ fact_id = prov[:fact_id]
26
+ next if seen_fact_ids.include?(fact_id)
27
+
28
+ seen_fact_ids.add(fact_id)
29
+ ordered_fact_ids << fact_id
30
+
31
+ break if ordered_fact_ids.size >= limit
32
+ end
33
+
34
+ break if ordered_fact_ids.size >= limit
35
+ end
36
+
37
+ ordered_fact_ids
38
+ end
39
+ end
40
+ end
41
+ end