claude_memory 0.7.1 → 0.8.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/memory.sqlite3 +0 -0
- data/.claude/memory.sqlite3-shm +0 -0
- data/.claude/memory.sqlite3-wal +0 -0
- data/.claude/settings.json +78 -6
- data/.claude/settings.local.json +2 -1
- data/.claude/skills/improve/SKILL.md +113 -25
- data/.claude-plugin/commands/distill-transcripts.md +98 -0
- data/.claude-plugin/commands/memory-recall.md +67 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +49 -1
- data/CLAUDE.md +29 -5
- data/docs/improvements.md +18 -56
- data/docs/quality_review.md +119 -224
- data/hooks/hooks.json +39 -7
- data/lib/claude_memory/commands/checks/distill_check.rb +61 -0
- data/lib/claude_memory/commands/checks/hooks_check.rb +2 -2
- data/lib/claude_memory/commands/checks/vec_check.rb +2 -1
- data/lib/claude_memory/commands/completion_command.rb +179 -0
- data/lib/claude_memory/commands/doctor_command.rb +2 -0
- data/lib/claude_memory/commands/help_command.rb +4 -0
- data/lib/claude_memory/commands/hook_command.rb +2 -1
- data/lib/claude_memory/commands/index_command.rb +85 -78
- data/lib/claude_memory/commands/initializers/database_ensurer.rb +16 -0
- data/lib/claude_memory/commands/initializers/global_initializer.rb +2 -1
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +55 -11
- data/lib/claude_memory/commands/initializers/project_initializer.rb +2 -1
- data/lib/claude_memory/commands/install_skill_command.rb +78 -0
- data/lib/claude_memory/commands/registry.rb +3 -1
- data/lib/claude_memory/commands/skills/distill-transcripts.md +98 -0
- data/lib/claude_memory/commands/skills/memory-recall.md +67 -0
- data/lib/claude_memory/core/fact_ranker.rb +2 -2
- data/lib/claude_memory/core/rr_fusion.rb +23 -6
- data/lib/claude_memory/core/snippet_extractor.rb +7 -3
- data/lib/claude_memory/core/text_builder.rb +11 -0
- data/lib/claude_memory/domain/provenance.rb +0 -1
- data/lib/claude_memory/embeddings/api_adapter.rb +96 -0
- data/lib/claude_memory/embeddings/dimension_check.rb +23 -0
- data/lib/claude_memory/embeddings/fastembed_adapter.rb +4 -0
- data/lib/claude_memory/embeddings/generator.rb +4 -0
- data/lib/claude_memory/embeddings/resolver.rb +18 -0
- data/lib/claude_memory/hook/context_injector.rb +58 -2
- data/lib/claude_memory/hook/distillation_runner.rb +46 -0
- data/lib/claude_memory/hook/handler.rb +11 -2
- data/lib/claude_memory/index/vector_index.rb +15 -2
- data/lib/claude_memory/infrastructure/schema_validator.rb +3 -3
- data/lib/claude_memory/mcp/handlers/context_handlers.rb +38 -0
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +145 -0
- data/lib/claude_memory/mcp/handlers/query_handlers.rb +115 -0
- data/lib/claude_memory/mcp/handlers/setup_handlers.rb +211 -0
- data/lib/claude_memory/mcp/handlers/shortcut_handlers.rb +37 -0
- data/lib/claude_memory/mcp/handlers/stats_handlers.rb +202 -0
- data/lib/claude_memory/mcp/instructions_builder.rb +2 -1
- data/lib/claude_memory/mcp/query_guide.rb +10 -0
- data/lib/claude_memory/mcp/response_formatter.rb +1 -0
- data/lib/claude_memory/mcp/text_summary.rb +26 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +30 -1
- data/lib/claude_memory/mcp/tool_helpers.rb +43 -0
- data/lib/claude_memory/mcp/tools.rb +39 -678
- data/lib/claude_memory/recall/dual_engine.rb +105 -0
- data/lib/claude_memory/recall/legacy_engine.rb +138 -0
- data/lib/claude_memory/recall/query_core.rb +371 -0
- data/lib/claude_memory/recall.rb +29 -662
- data/lib/claude_memory/shortcuts.rb +4 -4
- data/lib/claude_memory/store/retry_handler.rb +61 -0
- data/lib/claude_memory/store/schema_manager.rb +68 -0
- data/lib/claude_memory/store/sqlite_store.rb +85 -201
- data/lib/claude_memory/templates/hooks.example.json +26 -7
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +11 -0
- metadata +23 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Embeddings
|
|
5
|
+
# Resolves an embedding provider by name or ENV.
|
|
6
|
+
# Three providers: tfidf (default), fastembed, api.
|
|
7
|
+
def self.resolve(name = nil, env: ENV)
|
|
8
|
+
provider = name || env["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] || "tfidf"
|
|
9
|
+
|
|
10
|
+
case provider
|
|
11
|
+
when "tfidf" then Generator.new
|
|
12
|
+
when "fastembed" then FastembedAdapter.new
|
|
13
|
+
when "api" then ApiAdapter.new(env: env)
|
|
14
|
+
else raise ArgumentError, "Unknown embedding provider: #{provider}. Available: tfidf, fastembed, api"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -9,6 +9,10 @@ module ClaudeMemory
|
|
|
9
9
|
MAX_DECISIONS = 5
|
|
10
10
|
MAX_CONVENTIONS = 5
|
|
11
11
|
MAX_ARCHITECTURE = 5
|
|
12
|
+
MAX_UNDISTILLED = 3
|
|
13
|
+
MAX_TEXT_PER_ITEM = 1500
|
|
14
|
+
|
|
15
|
+
FRESH_SESSION_SOURCES = %w[startup resume clear].freeze
|
|
12
16
|
|
|
13
17
|
QUERIES = {
|
|
14
18
|
decisions: {query: "decision constraint rule requirement", scope: "all"},
|
|
@@ -16,8 +20,9 @@ module ClaudeMemory
|
|
|
16
20
|
architecture: {query: "uses framework implements architecture pattern", scope: "all"}
|
|
17
21
|
}.freeze
|
|
18
22
|
|
|
19
|
-
def initialize(manager)
|
|
23
|
+
def initialize(manager, source: nil)
|
|
20
24
|
@manager = manager
|
|
25
|
+
@source = source
|
|
21
26
|
@recall = Recall.new(manager)
|
|
22
27
|
end
|
|
23
28
|
|
|
@@ -33,6 +38,11 @@ module ClaudeMemory
|
|
|
33
38
|
architecture = fetch(:architecture, MAX_ARCHITECTURE)
|
|
34
39
|
sections << format_section("Architecture", architecture) if architecture.any?
|
|
35
40
|
|
|
41
|
+
if fresh_session?
|
|
42
|
+
undistilled = fetch_undistilled(MAX_UNDISTILLED)
|
|
43
|
+
sections << format_distillation_prompt(undistilled) if undistilled.any?
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
return nil if sections.empty?
|
|
37
47
|
|
|
38
48
|
sections.join("\n")
|
|
@@ -40,11 +50,16 @@ module ClaudeMemory
|
|
|
40
50
|
|
|
41
51
|
private
|
|
42
52
|
|
|
53
|
+
def fresh_session?
|
|
54
|
+
@source.nil? || FRESH_SESSION_SOURCES.include?(@source)
|
|
55
|
+
end
|
|
56
|
+
|
|
43
57
|
def fetch(category, limit)
|
|
44
58
|
config = QUERIES.fetch(category)
|
|
45
59
|
results = @recall.query(config[:query], limit: limit, scope: config[:scope])
|
|
46
60
|
results.map { |r| format_fact(r[:fact]) }
|
|
47
|
-
rescue =>
|
|
61
|
+
rescue => e
|
|
62
|
+
ClaudeMemory.logger.debug("ContextInjector#fetch(#{category}) failed: #{e.message}")
|
|
48
63
|
[]
|
|
49
64
|
end
|
|
50
65
|
|
|
@@ -62,6 +77,47 @@ module ClaudeMemory
|
|
|
62
77
|
end
|
|
63
78
|
end
|
|
64
79
|
|
|
80
|
+
def fetch_undistilled(limit)
|
|
81
|
+
stores = []
|
|
82
|
+
stores << @manager.project_store if @manager.project_store
|
|
83
|
+
stores << @manager.global_store if @manager.global_store
|
|
84
|
+
|
|
85
|
+
items = stores.flat_map { |s|
|
|
86
|
+
s.undistilled_content_items(limit: limit, min_length: 200)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
items
|
|
90
|
+
.sort_by { |i| i[:occurred_at] || "" }
|
|
91
|
+
.reverse
|
|
92
|
+
.first(limit)
|
|
93
|
+
rescue => e
|
|
94
|
+
ClaudeMemory.logger.warn("ContextInjector#fetch_undistilled failed: #{e.message}")
|
|
95
|
+
[]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def format_distillation_prompt(items)
|
|
99
|
+
lines = [
|
|
100
|
+
"## Pending Knowledge Extraction",
|
|
101
|
+
"",
|
|
102
|
+
"The following transcript segments haven't been deeply analyzed yet.",
|
|
103
|
+
"Extract facts, entities, and decisions, then call `memory.store_extraction`",
|
|
104
|
+
"followed by `memory.mark_distilled` for each item.",
|
|
105
|
+
"",
|
|
106
|
+
"**What to extract:** technology decisions, conventions, preferences, architecture",
|
|
107
|
+
"**What to skip:** debugging steps, code output, transient errors"
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
items.each do |item|
|
|
111
|
+
ago = Core::RelativeTime.format(item[:occurred_at]) || "unknown"
|
|
112
|
+
truncated = Core::TextBuilder.truncate(item[:raw_text], MAX_TEXT_PER_ITEM)
|
|
113
|
+
lines << ""
|
|
114
|
+
lines << "### Content Item #{item[:id]} (#{ago})"
|
|
115
|
+
lines << truncated
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
lines.join("\n")
|
|
119
|
+
end
|
|
120
|
+
|
|
65
121
|
def format_section(title, items)
|
|
66
122
|
items = items.compact.uniq
|
|
67
123
|
return nil if items.empty?
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Hook
|
|
5
|
+
class DistillationRunner
|
|
6
|
+
MIN_TEXT_LENGTH = 200
|
|
7
|
+
|
|
8
|
+
def initialize(store, distiller: Distill::NullDistiller.new)
|
|
9
|
+
@store = store
|
|
10
|
+
@distiller = distiller
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def distill_item(content_id, project_path:, scope: "project")
|
|
14
|
+
item = @store.get_content_item(content_id)
|
|
15
|
+
return unless item
|
|
16
|
+
|
|
17
|
+
raw_text = item[:raw_text]
|
|
18
|
+
return unless raw_text && raw_text.length >= MIN_TEXT_LENGTH
|
|
19
|
+
|
|
20
|
+
extraction = @distiller.distill(raw_text, content_item_id: content_id)
|
|
21
|
+
return if extraction.empty?
|
|
22
|
+
|
|
23
|
+
resolver = Resolve::Resolver.new(@store)
|
|
24
|
+
@store.db.transaction do
|
|
25
|
+
resolve_result = resolver.apply(
|
|
26
|
+
extraction, content_item_id: content_id,
|
|
27
|
+
project_path: project_path, scope: scope
|
|
28
|
+
)
|
|
29
|
+
@store.record_ingestion_metrics(
|
|
30
|
+
content_item_id: content_id, input_tokens: 0,
|
|
31
|
+
output_tokens: 0, facts_extracted: resolve_result[:facts_created]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
rescue => e
|
|
35
|
+
ClaudeMemory.logger.warn("DistillationRunner#distill_item(#{content_id}) failed: #{e.class} - #{e.message}")
|
|
36
|
+
ClaudeMemory.logger.warn(e.backtrace.first(5).join("\n"))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def distill_batch(project_path:, limit: 5)
|
|
40
|
+
items = @store.undistilled_content_items(limit: limit, min_length: MIN_TEXT_LENGTH)
|
|
41
|
+
items.each { |item| distill_item(item[:id], project_path: project_path) }
|
|
42
|
+
items.size
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -23,12 +23,20 @@ module ClaudeMemory
|
|
|
23
23
|
raise PayloadError, "Missing required field: transcript_path" if transcript_path.nil? || transcript_path.empty?
|
|
24
24
|
|
|
25
25
|
ingester = Ingest::Ingester.new(@store, env: @env)
|
|
26
|
-
ingester.ingest(
|
|
26
|
+
result = ingester.ingest(
|
|
27
27
|
source: "claude_code",
|
|
28
28
|
session_id: session_id,
|
|
29
29
|
transcript_path: transcript_path,
|
|
30
30
|
project_path: project_path
|
|
31
31
|
)
|
|
32
|
+
|
|
33
|
+
if result[:status] == :ingested && result[:content_id]
|
|
34
|
+
DistillationRunner.new(@store).distill_item(
|
|
35
|
+
result[:content_id], project_path: project_path
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result
|
|
32
40
|
rescue Ingest::TranscriptReader::FileNotFoundError => e
|
|
33
41
|
# Transcript file doesn't exist (e.g., headless Claude session)
|
|
34
42
|
# This is expected, not an error - return success with no-op status
|
|
@@ -56,7 +64,8 @@ module ClaudeMemory
|
|
|
56
64
|
manager = @manager || build_manager(payload)
|
|
57
65
|
manager.ensure_both!
|
|
58
66
|
|
|
59
|
-
|
|
67
|
+
source = payload["source"]
|
|
68
|
+
injector = ContextInjector.new(manager, source: source)
|
|
60
69
|
context_text = injector.generate_context
|
|
61
70
|
|
|
62
71
|
{status: :ok, context: context_text}
|
|
@@ -6,13 +6,16 @@ module ClaudeMemory
|
|
|
6
6
|
# Follows the same lazy-init pattern as LexicalFTS:
|
|
7
7
|
# the extension and virtual table are created on first use.
|
|
8
8
|
class VectorIndex
|
|
9
|
-
|
|
9
|
+
DEFAULT_DIMENSIONS = 384
|
|
10
|
+
|
|
11
|
+
attr_reader :dimensions
|
|
10
12
|
|
|
11
13
|
def initialize(store)
|
|
12
14
|
@store = store
|
|
13
15
|
@db = store.db
|
|
14
16
|
@available = nil
|
|
15
17
|
@vec_table_ensured = false
|
|
18
|
+
@dimensions = store.get_meta("embedding_dimensions")&.to_i || DEFAULT_DIMENSIONS
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
# Is the sqlite-vec extension loadable?
|
|
@@ -121,6 +124,16 @@ module ClaudeMemory
|
|
|
121
124
|
indexed_ids.size
|
|
122
125
|
end
|
|
123
126
|
|
|
127
|
+
# Delete all entries from the vec0 virtual table.
|
|
128
|
+
# Used when clearing stale embeddings after a dimension change.
|
|
129
|
+
def clear!
|
|
130
|
+
return false unless available?
|
|
131
|
+
|
|
132
|
+
ensure_vec_table!
|
|
133
|
+
@db.run("DELETE FROM facts_vec")
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
124
137
|
# Number of entries in the vec0 virtual table
|
|
125
138
|
def count
|
|
126
139
|
return 0 unless available?
|
|
@@ -162,7 +175,7 @@ module ClaudeMemory
|
|
|
162
175
|
|
|
163
176
|
@db.run(<<~SQL)
|
|
164
177
|
CREATE VIRTUAL TABLE IF NOT EXISTS facts_vec
|
|
165
|
-
USING vec0(fact_id INTEGER PRIMARY KEY, embedding float[#{
|
|
178
|
+
USING vec0(fact_id INTEGER PRIMARY KEY, embedding float[#{@dimensions}] distance_metric=cosine)
|
|
166
179
|
SQL
|
|
167
180
|
@vec_table_ensured = true
|
|
168
181
|
end
|
|
@@ -166,7 +166,7 @@ module ClaudeMemory
|
|
|
166
166
|
end
|
|
167
167
|
|
|
168
168
|
def check_embedding_dimensions(issues)
|
|
169
|
-
|
|
169
|
+
expected = @store.get_meta("embedding_dimensions")&.to_i || 384
|
|
170
170
|
facts_with_embeddings = @store.facts
|
|
171
171
|
.where(Sequel.~(embedding_json: nil))
|
|
172
172
|
.select(:id, :embedding_json)
|
|
@@ -174,8 +174,8 @@ module ClaudeMemory
|
|
|
174
174
|
|
|
175
175
|
facts_with_embeddings.each do |fact|
|
|
176
176
|
embedding = JSON.parse(fact[:embedding_json])
|
|
177
|
-
if embedding.size !=
|
|
178
|
-
issues << {severity: "error", message: "Fact #{fact[:id]} has embedding with incorrect dimensions (#{embedding.size}, expected
|
|
177
|
+
if embedding.size != expected
|
|
178
|
+
issues << {severity: "error", message: "Fact #{fact[:id]} has embedding with incorrect dimensions (#{embedding.size}, expected #{expected})"}
|
|
179
179
|
break # Only report first occurrence
|
|
180
180
|
end
|
|
181
181
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Handlers
|
|
6
|
+
# Context-aware query handlers (facts by tool, branch, directory)
|
|
7
|
+
module ContextHandlers
|
|
8
|
+
def facts_by_tool(args)
|
|
9
|
+
tool_name = args["tool_name"]
|
|
10
|
+
scope = extract_scope(args)
|
|
11
|
+
limit = extract_limit(args, default: 20)
|
|
12
|
+
|
|
13
|
+
results = @recall.facts_by_tool(tool_name, limit: limit, scope: scope)
|
|
14
|
+
ResponseFormatter.format_tool_facts(tool_name, scope, results)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def facts_by_context(args)
|
|
18
|
+
scope = extract_scope(args)
|
|
19
|
+
limit = extract_limit(args, default: 20)
|
|
20
|
+
|
|
21
|
+
if args["git_branch"]
|
|
22
|
+
results = @recall.facts_by_branch(args["git_branch"], limit: limit, scope: scope)
|
|
23
|
+
context_type = "git_branch"
|
|
24
|
+
context_value = args["git_branch"]
|
|
25
|
+
elsif args["cwd"]
|
|
26
|
+
results = @recall.facts_by_directory(args["cwd"], limit: limit, scope: scope)
|
|
27
|
+
context_type = "cwd"
|
|
28
|
+
context_value = args["cwd"]
|
|
29
|
+
else
|
|
30
|
+
return {error: "Must provide either git_branch or cwd parameter"}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
ResponseFormatter.format_context_facts(context_type, context_value, scope, results)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Handlers
|
|
6
|
+
# Management tool handlers (store_extraction, promote, sweep, changes, conflicts)
|
|
7
|
+
module ManagementHandlers
|
|
8
|
+
def store_extraction(args)
|
|
9
|
+
scope = args["scope"] || "project"
|
|
10
|
+
store = get_store_for_scope(scope)
|
|
11
|
+
return {error: "Database not available"} unless store
|
|
12
|
+
|
|
13
|
+
entities = (args["entities"] || []).map { |e| symbolize_keys(e) }
|
|
14
|
+
facts = (args["facts"] || []).map { |f| symbolize_keys(f) }
|
|
15
|
+
decisions = (args["decisions"] || []).map { |d| symbolize_keys(d) }
|
|
16
|
+
|
|
17
|
+
config = Configuration.new
|
|
18
|
+
project_path = config.project_dir
|
|
19
|
+
occurred_at = Time.now.utc.iso8601
|
|
20
|
+
|
|
21
|
+
searchable_text = Core::TextBuilder.build_searchable_text(entities, facts, decisions)
|
|
22
|
+
content_item_id = create_synthetic_content_item(store, searchable_text, project_path, occurred_at)
|
|
23
|
+
index_content_item(store, content_item_id, searchable_text)
|
|
24
|
+
|
|
25
|
+
extraction = Distill::Extraction.new(
|
|
26
|
+
entities: entities,
|
|
27
|
+
facts: facts,
|
|
28
|
+
decisions: decisions,
|
|
29
|
+
signals: []
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
resolver = Resolve::Resolver.new(store)
|
|
33
|
+
result = resolver.apply(
|
|
34
|
+
extraction,
|
|
35
|
+
content_item_id: content_item_id,
|
|
36
|
+
occurred_at: occurred_at,
|
|
37
|
+
project_path: project_path,
|
|
38
|
+
scope: scope
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
success: true,
|
|
43
|
+
scope: scope,
|
|
44
|
+
entities_created: result[:entities_created],
|
|
45
|
+
facts_created: result[:facts_created],
|
|
46
|
+
facts_superseded: result[:facts_superseded],
|
|
47
|
+
conflicts_created: result[:conflicts_created]
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def promote(args)
|
|
52
|
+
return {error: "Promote requires StoreManager"} unless @manager
|
|
53
|
+
|
|
54
|
+
fact_id = args["fact_id"]
|
|
55
|
+
global_fact_id = @manager.promote_fact(fact_id)
|
|
56
|
+
|
|
57
|
+
if global_fact_id
|
|
58
|
+
{
|
|
59
|
+
success: true,
|
|
60
|
+
project_fact_id: fact_id,
|
|
61
|
+
global_fact_id: global_fact_id,
|
|
62
|
+
message: "Fact promoted to global memory"
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
{error: "Fact #{fact_id} not found in project database"}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sweep_now(args)
|
|
70
|
+
scope = args["scope"] || "project"
|
|
71
|
+
store = get_store_for_scope(scope)
|
|
72
|
+
return {error: "Database not available"} unless store
|
|
73
|
+
|
|
74
|
+
sweeper = Sweep::Sweeper.new(store)
|
|
75
|
+
budget = args["budget_seconds"] || 5
|
|
76
|
+
stats = if args["escalate"]
|
|
77
|
+
sweeper.run_with_escalation!(budget_seconds: budget)
|
|
78
|
+
else
|
|
79
|
+
sweeper.run!(budget_seconds: budget)
|
|
80
|
+
end
|
|
81
|
+
ResponseFormatter.format_sweep_stats(scope, stats)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def changes(args)
|
|
85
|
+
since = args["since"] || (Time.now - 86400 * 7).utc.iso8601
|
|
86
|
+
scope = args["scope"] || "all"
|
|
87
|
+
list = @recall.changes(since: since, limit: args["limit"] || 20, scope: scope)
|
|
88
|
+
ResponseFormatter.format_changes(since, list)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def conflicts(args)
|
|
92
|
+
scope = args["scope"] || "all"
|
|
93
|
+
list = @recall.conflicts(scope: scope)
|
|
94
|
+
ResponseFormatter.format_conflicts(list)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def mark_distilled(args)
|
|
98
|
+
content_item_id = args["content_item_id"]
|
|
99
|
+
facts_extracted = args["facts_extracted"] || 0
|
|
100
|
+
|
|
101
|
+
store = find_store_for_content_item(content_item_id)
|
|
102
|
+
return {error: "Content item #{content_item_id} not found"} unless store
|
|
103
|
+
|
|
104
|
+
store.record_ingestion_metrics(
|
|
105
|
+
content_item_id: content_item_id,
|
|
106
|
+
input_tokens: 0,
|
|
107
|
+
output_tokens: 0,
|
|
108
|
+
facts_extracted: facts_extracted
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
success: true,
|
|
113
|
+
content_item_id: content_item_id,
|
|
114
|
+
facts_extracted: facts_extracted
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def create_synthetic_content_item(store, text, project_path, occurred_at)
|
|
121
|
+
text_hash = Digest::SHA256.hexdigest(text)
|
|
122
|
+
store.upsert_content_item(
|
|
123
|
+
source: "mcp_extraction",
|
|
124
|
+
session_id: "mcp-#{Time.now.to_i}",
|
|
125
|
+
transcript_path: nil,
|
|
126
|
+
project_path: project_path,
|
|
127
|
+
text_hash: text_hash,
|
|
128
|
+
byte_len: text.bytesize,
|
|
129
|
+
raw_text: text,
|
|
130
|
+
occurred_at: occurred_at
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def index_content_item(store, content_item_id, text)
|
|
135
|
+
fts = Index::LexicalFTS.new(store)
|
|
136
|
+
fts.index_content_item(content_item_id, text)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def symbolize_keys(hash)
|
|
140
|
+
Core::TextBuilder.symbolize_keys(hash)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module MCP
|
|
5
|
+
module Handlers
|
|
6
|
+
# Query and recall tool handlers
|
|
7
|
+
module QueryHandlers
|
|
8
|
+
def recall(args)
|
|
9
|
+
return database_not_found_error unless databases_exist?
|
|
10
|
+
|
|
11
|
+
scope = extract_scope(args)
|
|
12
|
+
limit = extract_limit(args)
|
|
13
|
+
compact = args["compact"] == true
|
|
14
|
+
query = args["query"]
|
|
15
|
+
intent = extract_intent(args)
|
|
16
|
+
results = @recall.query(query, limit: limit, scope: scope, include_raw_text: !compact, intent: intent)
|
|
17
|
+
ResponseFormatter.format_recall_results(results, compact: compact, query: query)
|
|
18
|
+
rescue Sequel::DatabaseError, Sequel::DatabaseConnectionError, Errno::ENOENT => e
|
|
19
|
+
classified_error(e, tool_name: "memory.recall")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def recall_index(args)
|
|
23
|
+
scope = extract_scope(args)
|
|
24
|
+
limit = extract_limit(args, default: 20)
|
|
25
|
+
intent = extract_intent(args)
|
|
26
|
+
results = @recall.query_index(args["query"], limit: limit, scope: scope, intent: intent)
|
|
27
|
+
ResponseFormatter.format_index_results(args["query"], scope, results)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def recall_details(args)
|
|
31
|
+
fact_ids = args["fact_ids"]
|
|
32
|
+
scope = args["scope"] || "project"
|
|
33
|
+
|
|
34
|
+
explanations = fact_ids.map do |fact_id|
|
|
35
|
+
explanation = @recall.explain(fact_id, scope: scope)
|
|
36
|
+
next nil if explanation.is_a?(Core::NullExplanation)
|
|
37
|
+
|
|
38
|
+
ResponseFormatter.format_detailed_explanation(explanation)
|
|
39
|
+
end.compact
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
fact_count: explanations.size,
|
|
43
|
+
facts: explanations
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def explain(args)
|
|
48
|
+
scope = args["scope"] || "project"
|
|
49
|
+
explanation = @recall.explain(args["fact_id"], scope: scope)
|
|
50
|
+
return {error: "Fact not found in #{scope} database"} if explanation.is_a?(Core::NullExplanation)
|
|
51
|
+
|
|
52
|
+
ResponseFormatter.format_explanation(explanation, scope)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def recall_semantic(args)
|
|
56
|
+
query = args["query"]
|
|
57
|
+
mode = (args["mode"] || "both").to_sym
|
|
58
|
+
scope = extract_scope(args)
|
|
59
|
+
limit = extract_limit(args)
|
|
60
|
+
compact = args["compact"] == true
|
|
61
|
+
explain = args["explain"] == true
|
|
62
|
+
intent = extract_intent(args)
|
|
63
|
+
|
|
64
|
+
results = @recall.query_semantic(query, limit: limit, scope: scope, mode: mode, explain: explain, intent: intent)
|
|
65
|
+
ResponseFormatter.format_semantic_results(query, mode.to_s, scope, results, compact: compact)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def search_concepts(args)
|
|
69
|
+
concepts = args["concepts"]
|
|
70
|
+
scope = extract_scope(args)
|
|
71
|
+
limit = extract_limit(args)
|
|
72
|
+
compact = args["compact"] == true
|
|
73
|
+
|
|
74
|
+
return {error: "Must provide 2-5 concepts"} unless (2..5).cover?(concepts.size)
|
|
75
|
+
|
|
76
|
+
results = @recall.query_concepts(concepts, limit: limit, scope: scope)
|
|
77
|
+
ResponseFormatter.format_concept_results(concepts, scope, results, compact: compact)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fact_graph(args)
|
|
81
|
+
fact_id = args["fact_id"]
|
|
82
|
+
depth = args["depth"] || 2
|
|
83
|
+
scope = args["scope"] || "project"
|
|
84
|
+
|
|
85
|
+
graph = @recall.fact_graph(fact_id, depth: depth, scope: scope)
|
|
86
|
+
|
|
87
|
+
return {error: "Fact #{fact_id} not found in #{scope} database"} if graph[:node_count] == 0
|
|
88
|
+
|
|
89
|
+
graph
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def undistilled(args)
|
|
93
|
+
limit = args["limit"] || 3
|
|
94
|
+
min_length = args["min_length"] || 200
|
|
95
|
+
max_text = 2000
|
|
96
|
+
|
|
97
|
+
items = collect_undistilled_items(limit: limit, min_length: min_length)
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
count: items.size,
|
|
101
|
+
items: items.map { |item|
|
|
102
|
+
{
|
|
103
|
+
content_item_id: item[:id],
|
|
104
|
+
occurred_at: item[:occurred_at],
|
|
105
|
+
occurred_ago: Core::RelativeTime.format(item[:occurred_at]),
|
|
106
|
+
project_path: item[:project_path],
|
|
107
|
+
raw_text: Core::TextBuilder.truncate(item[:raw_text], max_text)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|