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.
- checksums.yaml +4 -4
- data/.claude/.mind.mv2.aLCUZd +0 -0
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +7 -1
- data/.claude/settings.json +0 -4
- data/.claude/settings.local.json +4 -1
- data/.claude-plugin/plugin.json +1 -1
- data/.claude.json +11 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +62 -11
- data/CLAUDE.md +87 -24
- data/README.md +76 -159
- data/docs/EXAMPLES.md +436 -0
- data/docs/RELEASE_NOTES_v0.2.0.md +179 -0
- data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +582 -0
- data/docs/SOCIAL_MEDIA_v0.2.0.md +420 -0
- data/docs/architecture.md +360 -0
- data/docs/expert_review.md +1718 -0
- data/docs/feature_adoption_plan.md +1241 -0
- data/docs/feature_adoption_plan_revised.md +2374 -0
- data/docs/improvements.md +1325 -0
- data/docs/quality_review.md +1544 -0
- data/docs/review_summary.md +480 -0
- data/lefthook.yml +10 -0
- data/lib/claude_memory/cli.rb +16 -844
- data/lib/claude_memory/commands/base_command.rb +95 -0
- data/lib/claude_memory/commands/changes_command.rb +39 -0
- data/lib/claude_memory/commands/conflicts_command.rb +37 -0
- data/lib/claude_memory/commands/db_init_command.rb +40 -0
- data/lib/claude_memory/commands/doctor_command.rb +147 -0
- data/lib/claude_memory/commands/explain_command.rb +65 -0
- data/lib/claude_memory/commands/help_command.rb +37 -0
- data/lib/claude_memory/commands/hook_command.rb +106 -0
- data/lib/claude_memory/commands/ingest_command.rb +47 -0
- data/lib/claude_memory/commands/init_command.rb +218 -0
- data/lib/claude_memory/commands/promote_command.rb +30 -0
- data/lib/claude_memory/commands/publish_command.rb +36 -0
- data/lib/claude_memory/commands/recall_command.rb +61 -0
- data/lib/claude_memory/commands/registry.rb +55 -0
- data/lib/claude_memory/commands/search_command.rb +43 -0
- data/lib/claude_memory/commands/serve_mcp_command.rb +16 -0
- data/lib/claude_memory/commands/sweep_command.rb +36 -0
- data/lib/claude_memory/commands/version_command.rb +13 -0
- data/lib/claude_memory/configuration.rb +38 -0
- data/lib/claude_memory/core/fact_id.rb +41 -0
- data/lib/claude_memory/core/null_explanation.rb +47 -0
- data/lib/claude_memory/core/null_fact.rb +30 -0
- data/lib/claude_memory/core/result.rb +143 -0
- data/lib/claude_memory/core/session_id.rb +37 -0
- data/lib/claude_memory/core/token_estimator.rb +33 -0
- data/lib/claude_memory/core/transcript_path.rb +37 -0
- data/lib/claude_memory/domain/conflict.rb +51 -0
- data/lib/claude_memory/domain/entity.rb +51 -0
- data/lib/claude_memory/domain/fact.rb +70 -0
- data/lib/claude_memory/domain/provenance.rb +48 -0
- data/lib/claude_memory/hook/exit_codes.rb +18 -0
- data/lib/claude_memory/hook/handler.rb +7 -2
- data/lib/claude_memory/index/index_query.rb +89 -0
- data/lib/claude_memory/index/index_query_logic.rb +41 -0
- data/lib/claude_memory/index/query_options.rb +67 -0
- data/lib/claude_memory/infrastructure/file_system.rb +29 -0
- data/lib/claude_memory/infrastructure/in_memory_file_system.rb +32 -0
- data/lib/claude_memory/ingest/content_sanitizer.rb +42 -0
- data/lib/claude_memory/ingest/ingester.rb +3 -0
- data/lib/claude_memory/ingest/privacy_tag.rb +48 -0
- data/lib/claude_memory/mcp/tools.rb +174 -1
- data/lib/claude_memory/publish.rb +29 -20
- data/lib/claude_memory/recall.rb +164 -16
- data/lib/claude_memory/resolve/resolver.rb +41 -37
- data/lib/claude_memory/shortcuts.rb +56 -0
- data/lib/claude_memory/store/store_manager.rb +35 -32
- data/lib/claude_memory/templates/hooks.example.json +0 -4
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +59 -21
- 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
|