claude_memory 0.7.0 → 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 +5 -2
- 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 -2
- data/CHANGELOG.md +74 -1
- data/CLAUDE.md +32 -6
- data/README.md +1 -1
- data/docs/improvements.md +51 -91
- data/docs/influence/lossless-claw.md +409 -0
- 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 +100 -65
- 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/error_classifier.rb +171 -0
- 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 +64 -5
- data/lib/claude_memory/mcp/query_guide.rb +51 -22
- data/lib/claude_memory/mcp/response_formatter.rb +4 -1
- data/lib/claude_memory/mcp/server.rb +1 -0
- data/lib/claude_memory/mcp/text_summary.rb +28 -1
- data/lib/claude_memory/mcp/tool_definitions.rb +33 -3
- data/lib/claude_memory/mcp/tool_helpers.rb +43 -0
- data/lib/claude_memory/mcp/tools.rb +47 -681
- 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 -616
- 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/sweep/maintenance.rb +126 -0
- data/lib/claude_memory/sweep/sweeper.rb +81 -75
- data/lib/claude_memory/templates/hooks.example.json +26 -7
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +12 -0
- data/v0.6.0.ANNOUNCE +32 -0
- metadata +27 -1
|
@@ -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,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module MCP
|
|
5
|
+
# Classifies MCP tool errors into three tiers with structured responses.
|
|
6
|
+
#
|
|
7
|
+
# Benign: Empty results, first use, database not yet initialized.
|
|
8
|
+
# No error surfaced — return helpful guidance instead.
|
|
9
|
+
#
|
|
10
|
+
# Retryable: Database locked, temporary I/O, connection reset.
|
|
11
|
+
# Tool failed but may succeed on retry.
|
|
12
|
+
#
|
|
13
|
+
# Fatal: Schema corruption, invalid parameters, programming bugs.
|
|
14
|
+
# Requires user intervention to resolve.
|
|
15
|
+
#
|
|
16
|
+
# Source: supermemory src/lib/error-helpers.js:1-72
|
|
17
|
+
module ErrorClassifier
|
|
18
|
+
SEVERITY_BENIGN = "benign"
|
|
19
|
+
SEVERITY_RETRYABLE = "retryable"
|
|
20
|
+
SEVERITY_FATAL = "fatal"
|
|
21
|
+
|
|
22
|
+
# Errors that indicate temporary/transient failures
|
|
23
|
+
RETRYABLE_ERROR_NAMES = %w[
|
|
24
|
+
Sequel::DatabaseConnectionError
|
|
25
|
+
Extralite::BusyError
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
RETRYABLE_ERROR_CLASSES = [
|
|
29
|
+
Errno::EACCES,
|
|
30
|
+
Errno::EAGAIN,
|
|
31
|
+
IOError
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# Errors that indicate permanent/programming failures
|
|
35
|
+
FATAL_ERROR_CLASSES = [
|
|
36
|
+
Errno::ENOSPC,
|
|
37
|
+
Errno::EROFS,
|
|
38
|
+
TypeError,
|
|
39
|
+
NoMethodError,
|
|
40
|
+
ArgumentError
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def classify(error)
|
|
46
|
+
if retryable?(error)
|
|
47
|
+
SEVERITY_RETRYABLE
|
|
48
|
+
elsif fatal?(error)
|
|
49
|
+
SEVERITY_FATAL
|
|
50
|
+
elsif database_error?(error)
|
|
51
|
+
# Generic database errors default to retryable
|
|
52
|
+
SEVERITY_RETRYABLE
|
|
53
|
+
else
|
|
54
|
+
# Unknown errors default to fatal to surface issues
|
|
55
|
+
SEVERITY_FATAL
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_error_response(error, tool_name: nil)
|
|
60
|
+
severity = classify(error)
|
|
61
|
+
|
|
62
|
+
case severity
|
|
63
|
+
when SEVERITY_RETRYABLE
|
|
64
|
+
{
|
|
65
|
+
error: "Temporary failure",
|
|
66
|
+
severity: SEVERITY_RETRYABLE,
|
|
67
|
+
message: retryable_message(error),
|
|
68
|
+
tool: tool_name,
|
|
69
|
+
retry: true
|
|
70
|
+
}
|
|
71
|
+
when SEVERITY_FATAL
|
|
72
|
+
{
|
|
73
|
+
error: "Operation failed",
|
|
74
|
+
severity: SEVERITY_FATAL,
|
|
75
|
+
message: fatal_message(error),
|
|
76
|
+
tool: tool_name,
|
|
77
|
+
retry: false,
|
|
78
|
+
recommendations: fatal_recommendations(error)
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_benign_response(reason, tool_name: nil)
|
|
84
|
+
{
|
|
85
|
+
severity: SEVERITY_BENIGN,
|
|
86
|
+
message: benign_message(reason),
|
|
87
|
+
tool: tool_name,
|
|
88
|
+
results: []
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def retryable?(error)
|
|
93
|
+
RETRYABLE_ERROR_CLASSES.any? { |klass| error.is_a?(klass) } ||
|
|
94
|
+
RETRYABLE_ERROR_NAMES.any? { |name| error.class.ancestors.any? { |a| a.name == name } }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fatal?(error)
|
|
98
|
+
FATAL_ERROR_CLASSES.any? { |klass| error.is_a?(klass) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def database_error?(error)
|
|
102
|
+
error.class.ancestors.any? { |a| a.name == "Sequel::DatabaseError" }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def retryable_message(error)
|
|
106
|
+
case error
|
|
107
|
+
when Errno::EACCES
|
|
108
|
+
"Database file is temporarily inaccessible. Another process may hold the lock."
|
|
109
|
+
when Errno::EAGAIN
|
|
110
|
+
"Resource temporarily unavailable. Try again shortly."
|
|
111
|
+
when IOError
|
|
112
|
+
"I/O error during database access. Connection may have been interrupted."
|
|
113
|
+
else
|
|
114
|
+
if error.class.name&.include?("Busy")
|
|
115
|
+
"Database is busy. Another operation is in progress."
|
|
116
|
+
else
|
|
117
|
+
"Temporary database error: #{error.message}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def fatal_message(error)
|
|
123
|
+
case error
|
|
124
|
+
when Errno::ENOSPC
|
|
125
|
+
"No disk space available for database operations."
|
|
126
|
+
when Errno::EROFS
|
|
127
|
+
"Database is on a read-only filesystem."
|
|
128
|
+
when TypeError, NoMethodError
|
|
129
|
+
"Internal error in memory system."
|
|
130
|
+
when ArgumentError
|
|
131
|
+
"Invalid parameters provided."
|
|
132
|
+
else
|
|
133
|
+
"Database error: #{error.message}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def fatal_recommendations(error)
|
|
138
|
+
recs = ["Run memory.check_setup to diagnose the issue"]
|
|
139
|
+
|
|
140
|
+
case error
|
|
141
|
+
when Errno::ENOSPC
|
|
142
|
+
recs << "Free up disk space and retry"
|
|
143
|
+
when Errno::EROFS
|
|
144
|
+
recs << "Check filesystem permissions"
|
|
145
|
+
else
|
|
146
|
+
if error.message.include?("corrupt") || error.message.include?("malformed")
|
|
147
|
+
recs << "Database may be corrupted — run: claude-memory doctor"
|
|
148
|
+
recs << "If unrecoverable: claude-memory init --force"
|
|
149
|
+
else
|
|
150
|
+
recs << "If persistent: claude-memory doctor"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
recs
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def benign_message(reason)
|
|
158
|
+
case reason
|
|
159
|
+
when :no_results
|
|
160
|
+
"No matching facts found. The memory system is working but has no relevant data for this query."
|
|
161
|
+
when :not_initialized
|
|
162
|
+
"Memory system not yet initialized. Facts will be stored as conversations happen."
|
|
163
|
+
when :empty_database
|
|
164
|
+
"Database exists but contains no facts yet. Use store_extraction to add facts."
|
|
165
|
+
else
|
|
166
|
+
"No data available."
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
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
|