claude_memory 0.8.0 → 0.9.1
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/rules/claude_memory.generated.md +94 -2
- data/.claude/settings.json +30 -52
- data/.claude/settings.local.json +3 -1
- data/.claude/skills/release/SKILL.md +168 -0
- data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
- data/.claude-plugin/marketplace.json +2 -2
- data/.claude-plugin/plugin.json +3 -3
- data/.claude-plugin/scripts/hook-runner.sh +14 -0
- data/.claude-plugin/scripts/serve-mcp.sh +14 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +47 -0
- data/CLAUDE.md +31 -17
- data/README.md +35 -0
- data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
- data/db/migrations/014_canonicalize_predicates.rb +30 -0
- data/docs/improvements.md +58 -20
- data/docs/influence/claude-mem.md +1 -0
- data/docs/influence/claude-supermemory.md +1 -0
- data/docs/influence/episodic-memory.md +1 -0
- data/docs/influence/grepai.md +1 -0
- data/docs/influence/kbs.md +1 -0
- data/docs/influence/lossless-claw.md +1 -0
- data/docs/influence/qmd.md +1 -0
- data/lib/claude_memory/commands/completion_command.rb +1 -31
- data/lib/claude_memory/commands/embeddings_command.rb +198 -0
- data/lib/claude_memory/commands/help_command.rb +8 -1
- data/lib/claude_memory/commands/registry.rb +47 -34
- data/lib/claude_memory/commands/reject_command.rb +62 -0
- data/lib/claude_memory/commands/restore_command.rb +77 -0
- data/lib/claude_memory/commands/skills/distill-transcripts.md +5 -1
- data/lib/claude_memory/commands/stats_command.rb +98 -2
- data/lib/claude_memory/configuration.rb +14 -1
- data/lib/claude_memory/distill/json_schema.md +8 -4
- data/lib/claude_memory/distill/null_distiller.rb +2 -0
- data/lib/claude_memory/domain/entity.rb +13 -1
- data/lib/claude_memory/domain/fact.rb +26 -2
- data/lib/claude_memory/embeddings/api_adapter.rb +5 -4
- data/lib/claude_memory/embeddings/fastembed_adapter.rb +43 -13
- data/lib/claude_memory/embeddings/inspector.rb +91 -0
- data/lib/claude_memory/embeddings/model_registry.rb +210 -0
- data/lib/claude_memory/embeddings/resolver.rb +32 -6
- data/lib/claude_memory/ingest/ingester.rb +17 -0
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +24 -0
- data/lib/claude_memory/mcp/handlers/stats_handlers.rb +5 -2
- data/lib/claude_memory/mcp/instructions_builder.rb +17 -0
- data/lib/claude_memory/mcp/server.rb +30 -3
- data/lib/claude_memory/mcp/telemetry.rb +86 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +86 -3
- data/lib/claude_memory/mcp/tools.rb +10 -0
- data/lib/claude_memory/publish.rb +40 -5
- data/lib/claude_memory/recall.rb +81 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
- data/lib/claude_memory/resolve/resolver.rb +43 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +250 -1
- data/lib/claude_memory/store/store_manager.rb +50 -1
- data/lib/claude_memory/sweep/maintenance.rb +115 -1
- data/lib/claude_memory/sweep/sweeper.rb +3 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +5 -0
- metadata +27 -8
- data/.claude/memory.sqlite3-shm +0 -0
- data/.claude/memory.sqlite3-wal +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Commands
|
|
5
|
+
# Shows embedding configuration, lists available models, and validates setup.
|
|
6
|
+
#
|
|
7
|
+
# Subcommands:
|
|
8
|
+
# claude-memory embeddings # Show current config
|
|
9
|
+
# claude-memory embeddings list # List available models
|
|
10
|
+
# claude-memory embeddings check # Validate current setup
|
|
11
|
+
#
|
|
12
|
+
class EmbeddingsCommand < BaseCommand
|
|
13
|
+
def call(args)
|
|
14
|
+
opts = parse_options(args, {}) do |o|
|
|
15
|
+
OptionParser.new do |parser|
|
|
16
|
+
parser.banner = "Usage: claude-memory embeddings [list|check]"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
return 1 if opts.nil?
|
|
20
|
+
|
|
21
|
+
subcommand = args.first
|
|
22
|
+
|
|
23
|
+
case subcommand
|
|
24
|
+
when "list" then list_models
|
|
25
|
+
when "check" then check_setup
|
|
26
|
+
when nil then show_config
|
|
27
|
+
else
|
|
28
|
+
failure("Unknown subcommand: #{subcommand}. Use: list, check")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def inspector
|
|
35
|
+
@inspector ||= Embeddings::Inspector.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def show_config
|
|
39
|
+
provider = ENV["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] || "tfidf"
|
|
40
|
+
model = ENV["CLAUDE_MEMORY_EMBEDDING_MODEL"]
|
|
41
|
+
api_url = ENV["CLAUDE_MEMORY_EMBEDDING_API_URL"]
|
|
42
|
+
|
|
43
|
+
stdout.puts "Embedding Configuration"
|
|
44
|
+
stdout.puts "======================"
|
|
45
|
+
stdout.puts "Provider: #{provider}"
|
|
46
|
+
stdout.puts "Model: #{model || "(default)"}"
|
|
47
|
+
|
|
48
|
+
if model
|
|
49
|
+
info = Embeddings::ModelRegistry.find(model)
|
|
50
|
+
if info
|
|
51
|
+
stdout.puts "Dimensions: #{info.dimensions}"
|
|
52
|
+
stdout.puts "Description: #{info.description}"
|
|
53
|
+
else
|
|
54
|
+
stdout.puts "Dimensions: (unknown - will be discovered at runtime)"
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
info = Embeddings::ModelRegistry.default_for_provider(provider)
|
|
58
|
+
if info
|
|
59
|
+
stdout.puts "Default model: #{info.name}"
|
|
60
|
+
stdout.puts "Dimensions: #{info.dimensions}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
stdout.puts "API URL: #{api_url}" if api_url && provider == "api"
|
|
65
|
+
|
|
66
|
+
inspector.database_states.each do |state|
|
|
67
|
+
stdout.puts ""
|
|
68
|
+
stdout.puts "#{state.label.capitalize} DB: provider=#{state.provider || "unknown"}, dimensions=#{state.dimensions || "unknown"}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
stdout.puts ""
|
|
72
|
+
stdout.puts "ENV variables:"
|
|
73
|
+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_PROVIDER Provider (tfidf, fastembed, api)"
|
|
74
|
+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_MODEL Model name"
|
|
75
|
+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_API_KEY API key (for api provider)"
|
|
76
|
+
stdout.puts " CLAUDE_MEMORY_EMBEDDING_API_URL API endpoint (for api provider)"
|
|
77
|
+
0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def list_models
|
|
81
|
+
Embeddings::ModelRegistry.providers.each do |provider|
|
|
82
|
+
stdout.puts ""
|
|
83
|
+
stdout.puts "#{provider_label(provider)}:"
|
|
84
|
+
stdout.puts "-" * 40
|
|
85
|
+
|
|
86
|
+
Embeddings::ModelRegistry.models_for_provider(provider).each do |model|
|
|
87
|
+
size = model.size_mb ? "#{model.size_mb}MB" : "cloud"
|
|
88
|
+
tokens = model.max_tokens ? "#{model.max_tokens} tokens" : ""
|
|
89
|
+
stdout.puts " #{model.name}"
|
|
90
|
+
stdout.puts " #{model.dimensions}-dim | #{size} | #{tokens}"
|
|
91
|
+
stdout.puts " #{model.description}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
stdout.puts ""
|
|
96
|
+
stdout.puts "Custom models: Set CLAUDE_MEMORY_EMBEDDING_MODEL to any model"
|
|
97
|
+
stdout.puts "supported by your provider. Dimensions are auto-detected."
|
|
98
|
+
0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check_setup
|
|
102
|
+
provider_name = ENV["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] || "tfidf"
|
|
103
|
+
model_name = ENV["CLAUDE_MEMORY_EMBEDDING_MODEL"]
|
|
104
|
+
|
|
105
|
+
stdout.puts "Checking embedding setup..."
|
|
106
|
+
stdout.puts ""
|
|
107
|
+
|
|
108
|
+
ok = true
|
|
109
|
+
ok &= check_provider(provider_name)
|
|
110
|
+
ok &= check_model(provider_name, model_name) if model_name
|
|
111
|
+
ok &= render_dimension_checks(provider_name, model_name)
|
|
112
|
+
|
|
113
|
+
stdout.puts ""
|
|
114
|
+
stdout.puts ok ? "All checks passed." : "Some checks failed. See above."
|
|
115
|
+
ok ? 0 : 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check_provider(name)
|
|
119
|
+
case name
|
|
120
|
+
when "fastembed"
|
|
121
|
+
check_fastembed
|
|
122
|
+
when "api"
|
|
123
|
+
check_api_config
|
|
124
|
+
when "tfidf"
|
|
125
|
+
stdout.puts " [OK] tfidf provider (built-in, always available)"
|
|
126
|
+
true
|
|
127
|
+
else
|
|
128
|
+
stdout.puts " [FAIL] Unknown provider: #{name}"
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def check_model(provider_name, model_name)
|
|
134
|
+
info = Embeddings::ModelRegistry.find(model_name)
|
|
135
|
+
if info
|
|
136
|
+
if info.provider != provider_name
|
|
137
|
+
stdout.puts " [WARN] Model '#{model_name}' is for '#{info.provider}' provider, but '#{provider_name}' is selected"
|
|
138
|
+
stdout.puts " Set CLAUDE_MEMORY_EMBEDDING_PROVIDER=#{info.provider}"
|
|
139
|
+
else
|
|
140
|
+
stdout.puts " [OK] Model '#{model_name}' (#{info.dimensions}-dim)"
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
stdout.puts " [INFO] Model '#{model_name}' not in registry (dimensions will be auto-detected)"
|
|
144
|
+
end
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def render_dimension_checks(provider_name, model_name)
|
|
149
|
+
ok = true
|
|
150
|
+
|
|
151
|
+
inspector.dimension_checks(provider_name, model_name).each do |check|
|
|
152
|
+
case check.status
|
|
153
|
+
when :mismatch
|
|
154
|
+
stdout.puts " [WARN] #{check.label}: Dimension mismatch (stored: #{check.stored_dims}, current: #{check.current_dims})"
|
|
155
|
+
stdout.puts " Re-index with: claude-memory index --force --scope #{check.label}"
|
|
156
|
+
ok = false
|
|
157
|
+
when :match
|
|
158
|
+
stdout.puts " [OK] #{check.label}: #{check.stored_dims}-dim (provider: #{check.stored_provider || "unknown"})"
|
|
159
|
+
when :fresh
|
|
160
|
+
stdout.puts " [INFO] #{check.label}: No embeddings indexed yet"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
ok
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def check_fastembed
|
|
168
|
+
require "fastembed"
|
|
169
|
+
stdout.puts " [OK] fastembed gem available"
|
|
170
|
+
true
|
|
171
|
+
rescue LoadError
|
|
172
|
+
stdout.puts " [FAIL] fastembed gem not installed"
|
|
173
|
+
stdout.puts " Add `gem 'fastembed'` to your Gemfile"
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def check_api_config
|
|
178
|
+
key = ENV["CLAUDE_MEMORY_EMBEDDING_API_KEY"] || ENV["OPENAI_API_KEY"]
|
|
179
|
+
if key
|
|
180
|
+
stdout.puts " [OK] API key configured"
|
|
181
|
+
true
|
|
182
|
+
else
|
|
183
|
+
stdout.puts " [FAIL] No API key found"
|
|
184
|
+
stdout.puts " Set CLAUDE_MEMORY_EMBEDDING_API_KEY or OPENAI_API_KEY"
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def provider_label(provider)
|
|
190
|
+
case provider
|
|
191
|
+
when "fastembed" then "fastembed (local ONNX, no API key)"
|
|
192
|
+
when "api" then "api (OpenAI-compatible endpoints, requires API key)"
|
|
193
|
+
when "tfidf" then "tfidf (built-in, no dependencies)"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -19,20 +19,27 @@ module ClaudeMemory
|
|
|
19
19
|
explain Explain a fact with receipts
|
|
20
20
|
export Export facts to JSON for backup
|
|
21
21
|
help Show this help message
|
|
22
|
-
hook Run hook entrypoints (ingest|sweep|publish)
|
|
22
|
+
hook Run hook entrypoints (ingest|sweep|publish|context)
|
|
23
23
|
init Initialize ClaudeMemory in a project
|
|
24
24
|
ingest Ingest transcript delta
|
|
25
25
|
promote Promote a project fact to global memory
|
|
26
26
|
publish Publish snapshot to Claude Code memory
|
|
27
27
|
recall Recall facts matching a query
|
|
28
|
+
recover Recover stuck operations
|
|
29
|
+
reject Mark a fact as rejected (e.g. hallucination)
|
|
30
|
+
restore Restore superseded facts from reclassified predicates
|
|
28
31
|
search Search indexed content
|
|
29
32
|
serve-mcp Start MCP server
|
|
33
|
+
stats Show statistics (--tools for MCP telemetry)
|
|
30
34
|
sweep Run maintenance/pruning
|
|
31
35
|
uninstall Remove ClaudeMemory configuration
|
|
32
36
|
version Show version number
|
|
33
37
|
|
|
34
38
|
Utilities:
|
|
35
39
|
completion Generate shell completions (bash/zsh)
|
|
40
|
+
embeddings Inspect embedding backend
|
|
41
|
+
git-lfs Git LFS integration for memory DB
|
|
42
|
+
index Build or rebuild content indexes
|
|
36
43
|
install-skill Install agent skills to ~/.claude/commands/
|
|
37
44
|
|
|
38
45
|
Run 'claude-memory <command> --help' for more information on a command.
|
|
@@ -3,48 +3,48 @@
|
|
|
3
3
|
module ClaudeMemory
|
|
4
4
|
module Commands
|
|
5
5
|
# Registry for CLI command lookup and dispatch
|
|
6
|
-
# Maps command names to command classes
|
|
6
|
+
# Maps command names to command classes and short descriptions.
|
|
7
|
+
# Descriptions are authoritative for shell completion and help output;
|
|
8
|
+
# keep them current when adding commands.
|
|
7
9
|
class Registry
|
|
8
|
-
# Map of command names to class
|
|
9
|
-
# As more commands are extracted, add them here
|
|
10
|
+
# Map of command names to {class:, description:} entries.
|
|
11
|
+
# As more commands are extracted, add them here.
|
|
10
12
|
COMMANDS = {
|
|
11
|
-
"help" =>
|
|
12
|
-
"version" =>
|
|
13
|
-
"doctor" =>
|
|
14
|
-
"stats" =>
|
|
15
|
-
"promote" =>
|
|
16
|
-
"search" =>
|
|
17
|
-
"explain" =>
|
|
18
|
-
"conflicts" =>
|
|
19
|
-
"changes" =>
|
|
20
|
-
"recall" =>
|
|
21
|
-
"sweep" =>
|
|
22
|
-
"ingest" =>
|
|
23
|
-
"publish" =>
|
|
24
|
-
"db:init" =>
|
|
25
|
-
"init" =>
|
|
26
|
-
"uninstall" =>
|
|
27
|
-
"serve-mcp" =>
|
|
28
|
-
"hook" =>
|
|
29
|
-
"index" =>
|
|
30
|
-
"recover" =>
|
|
31
|
-
"compact" =>
|
|
32
|
-
"export" =>
|
|
33
|
-
"git-lfs" =>
|
|
34
|
-
"install-skill" =>
|
|
35
|
-
"completion" =>
|
|
13
|
+
"help" => {class: HelpCommand, description: "Show help message"},
|
|
14
|
+
"version" => {class: VersionCommand, description: "Show version"},
|
|
15
|
+
"doctor" => {class: DoctorCommand, description: "Check system health"},
|
|
16
|
+
"stats" => {class: StatsCommand, description: "Show statistics"},
|
|
17
|
+
"promote" => {class: PromoteCommand, description: "Promote fact to global"},
|
|
18
|
+
"search" => {class: SearchCommand, description: "Search indexed content"},
|
|
19
|
+
"explain" => {class: ExplainCommand, description: "Explain a fact with receipts"},
|
|
20
|
+
"conflicts" => {class: ConflictsCommand, description: "Show open conflicts"},
|
|
21
|
+
"changes" => {class: ChangesCommand, description: "Show recent fact changes"},
|
|
22
|
+
"recall" => {class: RecallCommand, description: "Recall facts matching query"},
|
|
23
|
+
"sweep" => {class: SweepCommand, description: "Run maintenance"},
|
|
24
|
+
"ingest" => {class: IngestCommand, description: "Ingest transcript delta"},
|
|
25
|
+
"publish" => {class: PublishCommand, description: "Publish snapshot"},
|
|
26
|
+
"db:init" => {class: DbInitCommand, description: "Initialize database"},
|
|
27
|
+
"init" => {class: InitCommand, description: "Initialize ClaudeMemory"},
|
|
28
|
+
"uninstall" => {class: UninstallCommand, description: "Remove configuration"},
|
|
29
|
+
"serve-mcp" => {class: ServeMcpCommand, description: "Start MCP server"},
|
|
30
|
+
"hook" => {class: HookCommand, description: "Run hook entrypoints"},
|
|
31
|
+
"index" => {class: IndexCommand, description: "Index content"},
|
|
32
|
+
"recover" => {class: RecoverCommand, description: "Recover database"},
|
|
33
|
+
"compact" => {class: CompactCommand, description: "Compact databases"},
|
|
34
|
+
"export" => {class: ExportCommand, description: "Export facts to JSON"},
|
|
35
|
+
"git-lfs" => {class: GitLfsCommand, description: "Git LFS integration"},
|
|
36
|
+
"install-skill" => {class: InstallSkillCommand, description: "Install agent skills"},
|
|
37
|
+
"completion" => {class: CompletionCommand, description: "Generate shell completions"},
|
|
38
|
+
"embeddings" => {class: EmbeddingsCommand, description: "Inspect embedding backend"},
|
|
39
|
+
"reject" => {class: RejectCommand, description: "Mark a fact as rejected"},
|
|
40
|
+
"restore" => {class: RestoreCommand, description: "Restore superseded facts from obsolete single-value classification"}
|
|
36
41
|
}.freeze
|
|
37
42
|
|
|
38
43
|
# Find a command class by name
|
|
39
44
|
# @param command_name [String] the command name (e.g., "help", "version")
|
|
40
45
|
# @return [Class, nil] the command class, or nil if not found
|
|
41
46
|
def self.find(command_name)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class_name = COMMANDS[command_name]
|
|
45
|
-
return nil unless class_name
|
|
46
|
-
|
|
47
|
-
Commands.const_get(class_name)
|
|
47
|
+
COMMANDS.dig(command_name, :class)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# Get all registered command names
|
|
@@ -59,6 +59,19 @@ module ClaudeMemory
|
|
|
59
59
|
def self.registered?(command_name)
|
|
60
60
|
COMMANDS.key?(command_name)
|
|
61
61
|
end
|
|
62
|
+
|
|
63
|
+
# Get the short description for a command
|
|
64
|
+
# @param command_name [String] the command name
|
|
65
|
+
# @return [String, nil] the description, or nil if not registered
|
|
66
|
+
def self.description(command_name)
|
|
67
|
+
COMMANDS.dig(command_name, :description)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get all command descriptions as a hash
|
|
71
|
+
# @return [Hash{String => String}] command name → description
|
|
72
|
+
def self.descriptions
|
|
73
|
+
COMMANDS.transform_values { |entry| entry[:description] }
|
|
74
|
+
end
|
|
62
75
|
end
|
|
63
76
|
end
|
|
64
77
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Commands
|
|
7
|
+
# Mark a fact as rejected (e.g. a distiller hallucination).
|
|
8
|
+
# Closes any open conflicts involving the fact.
|
|
9
|
+
class RejectCommand < BaseCommand
|
|
10
|
+
# @param args [Array<String>] command line arguments (fact_id_or_docid, --scope, --reason)
|
|
11
|
+
# @return [Integer] exit code (0 for success, 1 for failure)
|
|
12
|
+
def call(args)
|
|
13
|
+
opts = parse_options(args, {scope: "project", reason: nil}) do |o|
|
|
14
|
+
OptionParser.new do |parser|
|
|
15
|
+
parser.banner = "Usage: claude-memory reject <fact_id_or_docid> [options]"
|
|
16
|
+
parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
|
|
17
|
+
parser.on("--reason TEXT", "Why this fact is wrong (recorded in conflict notes)") { |v| o[:reason] = v }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
return 1 if opts.nil?
|
|
21
|
+
|
|
22
|
+
identifier = args.first
|
|
23
|
+
return failure("Usage: claude-memory reject <fact_id_or_docid> [options]") if identifier.nil? || identifier.empty?
|
|
24
|
+
|
|
25
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
26
|
+
store = manager.store_for_scope(opts[:scope])
|
|
27
|
+
|
|
28
|
+
fact_id = resolve_fact_id(store, identifier)
|
|
29
|
+
unless fact_id
|
|
30
|
+
stderr.puts "Fact '#{identifier}' not found in #{opts[:scope]} database."
|
|
31
|
+
manager.close
|
|
32
|
+
return 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
result = store.reject_fact(fact_id, reason: opts[:reason])
|
|
36
|
+
manager.close
|
|
37
|
+
|
|
38
|
+
if result.nil?
|
|
39
|
+
stderr.puts "Fact ##{fact_id} not found."
|
|
40
|
+
return 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
stdout.puts "Rejected fact ##{fact_id} in #{opts[:scope]} database."
|
|
44
|
+
stdout.puts "Resolved #{result[:conflicts_resolved]} open conflict(s)." if result[:conflicts_resolved] > 0
|
|
45
|
+
0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Accept either a numeric fact id or an 8-char docid hex string.
|
|
51
|
+
# @param store [Store::SQLiteStore] database to look up the fact in
|
|
52
|
+
# @param identifier [String] numeric fact id or hex docid
|
|
53
|
+
# @return [Integer, nil] resolved fact id, or nil if not found
|
|
54
|
+
def resolve_fact_id(store, identifier)
|
|
55
|
+
return identifier.to_i if identifier.match?(/\A\d+\z/)
|
|
56
|
+
|
|
57
|
+
row = store.find_fact_by_docid(identifier)
|
|
58
|
+
row ? row[:id] : nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Commands
|
|
7
|
+
# One-time recovery for facts that were superseded because of an
|
|
8
|
+
# obsolete single-value predicate classification. See
|
|
9
|
+
# Sweep::Maintenance#restore_multi_value_supersessions for the algorithm
|
|
10
|
+
# and Jaccard heuristic.
|
|
11
|
+
class RestoreCommand < BaseCommand
|
|
12
|
+
# @param args [Array<String>] command line arguments (--predicate, --scope, --dry-run)
|
|
13
|
+
# @return [Integer] exit code (0 for success, 1 for failure)
|
|
14
|
+
def call(args)
|
|
15
|
+
opts = parse_options(args, {predicate: nil, scope: "project", dry_run: false}) do |o|
|
|
16
|
+
OptionParser.new do |parser|
|
|
17
|
+
parser.banner = "Usage: claude-memory restore --predicate NAME [options]"
|
|
18
|
+
parser.on("--predicate NAME", "Predicate to restore (e.g. uses_framework)") { |v| o[:predicate] = v }
|
|
19
|
+
parser.on("--scope SCOPE", %w[project global], "Database scope (default: project)") { |v| o[:scope] = v }
|
|
20
|
+
parser.on("--dry-run", "Show what would be restored without writing") { o[:dry_run] = true }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
return 1 if opts.nil?
|
|
24
|
+
|
|
25
|
+
return failure("--predicate required (e.g. --predicate uses_framework)") if opts[:predicate].nil?
|
|
26
|
+
|
|
27
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
28
|
+
store = manager.store_for_scope(opts[:scope])
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
result = Sweep::Maintenance.new(store).restore_multi_value_supersessions(
|
|
32
|
+
predicate: opts[:predicate],
|
|
33
|
+
dry_run: opts[:dry_run]
|
|
34
|
+
)
|
|
35
|
+
rescue ArgumentError => e
|
|
36
|
+
stderr.puts e.message
|
|
37
|
+
manager.close
|
|
38
|
+
return 1
|
|
39
|
+
ensure
|
|
40
|
+
manager.close
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
print_result(opts, result)
|
|
44
|
+
0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Print a summary of restored and skipped facts.
|
|
50
|
+
# @param opts [Hash] parsed options including :predicate, :scope, :dry_run
|
|
51
|
+
# @param result [Hash] result from Sweep::Maintenance#restore_multi_value_supersessions
|
|
52
|
+
# @return [void]
|
|
53
|
+
def print_result(opts, result)
|
|
54
|
+
mode = opts[:dry_run] ? "DRY RUN" : "RESTORE"
|
|
55
|
+
stdout.puts "#{mode}: predicate=#{opts[:predicate]} scope=#{opts[:scope]}"
|
|
56
|
+
stdout.puts "=" * 50
|
|
57
|
+
stdout.puts "Inspected: #{result[:inspected]}"
|
|
58
|
+
stdout.puts "Restored: #{result[:restored]}"
|
|
59
|
+
stdout.puts "Skipped ambiguous: #{result[:skipped_ambiguous]}"
|
|
60
|
+
|
|
61
|
+
return if result[:decisions].empty?
|
|
62
|
+
|
|
63
|
+
stdout.puts
|
|
64
|
+
stdout.puts "Decisions:"
|
|
65
|
+
result[:decisions].each do |d|
|
|
66
|
+
case d[:action]
|
|
67
|
+
when :restore
|
|
68
|
+
stdout.puts " [restore] ##{d[:fact_id]} #{d[:object]}"
|
|
69
|
+
when :skip_ambiguous
|
|
70
|
+
overlaps = d[:overlaps_with].map { |o| "'#{o}'" }.join(", ")
|
|
71
|
+
stdout.puts " [skip] ##{d[:fact_id]} #{d[:object]} (overlaps: #{overlaps})"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -30,7 +30,11 @@ For each content item, carefully read the raw_text and extract:
|
|
|
30
30
|
|
|
31
31
|
**Facts** — Knowledge learned:
|
|
32
32
|
- subject: Entity name or "repo" for project-level facts
|
|
33
|
-
- predicate:
|
|
33
|
+
- predicate: prefer a predicate from the canonical vocabulary defined in
|
|
34
|
+
`lib/claude_memory/resolve/predicate_policy.rb` (convention, decision,
|
|
35
|
+
architecture, uses_framework, uses_language, uses_database,
|
|
36
|
+
deployment_platform, auth_method). Other snake_case predicates are
|
|
37
|
+
accepted but fall through to the default multi-value policy.
|
|
34
38
|
- object: The value
|
|
35
39
|
- confidence: 0.0-1.0
|
|
36
40
|
- quote: Source excerpt (max 200 chars)
|
|
@@ -13,15 +13,21 @@ module ClaudeMemory
|
|
|
13
13
|
SCOPE_PROJECT = "project"
|
|
14
14
|
|
|
15
15
|
def call(args)
|
|
16
|
-
opts = parse_options(args, {scope: SCOPE_ALL}) do |o|
|
|
16
|
+
opts = parse_options(args, {scope: SCOPE_ALL, tools: false, since_days: nil}) do |o|
|
|
17
17
|
OptionParser.new do |parser|
|
|
18
18
|
parser.banner = "Usage: claude-memory stats [options]"
|
|
19
19
|
parser.on("--scope SCOPE", ["all", "global", "project"],
|
|
20
20
|
"Show stats for: all (default), global, or project") { |v| o[:scope] = v }
|
|
21
|
+
parser.on("--tools", "Show MCP tool-call usage stats") { o[:tools] = true }
|
|
22
|
+
parser.on("--since DAYS", Integer, "Limit --tools to last N days") { |v| o[:since_days] = v }
|
|
21
23
|
end
|
|
22
24
|
end
|
|
23
25
|
return 1 if opts.nil?
|
|
24
26
|
|
|
27
|
+
if opts[:tools]
|
|
28
|
+
return print_mcp_tool_call_stats(opts[:since_days])
|
|
29
|
+
end
|
|
30
|
+
|
|
25
31
|
manager = ClaudeMemory::Store::StoreManager.new
|
|
26
32
|
|
|
27
33
|
stdout.puts "ClaudeMemory Statistics"
|
|
@@ -42,6 +48,10 @@ module ClaudeMemory
|
|
|
42
48
|
|
|
43
49
|
private
|
|
44
50
|
|
|
51
|
+
def open_readonly(db_path)
|
|
52
|
+
Sequel.connect("extralite://#{db_path}")
|
|
53
|
+
end
|
|
54
|
+
|
|
45
55
|
def print_database_stats(label, db_path)
|
|
46
56
|
stdout.puts "## #{label} DATABASE"
|
|
47
57
|
stdout.puts
|
|
@@ -53,7 +63,7 @@ module ClaudeMemory
|
|
|
53
63
|
end
|
|
54
64
|
|
|
55
65
|
begin
|
|
56
|
-
db =
|
|
66
|
+
db = open_readonly(db_path)
|
|
57
67
|
|
|
58
68
|
# Facts statistics
|
|
59
69
|
print_fact_stats(db)
|
|
@@ -245,6 +255,92 @@ module ClaudeMemory
|
|
|
245
255
|
# Format number with comma separators (e.g., 1234567 => "1,234,567")
|
|
246
256
|
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
247
257
|
end
|
|
258
|
+
|
|
259
|
+
def print_mcp_tool_call_stats(since_days)
|
|
260
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
261
|
+
db_path = manager.project_db_path
|
|
262
|
+
|
|
263
|
+
stdout.puts "MCP Tool Call Statistics"
|
|
264
|
+
stdout.puts "=" * 50
|
|
265
|
+
|
|
266
|
+
unless File.exist?(db_path)
|
|
267
|
+
stdout.puts "Project database does not exist: #{db_path}"
|
|
268
|
+
manager.close
|
|
269
|
+
return 0
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
db = open_readonly(db_path)
|
|
273
|
+
|
|
274
|
+
unless db.table_exists?(:mcp_tool_calls)
|
|
275
|
+
stdout.puts "No telemetry recorded yet (run MCP server first)."
|
|
276
|
+
db.disconnect
|
|
277
|
+
manager.close
|
|
278
|
+
return 0
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
dataset = db[:mcp_tool_calls]
|
|
282
|
+
if since_days
|
|
283
|
+
cutoff = (Time.now - since_days * 86400).utc.iso8601
|
|
284
|
+
dataset = dataset.where { called_at >= cutoff }
|
|
285
|
+
stdout.puts "Window: last #{since_days} day#{"s" unless since_days == 1}"
|
|
286
|
+
else
|
|
287
|
+
stdout.puts "Window: all time"
|
|
288
|
+
end
|
|
289
|
+
stdout.puts
|
|
290
|
+
|
|
291
|
+
total = dataset.count
|
|
292
|
+
if total.zero?
|
|
293
|
+
stdout.puts "No tool calls recorded in window."
|
|
294
|
+
db.disconnect
|
|
295
|
+
manager.close
|
|
296
|
+
return 0
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
errors = dataset.exclude(error_class: nil).count
|
|
300
|
+
error_rate = (errors * 100.0 / total).round(1)
|
|
301
|
+
stdout.puts "Total calls: #{format_number(total)}"
|
|
302
|
+
stdout.puts "Errors: #{format_number(errors)} (#{error_rate}%)"
|
|
303
|
+
stdout.puts
|
|
304
|
+
|
|
305
|
+
print_per_tool_breakdown(dataset)
|
|
306
|
+
|
|
307
|
+
db.disconnect
|
|
308
|
+
manager.close
|
|
309
|
+
0
|
|
310
|
+
rescue Sequel::DatabaseError, Extralite::Error => e
|
|
311
|
+
stderr.puts "Error reading telemetry: #{e.message}"
|
|
312
|
+
1
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def print_per_tool_breakdown(dataset)
|
|
316
|
+
stdout.puts "Per-tool breakdown:"
|
|
317
|
+
stdout.puts " #{"Tool".ljust(28)} #{"Calls".rjust(7)} #{"Avg ms".rjust(8)} #{"P95 ms".rjust(8)} #{"Err %".rjust(6)}"
|
|
318
|
+
|
|
319
|
+
rows = dataset
|
|
320
|
+
.group_and_count(:tool_name)
|
|
321
|
+
.order(Sequel.desc(:count))
|
|
322
|
+
.all
|
|
323
|
+
|
|
324
|
+
rows.each do |row|
|
|
325
|
+
tool = row[:tool_name]
|
|
326
|
+
calls = row[:count]
|
|
327
|
+
durations = dataset.where(tool_name: tool).select_map(:duration_ms).sort
|
|
328
|
+
avg = (durations.sum.to_f / calls).round(1)
|
|
329
|
+
p95 = percentile(durations, 0.95)
|
|
330
|
+
tool_errors = dataset.where(tool_name: tool).exclude(error_class: nil).count
|
|
331
|
+
tool_err_rate = (tool_errors * 100.0 / calls).round(1)
|
|
332
|
+
|
|
333
|
+
stdout.puts " #{tool.to_s.ljust(28)} #{calls.to_s.rjust(7)} #{avg.to_s.rjust(8)} #{p95.to_s.rjust(8)} #{tool_err_rate.to_s.rjust(6)}"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def percentile(sorted, pct)
|
|
338
|
+
return 0 if sorted.empty?
|
|
339
|
+
idx = (sorted.size * pct).ceil - 1
|
|
340
|
+
idx = 0 if idx < 0
|
|
341
|
+
idx = sorted.size - 1 if idx >= sorted.size
|
|
342
|
+
sorted[idx]
|
|
343
|
+
end
|
|
248
344
|
end
|
|
249
345
|
end
|
|
250
346
|
end
|
|
@@ -8,31 +8,44 @@ module ClaudeMemory
|
|
|
8
8
|
class Configuration
|
|
9
9
|
attr_reader :env
|
|
10
10
|
|
|
11
|
+
# @param env [Hash] environment variables (default: ENV)
|
|
11
12
|
def initialize(env = ENV)
|
|
12
13
|
@env = env
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
# @return [String] user home directory
|
|
15
17
|
def home_dir
|
|
16
18
|
env["HOME"] || File.expand_path("~")
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
# @return [String] project root directory (resolves git worktrees)
|
|
19
22
|
def project_dir
|
|
20
23
|
env["CLAUDE_PROJECT_DIR"] || resolve_project_dir
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
# @return [String] Claude config directory (default: ~/.claude)
|
|
27
|
+
def claude_config_dir
|
|
28
|
+
env["CLAUDE_CONFIG_DIR"] || File.join(home_dir, ".claude")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] path to global memory database
|
|
23
32
|
def global_db_path
|
|
24
|
-
File.join(
|
|
33
|
+
File.join(claude_config_dir, "memory.sqlite3")
|
|
25
34
|
end
|
|
26
35
|
|
|
36
|
+
# @param project_path [String, nil] override project root (defaults to project_dir)
|
|
37
|
+
# @return [String] path to project memory database
|
|
27
38
|
def project_db_path(project_path = nil)
|
|
28
39
|
path = project_path || project_dir
|
|
29
40
|
File.join(path, ".claude", "memory.sqlite3")
|
|
30
41
|
end
|
|
31
42
|
|
|
43
|
+
# @return [String, nil] current Claude session ID from CLAUDE_SESSION_ID
|
|
32
44
|
def session_id
|
|
33
45
|
env["CLAUDE_SESSION_ID"]
|
|
34
46
|
end
|
|
35
47
|
|
|
48
|
+
# @return [String, nil] path to current transcript from CLAUDE_TRANSCRIPT_PATH
|
|
36
49
|
def transcript_path
|
|
37
50
|
env["CLAUDE_TRANSCRIPT_PATH"]
|
|
38
51
|
end
|