claude_memory 0.2.0 → 0.3.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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/.mind.mv2.o2N83S +0 -0
  3. data/.claude/CLAUDE.md +1 -0
  4. data/.claude/rules/claude_memory.generated.md +28 -9
  5. data/.claude/settings.local.json +9 -1
  6. data/.claude/skills/check-memory/SKILL.md +77 -0
  7. data/.claude/skills/improve/SKILL.md +532 -0
  8. data/.claude/skills/improve/feature-patterns.md +1221 -0
  9. data/.claude/skills/quality-update/SKILL.md +229 -0
  10. data/.claude/skills/quality-update/implementation-guide.md +346 -0
  11. data/.claude/skills/review-commit/SKILL.md +199 -0
  12. data/.claude/skills/review-for-quality/SKILL.md +154 -0
  13. data/.claude/skills/review-for-quality/expert-checklists.md +79 -0
  14. data/.claude/skills/setup-memory/SKILL.md +168 -0
  15. data/.claude/skills/study-repo/SKILL.md +307 -0
  16. data/.claude/skills/study-repo/analysis-template.md +323 -0
  17. data/.claude/skills/study-repo/focus-examples.md +327 -0
  18. data/CHANGELOG.md +133 -0
  19. data/CLAUDE.md +130 -11
  20. data/README.md +117 -10
  21. data/db/migrations/001_create_initial_schema.rb +117 -0
  22. data/db/migrations/002_add_project_scoping.rb +33 -0
  23. data/db/migrations/003_add_session_metadata.rb +42 -0
  24. data/db/migrations/004_add_fact_embeddings.rb +20 -0
  25. data/db/migrations/005_add_incremental_sync.rb +21 -0
  26. data/db/migrations/006_add_operation_tracking.rb +40 -0
  27. data/db/migrations/007_add_ingestion_metrics.rb +26 -0
  28. data/docs/.claude/mind.mv2.lock +0 -0
  29. data/docs/GETTING_STARTED.md +587 -0
  30. data/docs/RELEASE_NOTES_v0.2.0.md +0 -1
  31. data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +0 -2
  32. data/docs/architecture.md +9 -8
  33. data/docs/auto_init_design.md +230 -0
  34. data/docs/improvements.md +557 -731
  35. data/docs/influence/.gitkeep +13 -0
  36. data/docs/influence/grepai.md +933 -0
  37. data/docs/influence/qmd.md +2195 -0
  38. data/docs/plugin.md +257 -11
  39. data/docs/quality_review.md +472 -1273
  40. data/docs/remaining_improvements.md +330 -0
  41. data/lefthook.yml +13 -0
  42. data/lib/claude_memory/commands/checks/claude_md_check.rb +41 -0
  43. data/lib/claude_memory/commands/checks/database_check.rb +120 -0
  44. data/lib/claude_memory/commands/checks/hooks_check.rb +112 -0
  45. data/lib/claude_memory/commands/checks/reporter.rb +110 -0
  46. data/lib/claude_memory/commands/checks/snapshot_check.rb +30 -0
  47. data/lib/claude_memory/commands/doctor_command.rb +12 -129
  48. data/lib/claude_memory/commands/help_command.rb +1 -0
  49. data/lib/claude_memory/commands/hook_command.rb +9 -2
  50. data/lib/claude_memory/commands/index_command.rb +169 -0
  51. data/lib/claude_memory/commands/ingest_command.rb +1 -1
  52. data/lib/claude_memory/commands/init_command.rb +5 -197
  53. data/lib/claude_memory/commands/initializers/database_ensurer.rb +30 -0
  54. data/lib/claude_memory/commands/initializers/global_initializer.rb +85 -0
  55. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +156 -0
  56. data/lib/claude_memory/commands/initializers/mcp_configurator.rb +56 -0
  57. data/lib/claude_memory/commands/initializers/memory_instructions_writer.rb +135 -0
  58. data/lib/claude_memory/commands/initializers/project_initializer.rb +111 -0
  59. data/lib/claude_memory/commands/recover_command.rb +75 -0
  60. data/lib/claude_memory/commands/registry.rb +5 -1
  61. data/lib/claude_memory/commands/stats_command.rb +239 -0
  62. data/lib/claude_memory/commands/uninstall_command.rb +226 -0
  63. data/lib/claude_memory/core/batch_loader.rb +32 -0
  64. data/lib/claude_memory/core/concept_ranker.rb +73 -0
  65. data/lib/claude_memory/core/embedding_candidate_builder.rb +37 -0
  66. data/lib/claude_memory/core/fact_collector.rb +51 -0
  67. data/lib/claude_memory/core/fact_query_builder.rb +154 -0
  68. data/lib/claude_memory/core/fact_ranker.rb +113 -0
  69. data/lib/claude_memory/core/result_builder.rb +54 -0
  70. data/lib/claude_memory/core/result_sorter.rb +25 -0
  71. data/lib/claude_memory/core/scope_filter.rb +61 -0
  72. data/lib/claude_memory/core/text_builder.rb +29 -0
  73. data/lib/claude_memory/embeddings/generator.rb +161 -0
  74. data/lib/claude_memory/embeddings/similarity.rb +69 -0
  75. data/lib/claude_memory/hook/handler.rb +4 -3
  76. data/lib/claude_memory/index/lexical_fts.rb +7 -2
  77. data/lib/claude_memory/infrastructure/operation_tracker.rb +158 -0
  78. data/lib/claude_memory/infrastructure/schema_validator.rb +206 -0
  79. data/lib/claude_memory/ingest/content_sanitizer.rb +6 -7
  80. data/lib/claude_memory/ingest/ingester.rb +99 -15
  81. data/lib/claude_memory/ingest/metadata_extractor.rb +57 -0
  82. data/lib/claude_memory/ingest/tool_extractor.rb +71 -0
  83. data/lib/claude_memory/mcp/response_formatter.rb +331 -0
  84. data/lib/claude_memory/mcp/server.rb +19 -0
  85. data/lib/claude_memory/mcp/setup_status_analyzer.rb +73 -0
  86. data/lib/claude_memory/mcp/tool_definitions.rb +279 -0
  87. data/lib/claude_memory/mcp/tool_helpers.rb +80 -0
  88. data/lib/claude_memory/mcp/tools.rb +330 -320
  89. data/lib/claude_memory/recall/dual_query_template.rb +63 -0
  90. data/lib/claude_memory/recall.rb +304 -237
  91. data/lib/claude_memory/resolve/resolver.rb +52 -49
  92. data/lib/claude_memory/store/sqlite_store.rb +210 -144
  93. data/lib/claude_memory/store/store_manager.rb +6 -6
  94. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  95. data/lib/claude_memory/version.rb +1 -1
  96. data/lib/claude_memory.rb +35 -3
  97. metadata +71 -11
  98. data/.claude/.mind.mv2.aLCUZd +0 -0
  99. data/.claude/memory.sqlite3 +0 -0
  100. data/.mcp.json +0 -11
  101. /data/docs/{feature_adoption_plan.md → plans/feature_adoption_plan.md} +0 -0
  102. /data/docs/{feature_adoption_plan_revised.md → plans/feature_adoption_plan_revised.md} +0 -0
  103. /data/docs/{plan.md → plans/plan.md} +0 -0
  104. /data/docs/{updated_plan.md → plans/updated_plan.md} +0 -0
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module ClaudeMemory
6
+ module Commands
7
+ module Initializers
8
+ # Orchestrates project-local ClaudeMemory initialization
9
+ class ProjectInitializer
10
+ def initialize(stdout, stderr, stdin)
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ @stdin = stdin
14
+ end
15
+
16
+ def initialize_memory
17
+ @stdout.puts "Initializing ClaudeMemory (project-local)...\n\n"
18
+
19
+ # Check for existing hooks and offer options
20
+ hooks_config = HooksConfigurator.new(@stdout)
21
+ if hooks_config.has_claude_memory_hooks?(".claude/settings.json")
22
+ handle_existing_hooks(hooks_config)
23
+ return 0 if @skip_initialization
24
+ end
25
+
26
+ ensure_databases
27
+ ensure_directories
28
+ configure_hooks unless @skip_hooks
29
+ configure_mcp
30
+ configure_memory_instructions
31
+ install_output_style
32
+
33
+ print_completion_message
34
+ 0
35
+ end
36
+
37
+ private
38
+
39
+ def handle_existing_hooks(hooks_config)
40
+ @stdout.puts "⚠️ Existing claude-memory hooks detected in .claude/settings.json"
41
+ @stdout.puts "\nOptions:"
42
+ @stdout.puts " 1. Update to current version (recommended)"
43
+ @stdout.puts " 2. Remove hooks (uninstall)"
44
+ @stdout.puts " 3. Leave as-is (skip)"
45
+ @stdout.print "\nChoice [1]: "
46
+
47
+ choice = @stdin.gets.to_s.strip
48
+ choice = "1" if choice.empty?
49
+
50
+ case choice
51
+ when "2"
52
+ @stdout.puts "\nRemoving hooks..."
53
+ hooks_config.remove_hooks_from_file(".claude/settings.json")
54
+ @stdout.puts "✓ Hooks removed. Run 'claude-memory uninstall' for full cleanup."
55
+ @skip_initialization = true
56
+ when "3"
57
+ @stdout.puts "\nSkipping hook configuration."
58
+ @skip_hooks = true
59
+ else
60
+ @stdout.puts "\nUpdating hooks..."
61
+ end
62
+ end
63
+
64
+ def ensure_databases
65
+ DatabaseEnsurer.new(@stdout).ensure_project_databases
66
+ end
67
+
68
+ def ensure_directories
69
+ FileUtils.mkdir_p(".claude/rules")
70
+ @stdout.puts "✓ Created .claude/rules directory"
71
+ end
72
+
73
+ def configure_hooks
74
+ HooksConfigurator.new(@stdout).configure_project_hooks
75
+ end
76
+
77
+ def configure_mcp
78
+ McpConfigurator.new(@stdout).configure_project_mcp
79
+ end
80
+
81
+ def configure_memory_instructions
82
+ MemoryInstructionsWriter.new(@stdout).write_project_instructions
83
+ end
84
+
85
+ def install_output_style
86
+ style_source = File.join(__dir__, "../../../output_styles/claude_memory.json")
87
+ style_dest = ".claude/output_styles/claude_memory.json"
88
+
89
+ return unless File.exist?(style_source)
90
+
91
+ FileUtils.mkdir_p(File.dirname(style_dest))
92
+ FileUtils.cp(style_source, style_dest)
93
+ @stdout.puts "✓ Installed output style at #{style_dest}"
94
+ end
95
+
96
+ def print_completion_message
97
+ @stdout.puts "\n=== Setup Complete ===\n"
98
+ @stdout.puts "ClaudeMemory is now configured for this project."
99
+ @stdout.puts "\nDatabases:"
100
+ @stdout.puts " Global: ~/.claude/memory.sqlite3 (user-wide knowledge)"
101
+ @stdout.puts " Project: .claude/memory.sqlite3 (project-specific)"
102
+ @stdout.puts "\nNext steps:"
103
+ @stdout.puts " 1. Restart Claude Code to load the new configuration"
104
+ @stdout.puts " 2. Use Claude Code normally - transcripts will be ingested automatically"
105
+ @stdout.puts " 3. Run 'claude-memory promote <fact_id>' to move facts to global"
106
+ @stdout.puts " 4. Run 'claude-memory doctor' to verify setup"
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Commands
5
+ # Recovers from stuck operations by resetting them
6
+ class RecoverCommand < BaseCommand
7
+ def call(args)
8
+ opts = parse_options(args, {operation: nil, scope: nil}) do |o|
9
+ OptionParser.new do |parser|
10
+ parser.banner = "Usage: claude-memory recover [options]"
11
+ parser.on("--operation TYPE", "Filter by operation type") { |v| o[:operation] = v }
12
+ parser.on("--scope SCOPE", "Filter by scope (global/project)") { |v| o[:scope] = v }
13
+ end
14
+ end
15
+ return 1 if opts.nil?
16
+
17
+ manager = Store::StoreManager.new
18
+
19
+ total_reset = 0
20
+
21
+ # Reset stuck operations in global database
22
+ if opts[:scope].nil? || opts[:scope] == "global"
23
+ if File.exist?(manager.global_db_path)
24
+ count = reset_stuck_operations(
25
+ manager.global_store,
26
+ "global",
27
+ opts[:operation]
28
+ )
29
+ total_reset += count
30
+ end
31
+ end
32
+
33
+ # Reset stuck operations in project database
34
+ if opts[:scope].nil? || opts[:scope] == "project"
35
+ if File.exist?(manager.project_db_path)
36
+ count = reset_stuck_operations(
37
+ manager.project_store,
38
+ "project",
39
+ opts[:operation]
40
+ )
41
+ total_reset += count
42
+ end
43
+ end
44
+
45
+ manager.close
46
+
47
+ if total_reset.zero?
48
+ stdout.puts "No stuck operations found."
49
+ else
50
+ stdout.puts "Reset #{total_reset} stuck operation(s)."
51
+ stdout.puts "You can now re-run the failed operation."
52
+ end
53
+
54
+ 0
55
+ end
56
+
57
+ private
58
+
59
+ def reset_stuck_operations(store, scope_label, operation_type_filter)
60
+ tracker = Infrastructure::OperationTracker.new(store)
61
+
62
+ count = tracker.reset_stuck_operations(
63
+ operation_type: operation_type_filter,
64
+ scope: scope_label
65
+ )
66
+
67
+ if count > 0
68
+ stdout.puts "#{scope_label.capitalize}: Reset #{count} stuck operation(s)"
69
+ end
70
+
71
+ count
72
+ end
73
+ end
74
+ end
75
+ end
@@ -11,6 +11,7 @@ module ClaudeMemory
11
11
  "help" => "HelpCommand",
