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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +94 -2
  4. data/.claude/settings.json +30 -52
  5. data/.claude/settings.local.json +3 -1
  6. data/.claude/skills/release/SKILL.md +168 -0
  7. data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
  8. data/.claude-plugin/marketplace.json +2 -2
  9. data/.claude-plugin/plugin.json +3 -3
  10. data/.claude-plugin/scripts/hook-runner.sh +14 -0
  11. data/.claude-plugin/scripts/serve-mcp.sh +14 -0
  12. data/.ruby-version +1 -1
  13. data/CHANGELOG.md +47 -0
  14. data/CLAUDE.md +31 -17
  15. data/README.md +35 -0
  16. data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
  17. data/db/migrations/014_canonicalize_predicates.rb +30 -0
  18. data/docs/improvements.md +58 -20
  19. data/docs/influence/claude-mem.md +1 -0
  20. data/docs/influence/claude-supermemory.md +1 -0
  21. data/docs/influence/episodic-memory.md +1 -0
  22. data/docs/influence/grepai.md +1 -0
  23. data/docs/influence/kbs.md +1 -0
  24. data/docs/influence/lossless-claw.md +1 -0
  25. data/docs/influence/qmd.md +1 -0
  26. data/lib/claude_memory/commands/completion_command.rb +1 -31
  27. data/lib/claude_memory/commands/embeddings_command.rb +198 -0
  28. data/lib/claude_memory/commands/help_command.rb +8 -1
  29. data/lib/claude_memory/commands/registry.rb +47 -34
  30. data/lib/claude_memory/commands/reject_command.rb +62 -0
  31. data/lib/claude_memory/commands/restore_command.rb +77 -0
  32. data/lib/claude_memory/commands/skills/distill-transcripts.md +5 -1
  33. data/lib/claude_memory/commands/stats_command.rb +98 -2
  34. data/lib/claude_memory/configuration.rb +14 -1
  35. data/lib/claude_memory/distill/json_schema.md +8 -4
  36. data/lib/claude_memory/distill/null_distiller.rb +2 -0
  37. data/lib/claude_memory/domain/entity.rb +13 -1
  38. data/lib/claude_memory/domain/fact.rb +26 -2
  39. data/lib/claude_memory/embeddings/api_adapter.rb +5 -4
  40. data/lib/claude_memory/embeddings/fastembed_adapter.rb +43 -13
  41. data/lib/claude_memory/embeddings/inspector.rb +91 -0
  42. data/lib/claude_memory/embeddings/model_registry.rb +210 -0
  43. data/lib/claude_memory/embeddings/resolver.rb +32 -6
  44. data/lib/claude_memory/ingest/ingester.rb +17 -0
  45. data/lib/claude_memory/mcp/handlers/management_handlers.rb +24 -0
  46. data/lib/claude_memory/mcp/handlers/stats_handlers.rb +5 -2
  47. data/lib/claude_memory/mcp/instructions_builder.rb +17 -0
  48. data/lib/claude_memory/mcp/server.rb +30 -3
  49. data/lib/claude_memory/mcp/telemetry.rb +86 -0
  50. data/lib/claude_memory/mcp/tool_definitions.rb +86 -3
  51. data/lib/claude_memory/mcp/tools.rb +10 -0
  52. data/lib/claude_memory/publish.rb +40 -5
  53. data/lib/claude_memory/recall.rb +81 -0
  54. data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
  55. data/lib/claude_memory/resolve/resolver.rb +43 -0
  56. data/lib/claude_memory/store/schema_manager.rb +1 -1
  57. data/lib/claude_memory/store/sqlite_store.rb +250 -1
  58. data/lib/claude_memory/store/store_manager.rb +50 -1
  59. data/lib/claude_memory/sweep/maintenance.rb +115 -1
  60. data/lib/claude_memory/sweep/sweeper.rb +3 -0
  61. data/lib/claude_memory/version.rb +1 -1
  62. data/lib/claude_memory.rb +5 -0
  63. metadata +27 -8
  64. data/.claude/memory.sqlite3-shm +0 -0
  65. 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 for dynamic dispatch
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 names
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" => "HelpCommand",
12
- "version" => "VersionCommand",
13
- "doctor" => "DoctorCommand",
14
- "stats" => "StatsCommand",
15
- "promote" => "PromoteCommand",
16
- "search" => "SearchCommand",
17
- "explain" => "ExplainCommand",
18
- "conflicts" => "ConflictsCommand",
19
- "changes" => "ChangesCommand",
20
- "recall" => "RecallCommand",
21
- "sweep" => "SweepCommand",
22
- "ingest" => "IngestCommand",
23
- "publish" => "PublishCommand",
24
- "db:init" => "DbInitCommand",
25
- "init" => "InitCommand",
26
- "uninstall" => "UninstallCommand",
27
- "serve-mcp" => "ServeMcpCommand",
28
- "hook" => "HookCommand",
29
- "index" => "IndexCommand",
30
- "recover" => "RecoverCommand",
31
- "compact" => "CompactCommand",
32
- "export" => "ExportCommand",
33
- "git-lfs" => "GitLfsCommand",
34
- "install-skill" => "InstallSkillCommand",
35
- "completion" => "CompletionCommand"
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
- return nil if command_name.nil?
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: uses_database, uses_framework, convention, decision, auth_method, deployment_platform, depends_on, testing_strategy
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 = Sequel.sqlite(db_path, readonly: true)
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(home_dir, ".claude", "memory.sqlite3")
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