12
12
  "version" => "VersionCommand",
13
13
  "doctor" => "DoctorCommand",
14
+ "stats" => "StatsCommand",
14
15
  "promote" => "PromoteCommand",
15
16
  "search" => "SearchCommand",
16
17
  "explain" => "ExplainCommand",
@@ -22,8 +23,11 @@ module ClaudeMemory
22
23
  "publish" => "PublishCommand",
23
24
  "db:init" => "DbInitCommand",
24
25
  "init" => "InitCommand",
26
+ "uninstall" => "UninstallCommand",
25
27
  "serve-mcp" => "ServeMcpCommand",
26
- "hook" => "HookCommand"
28
+ "hook" => "HookCommand",
29
+ "index" => "IndexCommand",
30
+ "recover" => "RecoverCommand"
27
31
  }.freeze
28
32
 
29
33
  # Find a command class by name
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ClaudeMemory
6
+ module Commands
7
+ # Displays detailed statistics about the memory system
8
+ # Shows facts by status and predicate, entities by type, content items,
9
+ # provenance coverage, conflicts, and database sizes
10
+ class StatsCommand < BaseCommand
11
+ SCOPE_ALL = "all"
12
+ SCOPE_GLOBAL = "global"
13
+ SCOPE_PROJECT = "project"
14
+
15
+ def call(args)
16
+ opts = parse_options(args, {scope: SCOPE_ALL}) do |o|
17
+ OptionParser.new do |parser|
18
+ parser.banner = "Usage: claude-memory stats [options]"
19
+ parser.on("--scope SCOPE", ["all", "global", "project"],
20
+ "Show stats for: all (default), global, or project") { |v| o[:scope] = v }
21
+ end
22
+ end
23
+ return 1 if opts.nil?
24
+
25
+ manager = ClaudeMemory::Store::StoreManager.new
26
+
27
+ stdout.puts "ClaudeMemory Statistics"
28
+ stdout.puts "=" * 50
29
+ stdout.puts
30
+
31
+ if opts[:scope] == SCOPE_ALL || opts[:scope] == SCOPE_GLOBAL
32
+ print_database_stats("GLOBAL", manager.global_db_path)
33
+ end
34
+
35
+ if opts[:scope] == SCOPE_ALL || opts[:scope] == SCOPE_PROJECT
36
+ print_database_stats("PROJECT", manager.project_db_path)
37
+ end
38
+
39
+ manager.close
40
+ 0
41
+ end
42
+
43
+ private
44
+
45
+ def print_database_stats(label, db_path)
46
+ stdout.puts "## #{label} DATABASE"
47
+ stdout.puts
48
+
49
+ unless File.exist?(db_path)
50
+ stdout.puts "Database does not exist: #{db_path}"
51
+ stdout.puts
52
+ return
53
+ end
54
+
55
+ begin
56
+ db = Sequel.sqlite(db_path, readonly: true)
57
+
58
+ # Facts statistics
59
+ print_fact_stats(db)
60
+ stdout.puts
61
+
62
+ # Entities statistics
63
+ print_entity_stats(db)
64
+ stdout.puts
65
+
66
+ # Content items statistics
67
+ print_content_stats(db)
68
+ stdout.puts
69
+
70
+ # Provenance coverage
71
+ print_provenance_stats(db)
72
+ stdout.puts
73
+
74
+ # Conflicts
75
+ print_conflict_stats(db)
76
+ stdout.puts
77
+
78
+ # ROI Metrics (if available)
79
+ print_roi_metrics(db)
80
+ stdout.puts
81
+
82
+ # Database size
83
+ print_database_size(db_path)
84
+ stdout.puts
85
+
86
+ db.disconnect
87
+ rescue => e
88
+ stderr.puts "Error reading database: #{e.message}"
89
+ end
90
+ end
91
+
92
+ def print_fact_stats(db)
93
+ total = db[:facts].count
94
+ active = db[:facts].where(status: "active").count
95
+ superseded = db[:facts].where(status: "superseded").count
96
+
97
+ stdout.puts "Facts:"
98
+ stdout.puts " Total: #{total} (#{active} active, #{superseded} superseded)"
99
+
100
+ if total > 0
101
+ stdout.puts
102
+ stdout.puts " Top Predicates:"
103
+
104
+ predicate_counts = db[:facts]
105
+ .where(status: "active")
106
+ .group_and_count(:predicate)
107
+ .order(Sequel.desc(:count))
108
+ .limit(10)
109
+ .all
110
+
111
+ predicate_counts.each do |row|
112
+ stdout.puts " #{row[:count].to_s.rjust(4)} - #{row[:predicate]}"
113
+ end
114
+ end
115
+ end
116
+
117
+ def print_entity_stats(db)
118
+ total = db[:entities].count
119
+
120
+ stdout.puts "Entities: #{total}"
121
+
122
+ if total > 0
123
+ type_counts = db[:entities]
124
+ .group_and_count(:type)
125
+ .order(Sequel.desc(:count))
126
+ .all
127
+
128
+ type_counts.each do |row|
129
+ stdout.puts " #{row[:count].to_s.rjust(4)} - #{row[:type]}"
130
+ end
131
+ end
132
+ end
133
+
134
+ def print_content_stats(db)
135
+ total = db[:content_items].count
136
+
137
+ stdout.puts "Content Items: #{total}"
138
+
139
+ if total > 0
140
+ first_date = db[:content_items].min(:occurred_at)
141
+ last_date = db[:content_items].max(:occurred_at)
142
+
143
+ if first_date && last_date
144
+ first_formatted = format_date(first_date)
145
+ last_formatted = format_date(last_date)
146
+ stdout.puts " Date Range: #{first_formatted} - #{last_formatted}"
147
+ end
148
+ end
149
+ end
150
+
151
+ def print_provenance_stats(db)
152
+ total_active_facts = db[:facts].where(status: "active").count
153
+ facts_with_provenance = db[:provenance]
154
+ .join(:facts, id: :fact_id)
155
+ .where(Sequel[:facts][:status] => "active")
156
+ .select(Sequel[:provenance][:fact_id])
157
+ .distinct
158
+ .count
159
+
160
+ if total_active_facts > 0
161
+ percentage = (facts_with_provenance * 100.0 / total_active_facts).round(1)
162
+ stdout.puts "Provenance: #{facts_with_provenance}/#{total_active_facts} facts have sources (#{percentage}%)"
163
+ else
164
+ stdout.puts "Provenance: 0/0 facts have sources"
165
+ end
166
+ end
167
+
168
+ def print_conflict_stats(db)
169
+ open = db[:conflicts].where(status: "open").count
170
+ resolved = db[:conflicts].where(status: "resolved").count
171
+ total = open + resolved
172
+
173
+ stdout.puts "Conflicts: #{open} open, #{resolved} resolved (#{total} total)"
174
+ end
175
+
176
+ def print_roi_metrics(db)
177
+ # Check if ingestion_metrics table exists (schema v7+)
178
+ return unless db.table_exists?(:ingestion_metrics)
179
+
180
+ # standard:disable Performance/Detect (Sequel DSL requires .select{}.first)
181
+ result = db[:ingestion_metrics]
182
+ .select {
183
+ [
184
+ sum(:input_tokens).as(:total_input),
185
+ sum(:output_tokens).as(:total_output),
186
+ sum(:facts_extracted).as(:total_facts),
187
+ count(:id).as(:total_ops)
188
+ ]
189
+ }
190
+ .first
191
+ # standard:enable Performance/Detect
192
+
193
+ return if result.nil? || result[:total_ops].to_i.zero?
194
+
195
+ total_input = result[:total_input].to_i
196
+ total_output = result[:total_output].to_i
197
+ total_facts = result[:total_facts].to_i
198
+ total_ops = result[:total_ops].to_i
199
+
200
+ efficiency = total_input.zero? ? 0.0 : (total_facts.to_f / total_input * 1000).round(2)
201
+
202
+ stdout.puts "Token Economics (Distillation ROI):"
203
+ stdout.puts " Input Tokens: #{format_number(total_input)}"
204
+ stdout.puts " Output Tokens: #{format_number(total_output)}"
205
+ stdout.puts " Facts Extracted: #{format_number(total_facts)}"
206
+ stdout.puts " Operations: #{format_number(total_ops)}"
207
+ stdout.puts " Efficiency: #{efficiency} facts per 1,000 input tokens"
208
+ end
209
+
210
+ def print_database_size(db_path)
211
+ size_bytes = File.size(db_path)
212
+ size_kb = (size_bytes / 1024.0).round(1)
213
+ size_mb = (size_bytes / (1024.0 * 1024.0)).round(2)
214
+
215
+ if size_mb >= 1.0
216
+ stdout.puts "Database Size: #{size_mb} MB"
217
+ else
218
+ stdout.puts "Database Size: #{size_kb} KB"
219
+ end
220
+ end
221
+
222
+ def format_date(iso8601_string)
223
+ # Extract just the date part (YYYY-MM-DD) from ISO8601 timestamp
224
+ return iso8601_string unless iso8601_string
225
+
226
+ date_part = iso8601_string.split("T").first
227
+ return date_part if date_part
228
+
229
+ # Fallback to first 10 chars
230
+ iso8601_string[0...10]
231
+ end
232
+
233
+ def format_number(num)
234
+ # Format number with comma separators (e.g., 1234567 => "1,234,567")
235
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module ClaudeMemory
7
+ module Commands
8
+ # Removes ClaudeMemory configuration from a project or globally
9
+ class UninstallCommand < BaseCommand
10
+ def call(args)
11
+ opts = parse_options(args, {global: false, full: false}) do |o|
12
+ OptionParser.new do |parser|
13
+ parser.banner = "Usage: claude-memory uninstall [options]"
14
+ parser.on("--global", "Uninstall from global ~/.claude/ settings") { o[:global] = true }
15
+ parser.on("--full", "Remove databases and all configuration (not just hooks)") { o[:full] = true }
16
+ end
17
+ end
18
+ return 1 if opts.nil?
19
+
20
+ if opts[:global]
21
+ uninstall_global(opts[:full])
22
+ else
23
+ uninstall_local(opts[:full])
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def uninstall_local(full)
30
+ stdout.puts "Uninstalling ClaudeMemory (project-local)...\n\n"
31
+
32
+ removed_items = []
33
+
34
+ # Remove hooks from settings.json
35
+ if remove_hooks_from_file(".claude/settings.json")
36
+ removed_items << "Hooks from .claude/settings.json"
37
+ end
38
+
39
+ # Remove MCP server from .claude.json
40
+ if remove_mcp_server(".claude.json")
41
+ removed_items << "MCP server from .claude.json"
42
+ end
43
+
44
+ # Remove ClaudeMemory section from .claude/CLAUDE.md
45
+ if remove_claude_md_section(".claude/CLAUDE.md")
46
+ removed_items << "ClaudeMemory section from .claude/CLAUDE.md"
47
+ end
48
+
49
+ if full
50
+ # Remove databases and generated files
51
+ files_to_remove = [
52
+ ".claude/memory.sqlite3",
53
+ ".claude/rules/claude_memory.generated.md",
54
+ ".claude/output_styles/claude_memory.json"
55
+ ]
56
+
57
+ files_to_remove.each do |file|
58
+ if File.exist?(file)
59
+ FileUtils.rm_f(file)
60
+ removed_items << "Removed #{file}"
61
+ end
62
+ end
63
+ end
64
+
65
+ stdout.puts "\n=== Uninstall Complete ===\n"
66
+ if removed_items.any?
67
+ stdout.puts "Removed:"
68
+ removed_items.each { |item| stdout.puts " ✓ #{item}" }
69
+ else
70
+ stdout.puts "No ClaudeMemory configuration found."
71
+ end
72
+
73
+ if full
74
+ stdout.puts "\nNote: Global database (~/.claude/memory.sqlite3) was NOT removed."
75
+ stdout.puts " Run 'claude-memory uninstall --global --full' to remove it."
76
+ else
77
+ stdout.puts "\nNote: Project database (.claude/memory.sqlite3) was NOT removed."
78
+ stdout.puts " Run with --full flag to remove all files."
79
+ end
80
+
81
+ stdout.puts "\nRestart Claude Code to complete uninstallation."
82
+
83
+ 0
84
+ end
85
+
86
+ def uninstall_global(full)
87
+ stdout.puts "Uninstalling ClaudeMemory (global)...\n\n"
88
+
89
+ removed_items = []
90
+
91
+ # Remove hooks from ~/.claude/settings.json
92
+ global_settings = File.join(Dir.home, ".claude", "settings.json")
93
+ if remove_hooks_from_file(global_settings)
94
+ removed_items << "Hooks from #{global_settings}"
95
+ end
96
+
97
+ # Remove MCP server from ~/.claude.json
98
+ global_mcp = File.join(Dir.home, ".claude.json")
99
+ if remove_mcp_server(global_mcp)
100
+ removed_items << "MCP server from #{global_mcp}"
101
+ end
102
+
103
+ # Remove ClaudeMemory section from ~/.claude/CLAUDE.md
104
+ global_claude_md = File.join(Dir.home, ".claude", "CLAUDE.md")
105
+ if remove_claude_md_section(global_claude_md)
106
+ removed_items << "ClaudeMemory section from #{global_claude_md}"
107
+ end
108
+
109
+ if full
110
+ # Remove global database
111
+ global_db = ClaudeMemory.global_db_path
112
+ if File.exist?(global_db)
113
+ FileUtils.rm_f(global_db)
114
+ removed_items << "Removed #{global_db}"
115
+ end
116
+ end
117
+
118
+ stdout.puts "\n=== Global Uninstall Complete ===\n"
119
+ if removed_items.any?
120
+ stdout.puts "Removed:"
121
+ removed_items.each { |item| stdout.puts " ✓ #{item}" }
122
+ else
123
+ stdout.puts "No ClaudeMemory configuration found."
124
+ end
125
+
126
+ if full
127
+ stdout.puts "\nWarning: Global database was removed. All user-wide knowledge is deleted."
128
+ else
129
+ stdout.puts "\nNote: Global database (~/.claude/memory.sqlite3) was NOT removed."
130
+ stdout.puts " Run with --full flag to remove all files."
131
+ end
132
+
133
+ stdout.puts "\nRestart Claude Code to complete uninstallation."
134
+
135
+ 0
136
+ end
137
+
138
+ # Removes claude-memory hooks from a settings.json file
139
+ # Returns true if hooks were removed, false otherwise
140
+ def remove_hooks_from_file(settings_path)
141
+ return false unless File.exist?(settings_path)
142
+
143
+ begin
144
+ config = JSON.parse(File.read(settings_path))
145
+ rescue JSON::ParserError
146
+ stderr.puts "Warning: Could not parse #{settings_path}, skipping hook removal"
147
+ return false
148
+ end
149
+
150
+ return false unless config["hooks"]
151
+
152
+ modified = false
153
+ config["hooks"].each do |event, hook_arrays|
154
+ next unless hook_arrays.is_a?(Array)
155
+
156
+ # Filter out hook arrays that contain claude-memory commands
157
+ original_count = hook_arrays.size
158
+ hook_arrays.reject! do |hook_array|
159
+ next false unless hook_array["hooks"].is_a?(Array)
160
+
161
+ hook_array["hooks"].any? { |h| h["command"]&.include?("claude-memory") }
162
+ end
163
+
164
+ modified = true if hook_arrays.size < original_count
165
+
166
+ # Remove empty event keys
167
+ config["hooks"].delete(event) if hook_arrays.empty?
168
+ end
169
+
170
+ if modified
171
+ # Remove hooks key entirely if empty
172
+ config.delete("hooks") if config["hooks"].empty?
173
+
174
+ File.write(settings_path, JSON.pretty_generate(config))
175
+ true
176
+ else
177
+ false
178
+ end
179
+ end
180
+
181
+ # Removes claude-memory MCP server from .claude.json
182
+ # Returns true if server was removed, false otherwise
183
+ def remove_mcp_server(mcp_path)
184
+ return false unless File.exist?(mcp_path)
185
+
186
+ begin
187
+ config = JSON.parse(File.read(mcp_path))
188
+ rescue JSON::ParserError
189
+ stderr.puts "Warning: Could not parse #{mcp_path}, skipping MCP removal"
190
+ return false
191
+ end
192
+
193
+ return false unless config["mcpServers"]&.key?("claude-memory")
194
+
195
+ config["mcpServers"].delete("claude-memory")
196
+ config.delete("mcpServers") if config["mcpServers"].empty?
197
+
198
+ File.write(mcp_path, JSON.pretty_generate(config))
199
+ true
200
+ end
201
+
202
+ # Removes ClaudeMemory section from CLAUDE.md
203
+ # Returns true if section was removed, false otherwise
204
+ def remove_claude_md_section(claude_md_path)
205
+ return false unless File.exist?(claude_md_path)
206
+
207
+ content = File.read(claude_md_path)
208
+ return false unless content.include?("ClaudeMemory")
209
+
210
+ # Remove ClaudeMemory section (marked by HTML comment through end of section)
211
+ # Match from HTML comment through content until we hit another HTML comment or ## header
212
+ modified_content = content.gsub(/<!-- ClaudeMemory v.*?-->.*?(?=^##|\n<!--|\Z)/m, "")
213
+
214
+ # Clean up extra blank lines
215
+ modified_content = modified_content.gsub(/\n{3,}/, "\n\n").strip
216
+
217
+ if modified_content != content
218
+ File.write(claude_md_path, modified_content)
219
+ true
220
+ else
221
+ false
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Core
5
+ # Utility for batch loading records from Sequel datasets
6
+ # Eliminates duplication of batch query patterns
7
+ class BatchLoader
8
+ # Load multiple records by IDs and organize them by a key
9
+ #
10
+ # @param dataset [Sequel::Dataset] The dataset to query
11
+ # @param ids [Array] The IDs to load
12
+ # @param group_by [Symbol] How to organize results (:single for hash by ID, or column name for grouping)
13
+ # @return [Hash] Results organized by the specified key
14
+ def self.load_many(dataset, ids, group_by: :id)
15
+ return {} if ids.empty?
16
+
17
+ results = dataset.where(id: ids).all
18
+
19
+ case group_by
20
+ when :single
21
+ # Single record per ID (hash by ID)
22
+ results.each_with_object({}) { |row, hash| hash[row[:id]] = row }
23
+ when Symbol
24
+ # Multiple records per key (grouped)
25
+ results.group_by { |row| row[group_by] }
26
+ else
27
+ raise ArgumentError, "Invalid group_by: #{group_by.inspect}"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end