claude_memory 0.7.1 → 0.9.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +32 -2
  4. data/.claude/settings.json +65 -15
  5. data/.claude/settings.local.json +5 -2
  6. data/.claude/skills/improve/SKILL.md +113 -25
  7. data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
  8. data/.claude-plugin/commands/distill-transcripts.md +98 -0
  9. data/.claude-plugin/commands/memory-recall.md +67 -0
  10. data/.claude-plugin/marketplace.json +2 -2
  11. data/.claude-plugin/plugin.json +3 -3
  12. data/.claude-plugin/scripts/hook-runner.sh +14 -0
  13. data/.claude-plugin/scripts/serve-mcp.sh +14 -0
  14. data/.ruby-version +1 -1
  15. data/CHANGELOG.md +90 -1
  16. data/CLAUDE.md +56 -18
  17. data/README.md +35 -0
  18. data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
  19. data/db/migrations/014_canonicalize_predicates.rb +30 -0
  20. data/docs/improvements.md +74 -74
  21. data/docs/influence/claude-mem.md +1 -0
  22. data/docs/influence/claude-supermemory.md +1 -0
  23. data/docs/influence/episodic-memory.md +1 -0
  24. data/docs/influence/grepai.md +1 -0
  25. data/docs/influence/kbs.md +1 -0
  26. data/docs/influence/lossless-claw.md +1 -0
  27. data/docs/influence/qmd.md +1 -0
  28. data/docs/quality_review.md +119 -224
  29. data/hooks/hooks.json +39 -7
  30. data/lib/claude_memory/commands/checks/distill_check.rb +61 -0
  31. data/lib/claude_memory/commands/checks/hooks_check.rb +2 -2
  32. data/lib/claude_memory/commands/checks/vec_check.rb +2 -1
  33. data/lib/claude_memory/commands/completion_command.rb +149 -0
  34. data/lib/claude_memory/commands/doctor_command.rb +2 -0
  35. data/lib/claude_memory/commands/embeddings_command.rb +198 -0
  36. data/lib/claude_memory/commands/help_command.rb +12 -1
  37. data/lib/claude_memory/commands/hook_command.rb +2 -1
  38. data/lib/claude_memory/commands/index_command.rb +85 -78
  39. data/lib/claude_memory/commands/initializers/database_ensurer.rb +16 -0
  40. data/lib/claude_memory/commands/initializers/global_initializer.rb +2 -1
  41. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +55 -11
  42. data/lib/claude_memory/commands/initializers/project_initializer.rb +2 -1
  43. data/lib/claude_memory/commands/install_skill_command.rb +78 -0
  44. data/lib/claude_memory/commands/registry.rb +47 -32
  45. data/lib/claude_memory/commands/reject_command.rb +62 -0
  46. data/lib/claude_memory/commands/restore_command.rb +77 -0
  47. data/lib/claude_memory/commands/skills/distill-transcripts.md +102 -0
  48. data/lib/claude_memory/commands/skills/memory-recall.md +67 -0
  49. data/lib/claude_memory/commands/stats_command.rb +98 -2
  50. data/lib/claude_memory/configuration.rb +14 -1
  51. data/lib/claude_memory/core/fact_ranker.rb +2 -2
  52. data/lib/claude_memory/core/rr_fusion.rb +23 -6
  53. data/lib/claude_memory/core/snippet_extractor.rb +7 -3
  54. data/lib/claude_memory/core/text_builder.rb +11 -0
  55. data/lib/claude_memory/distill/json_schema.md +8 -4
  56. data/lib/claude_memory/distill/null_distiller.rb +2 -0
  57. data/lib/claude_memory/domain/entity.rb +13 -1
  58. data/lib/claude_memory/domain/fact.rb +26 -2
  59. data/lib/claude_memory/domain/provenance.rb +0 -1
  60. data/lib/claude_memory/embeddings/api_adapter.rb +97 -0
  61. data/lib/claude_memory/embeddings/dimension_check.rb +23 -0
  62. data/lib/claude_memory/embeddings/fastembed_adapter.rb +46 -12
  63. data/lib/claude_memory/embeddings/generator.rb +4 -0
  64. data/lib/claude_memory/embeddings/inspector.rb +91 -0
  65. data/lib/claude_memory/embeddings/model_registry.rb +210 -0
  66. data/lib/claude_memory/embeddings/resolver.rb +44 -0
  67. data/lib/claude_memory/hook/context_injector.rb +58 -2
  68. data/lib/claude_memory/hook/distillation_runner.rb +46 -0
  69. data/lib/claude_memory/hook/handler.rb +11 -2
  70. data/lib/claude_memory/index/vector_index.rb +15 -2
  71. data/lib/claude_memory/infrastructure/schema_validator.rb +3 -3
  72. data/lib/claude_memory/ingest/ingester.rb +17 -0
  73. data/lib/claude_memory/mcp/handlers/context_handlers.rb +38 -0
  74. data/lib/claude_memory/mcp/handlers/management_handlers.rb +169 -0
  75. data/lib/claude_memory/mcp/handlers/query_handlers.rb +115 -0
  76. data/lib/claude_memory/mcp/handlers/setup_handlers.rb +211 -0
  77. data/lib/claude_memory/mcp/handlers/shortcut_handlers.rb +37 -0
  78. data/lib/claude_memory/mcp/handlers/stats_handlers.rb +205 -0
  79. data/lib/claude_memory/mcp/instructions_builder.rb +19 -1
  80. data/lib/claude_memory/mcp/query_guide.rb +10 -0
  81. data/lib/claude_memory/mcp/response_formatter.rb +1 -0
  82. data/lib/claude_memory/mcp/server.rb +22 -1
  83. data/lib/claude_memory/mcp/telemetry.rb +86 -0
  84. data/lib/claude_memory/mcp/text_summary.rb +26 -0
  85. data/lib/claude_memory/mcp/tool_definitions.rb +116 -4
  86. data/lib/claude_memory/mcp/tool_helpers.rb +43 -0
  87. data/lib/claude_memory/mcp/tools.rb +50 -679
  88. data/lib/claude_memory/publish.rb +40 -5
  89. data/lib/claude_memory/recall/dual_engine.rb +105 -0
  90. data/lib/claude_memory/recall/legacy_engine.rb +138 -0
  91. data/lib/claude_memory/recall/query_core.rb +371 -0
  92. data/lib/claude_memory/recall.rb +121 -673
  93. data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
  94. data/lib/claude_memory/resolve/resolver.rb +43 -0
  95. data/lib/claude_memory/shortcuts.rb +4 -4
  96. data/lib/claude_memory/store/retry_handler.rb +61 -0
  97. data/lib/claude_memory/store/schema_manager.rb +68 -0
  98. data/lib/claude_memory/store/sqlite_store.rb +334 -201
  99. data/lib/claude_memory/store/store_manager.rb +50 -1
  100. data/lib/claude_memory/sweep/maintenance.rb +115 -1
  101. data/lib/claude_memory/sweep/sweeper.rb +3 -0
  102. data/lib/claude_memory/templates/hooks.example.json +26 -7
  103. data/lib/claude_memory/version.rb +1 -1
  104. data/lib/claude_memory.rb +16 -0
  105. metadata +48 -8
  106. data/.claude/memory.sqlite3-shm +0 -0
  107. data/.claude/memory.sqlite3-wal +0 -0
@@ -7,12 +7,28 @@ require_relative "response_formatter"
7
7
  require_relative "tool_definitions"
8
8
  require_relative "setup_status_analyzer"
9
9
  require_relative "error_classifier"
10
+ require_relative "handlers/query_handlers"
11
+ require_relative "handlers/shortcut_handlers"
12
+ require_relative "handlers/context_handlers"
13
+ require_relative "handlers/management_handlers"
14
+ require_relative "handlers/stats_handlers"
15
+ require_relative "handlers/setup_handlers"
10
16
 
11
17
  module ClaudeMemory
12
18
  module MCP
19
+ # Dispatcher that routes MCP tool calls to handler modules.
20
+ # Each handler module (QueryHandlers, ShortcutHandlers, etc.) provides
21
+ # the implementation for a group of related tools.
13
22
  class Tools
14
23
  include ToolHelpers
15
-
24
+ include Handlers::QueryHandlers
25
+ include Handlers::ShortcutHandlers
26
+ include Handlers::ContextHandlers
27
+ include Handlers::ManagementHandlers
28
+ include Handlers::StatsHandlers
29
+ include Handlers::SetupHandlers
30
+
31
+ # @param store_or_manager [Store::SQLiteStore, Store::StoreManager] database backend
16
32
  def initialize(store_or_manager)
17
33
  @recall = Recall.new(store_or_manager)
18
34
 
@@ -23,381 +39,52 @@ module ClaudeMemory
23
39
  end
24
40
  end
25
41
 
42
+ # @return [Array<Hash>] MCP tool definition hashes for tools/list
26
43
  def definitions
27
44
  ToolDefinitions.all
28
45
  end
29
46
 
47
+ # Dispatch a tool call to the appropriate handler method.
48
+ # @param name [String] fully-qualified tool name (e.g. "memory.recall")
49
+ # @param arguments [Hash] tool arguments from the MCP request
50
+ # @return [Hash] structured result hash for the tool response
30
51
  def call(name, arguments)
31
52
  case name
32
- when "memory.recall"
33
- recall(arguments)
34
- when "memory.recall_index"
35
- recall_index(arguments)
36
- when "memory.recall_details"
37
- recall_details(arguments)
38
- when "memory.explain"
39
- explain(arguments)
40
- when "memory.changes"
41
- changes(arguments)
42
- when "memory.conflicts"
43
- conflicts(arguments)
44
- when "memory.sweep_now"
45
- sweep_now(arguments)
46
- when "memory.status"
47
- status
48
- when "memory.stats"
49
- stats(arguments)
50
- when "memory.promote"
51
- promote(arguments)
52
- when "memory.store_extraction"
53
- store_extraction(arguments)
54
- when "memory.decisions"
55
- decisions(arguments)
56
- when "memory.conventions"
57
- conventions(arguments)
58
- when "memory.architecture"
59
- architecture(arguments)
60
- when "memory.facts_by_tool"
61
- facts_by_tool(arguments)
62
- when "memory.facts_by_context"
63
- facts_by_context(arguments)
64
- when "memory.recall_semantic"
65
- recall_semantic(arguments)
66
- when "memory.search_concepts"
67
- search_concepts(arguments)
68
- when "memory.fact_graph"
69
- fact_graph(arguments)
70
- when "memory.check_setup"
71
- check_setup
72
- when "memory.list_projects"
73
- list_projects
74
- else
75
- {error: "Unknown tool: #{name}"}
53
+ when "memory.recall" then recall(arguments)
54
+ when "memory.recall_index" then recall_index(arguments)
55
+ when "memory.recall_details" then recall_details(arguments)
56
+ when "memory.explain" then explain(arguments)
57
+ when "memory.changes" then changes(arguments)
58
+ when "memory.conflicts" then conflicts(arguments)
59
+ when "memory.sweep_now" then sweep_now(arguments)
60
+ when "memory.status" then status
61
+ when "memory.stats" then stats(arguments)
62
+ when "memory.promote" then promote(arguments)
63
+ when "memory.reject_fact" then reject_fact(arguments)
64
+ when "memory.store_extraction" then store_extraction(arguments)
65
+ when "memory.decisions" then decisions(arguments)
66
+ when "memory.conventions" then conventions(arguments)
67
+ when "memory.architecture" then architecture(arguments)
68
+ when "memory.facts_by_tool" then facts_by_tool(arguments)
69
+ when "memory.facts_by_context" then facts_by_context(arguments)
70
+ when "memory.recall_semantic" then recall_semantic(arguments)
71
+ when "memory.search_concepts" then search_concepts(arguments)
72
+ when "memory.fact_graph" then fact_graph(arguments)
73
+ when "memory.undistilled" then undistilled(arguments)
74
+ when "memory.mark_distilled" then mark_distilled(arguments)
75
+ when "memory.check_setup" then check_setup
76
+ when "memory.list_projects" then list_projects
77
+ else {error: "Unknown tool: #{name}"}
76
78
  end
77
79
  end
78
80
 
79
81
  private
80
82
 
81
- def recall(args)
82
- # Check if databases exist before querying
83
- return database_not_found_error unless databases_exist?
84
-
85
- scope = extract_scope(args)
86
- limit = extract_limit(args)
87
- compact = args["compact"] == true
88
- query = args["query"]
89
- results = @recall.query(query, limit: limit, scope: scope, include_raw_text: !compact)
90
- ResponseFormatter.format_recall_results(results, compact: compact, query: query)
91
- rescue Sequel::DatabaseError, Sequel::DatabaseConnectionError, Errno::ENOENT => e
92
- classified_error(e, tool_name: "memory.recall")
93
- end
94
-
95
- def recall_index(args)
96
- scope = extract_scope(args)
97
- limit = extract_limit(args, default: 20)
98
- results = @recall.query_index(args["query"], limit: limit, scope: scope)
99
- ResponseFormatter.format_index_results(args["query"], scope, results)
100
- end
101
-
102
- def recall_details(args)
103
- fact_ids = args["fact_ids"]
104
- scope = args["scope"] || "project"
105
-
106
- # Batch fetch detailed explanations
107
- explanations = fact_ids.map do |fact_id|
108
- explanation = @recall.explain(fact_id, scope: scope)
109
- next nil if explanation.is_a?(Core::NullExplanation)
110
-
111
- ResponseFormatter.format_detailed_explanation(explanation)
112
- end.compact
113
-
114
- {
115
- fact_count: explanations.size,
116
- facts: explanations
117
- }
118
- end
119
-
120
- def explain(args)
121
- scope = args["scope"] || "project"
122
- explanation = @recall.explain(args["fact_id"], scope: scope)
123
- return {error: "Fact not found in #{scope} database"} if explanation.is_a?(Core::NullExplanation)
124
-
125
- ResponseFormatter.format_explanation(explanation, scope)
126
- end
127
-
128
- def changes(args)
129
- since = args["since"] || (Time.now - 86400 * 7).utc.iso8601
130
- scope = args["scope"] || "all"
131
- list = @recall.changes(since: since, limit: args["limit"] || 20, scope: scope)
132
- ResponseFormatter.format_changes(since, list)
133
- end
134
-
135
- def conflicts(args)
136
- scope = args["scope"] || "all"
137
- list = @recall.conflicts(scope: scope)
138
- ResponseFormatter.format_conflicts(list)
139
- end
140
-
141
- def sweep_now(args)
142
- scope = args["scope"] || "project"
143
- store = get_store_for_scope(scope)
144
- return {error: "Database not available"} unless store
145
-
146
- sweeper = Sweep::Sweeper.new(store)
147
- budget = args["budget_seconds"] || 5
148
- stats = if args["escalate"]
149
- sweeper.run_with_escalation!(budget_seconds: budget)
150
- else
151
- sweeper.run!(budget_seconds: budget)
152
- end
153
- ResponseFormatter.format_sweep_stats(scope, stats)
154
- end
155
-
156
- def status
157
- result = {databases: {}}
158
-
159
- if @manager
160
- if @manager.global_exists?
161
- @manager.ensure_global!
162
- result[:databases][:global] = db_stats(@manager.global_store)
163
- else
164
- result[:databases][:global] = {exists: false}
165
- end
166
-
167
- if @manager.project_exists?
168
- @manager.ensure_project!
169
- result[:databases][:project] = db_stats(@manager.project_store)
170
- else
171
- result[:databases][:project] = {exists: false}
172
- end
173
- else
174
- result[:databases][:legacy] = db_stats(@legacy_store)
175
- end
176
-
177
- result
178
- end
179
-
180
- def stats(args)
181
- scope = args["scope"] || "all"
182
- result = {scope: scope, databases: {}}
183
-
184
- if @manager
185
- if scope == "all" || scope == "global"
186
- if @manager.global_exists?
187
- @manager.ensure_global!
188
- result[:databases][:global] = detailed_stats(@manager.global_store)
189
- else
190
- result[:databases][:global] = {exists: false}
191
- end
192
- end
193
-
194
- if scope == "all" || scope == "project"
195
- if @manager.project_exists?
196
- @manager.ensure_project!
197
- result[:databases][:project] = detailed_stats(@manager.project_store)
198
- else
199
- result[:databases][:project] = {exists: false}
200
- end
201
- end
202
- else
203
- result[:databases][:legacy] = detailed_stats(@legacy_store)
204
- end
205
-
206
- result
207
- end
208
-
209
- def promote(args)
210
- return {error: "Promote requires StoreManager"} unless @manager
211
-
212
- fact_id = args["fact_id"]
213
- global_fact_id = @manager.promote_fact(fact_id)
214
-
215
- if global_fact_id
216
- {
217
- success: true,
218
- project_fact_id: fact_id,
219
- global_fact_id: global_fact_id,
220
- message: "Fact promoted to global memory"
221
- }
222
- else
223
- {error: "Fact #{fact_id} not found in project database"}
224
- end
225
- end
226
-
227
- def store_extraction(args)
228
- scope = args["scope"] || "project"
229
- store = get_store_for_scope(scope)
230
- return {error: "Database not available"} unless store
231
-
232
- entities = (args["entities"] || []).map { |e| symbolize_keys(e) }
233
- facts = (args["facts"] || []).map { |f| symbolize_keys(f) }
234
- decisions = (args["decisions"] || []).map { |d| symbolize_keys(d) }
235
-
236
- config = Configuration.new
237
- project_path = config.project_dir
238
- occurred_at = Time.now.utc.iso8601
239
-
240
- searchable_text = build_searchable_text(entities, facts, decisions)
241
- content_item_id = create_synthetic_content_item(store, searchable_text, project_path, occurred_at)
242
- index_content_item(store, content_item_id, searchable_text)
243
-
244
- extraction = Distill::Extraction.new(
245
- entities: entities,
246
- facts: facts,
247
- decisions: decisions,
248
- signals: []
249
- )
250
-
251
- resolver = Resolve::Resolver.new(store)
252
- result = resolver.apply(
253
- extraction,
254
- content_item_id: content_item_id,
255
- occurred_at: occurred_at,
256
- project_path: project_path,
257
- scope: scope
258
- )
259
-
260
- {
261
- success: true,
262
- scope: scope,
263
- entities_created: result[:entities_created],
264
- facts_created: result[:facts_created],
265
- facts_superseded: result[:facts_superseded],
266
- conflicts_created: result[:conflicts_created]
267
- }
268
- end
269
-
270
- def build_searchable_text(entities, facts, decisions)
271
- Core::TextBuilder.build_searchable_text(entities, facts, decisions)
272
- end
273
-
274
- def create_synthetic_content_item(store, text, project_path, occurred_at)
275
- text_hash = Digest::SHA256.hexdigest(text)
276
- store.upsert_content_item(
277
- source: "mcp_extraction",
278
- session_id: "mcp-#{Time.now.to_i}",
279
- transcript_path: nil,
280
- project_path: project_path,
281
- text_hash: text_hash,
282
- byte_len: text.bytesize,
283
- raw_text: text,
284
- occurred_at: occurred_at
285
- )
286
- end
287
-
288
- def index_content_item(store, content_item_id, text)
289
- fts = Index::LexicalFTS.new(store)
290
- fts.index_content_item(content_item_id, text)
291
- end
292
-
293
- def symbolize_keys(hash)
294
- Core::TextBuilder.symbolize_keys(hash)
295
- end
296
-
297
- def get_store_for_scope(scope)
298
- if @manager
299
- @manager.store_for_scope(scope)
300
- else
301
- @legacy_store
302
- end
303
- end
304
-
305
- def decisions(args)
306
- return {error: "Decisions shortcut requires StoreManager"} unless @manager
307
-
308
- results = Recall.recent_decisions(@manager, limit: args["limit"] || 10)
309
- format_shortcut_results(results, "decisions")
310
- end
311
-
312
- def conventions(args)
313
- return {error: "Conventions shortcut requires StoreManager"} unless @manager
314
-
315
- results = Recall.conventions(@manager, limit: args["limit"] || 20)
316
- format_shortcut_results(results, "conventions")
317
- end
318
-
319
- def architecture(args)
320
- return {error: "Architecture shortcut requires StoreManager"} unless @manager
321
-
322
- results = Recall.architecture_choices(@manager, limit: args["limit"] || 10)
323
- format_shortcut_results(results, "architecture")
324
- end
325
-
326
- def format_shortcut_results(results, category)
327
- ResponseFormatter.format_shortcut_results(category, results)
328
- end
329
-
330
- def facts_by_tool(args)
331
- tool_name = args["tool_name"]
332
- scope = extract_scope(args)
333
- limit = extract_limit(args, default: 20)
334
-
335
- results = @recall.facts_by_tool(tool_name, limit: limit, scope: scope)
336
- ResponseFormatter.format_tool_facts(tool_name, scope, results)
337
- end
338
-
339
- def facts_by_context(args)
340
- scope = extract_scope(args)
341
- limit = extract_limit(args, default: 20)
342
-
343
- if args["git_branch"]
344
- results = @recall.facts_by_branch(args["git_branch"], limit: limit, scope: scope)
345
- context_type = "git_branch"
346
- context_value = args["git_branch"]
347
- elsif args["cwd"]
348
- results = @recall.facts_by_directory(args["cwd"], limit: limit, scope: scope)
349
- context_type = "cwd"
350
- context_value = args["cwd"]
351
- else
352
- return {error: "Must provide either git_branch or cwd parameter"}
353
- end
354
-
355
- ResponseFormatter.format_context_facts(context_type, context_value, scope, results)
356
- end
357
-
358
- def recall_semantic(args)
359
- query = args["query"]
360
- mode = (args["mode"] || "both").to_sym
361
- scope = extract_scope(args)
362
- limit = extract_limit(args)
363
- compact = args["compact"] == true
364
-
365
- results = @recall.query_semantic(query, limit: limit, scope: scope, mode: mode)
366
- ResponseFormatter.format_semantic_results(query, mode.to_s, scope, results, compact: compact)
367
- end
368
-
369
- def search_concepts(args)
370
- concepts = args["concepts"]
371
- scope = extract_scope(args)
372
- limit = extract_limit(args)
373
- compact = args["compact"] == true
374
-
375
- return {error: "Must provide 2-5 concepts"} unless (2..5).cover?(concepts.size)
376
-
377
- results = @recall.query_concepts(concepts, limit: limit, scope: scope)
378
- ResponseFormatter.format_concept_results(concepts, scope, results, compact: compact)
379
- end
380
-
381
- def fact_graph(args)
382
- fact_id = args["fact_id"]
383
- depth = args["depth"] || 2
384
- scope = args["scope"] || "project"
385
-
386
- graph = @recall.fact_graph(fact_id, depth: depth, scope: scope)
387
-
388
- return {error: "Fact #{fact_id} not found in #{scope} database"} if graph[:node_count] == 0
389
-
390
- graph
391
- end
392
-
393
83
  def databases_exist?
394
84
  if @manager
395
- # For dual-database mode, check if either database exists
396
85
  config = Configuration.new
397
86
  File.exist?(config.global_db_path) || File.exist?(config.project_db_path)
398
87
  elsif @legacy_store
399
- # For legacy mode, check if the database file exists
400
- # Extract the database path from the store's connection
401
88
  db_path = @legacy_store.db.opts[:database]
402
89
  db_path && File.exist?(db_path)
403
90
  else
@@ -417,328 +104,12 @@ module ClaudeMemory
417
104
  ErrorClassifier.build_error_response(error, tool_name: tool_name)
418
105
  end
419
106
 
420
- def check_setup
421
- issues = []
422
- warnings = []
423
- config = Configuration.new
424
-
425
- global_db_exists = check_global_database(config, issues)
426
- project_db_exists = check_project_database(config, warnings)
427
- current_version, version_status, claude_md_exists = check_claude_md_version(warnings)
428
- hooks_configured = check_hooks_configuration(warnings)
429
-
430
- build_setup_result(
431
- global_db_exists, project_db_exists, claude_md_exists,
432
- hooks_configured, current_version, version_status,
433
- issues, warnings
434
- )
435
- end
436
-
437
- def check_global_database(config, issues)
438
- exists = File.exist?(config.global_db_path)
439
- issues << "Global database not found at #{config.global_db_path}" unless exists
440
- exists
441
- end
442
-
443
- def check_project_database(config, warnings)
444
- exists = File.exist?(config.project_db_path)
445
- warnings << "Project database not found at #{config.project_db_path}" unless exists
446
- exists
447
- end
448
-
449
- def check_claude_md_version(warnings)
450
- claude_md_path = ".claude/CLAUDE.md"
451
- unless File.exist?(claude_md_path)
452
- warnings << "No .claude/CLAUDE.md found"
453
- return [nil, nil, false]
454
- end
455
-
456
- content = File.read(claude_md_path)
457
- unless content.include?("ClaudeMemory")
458
- warnings << "CLAUDE.md exists but no ClaudeMemory configuration found"
459
- return [nil, nil, true]
460
- end
461
-
462
- current_version = SetupStatusAnalyzer.extract_version(content)
463
- unless current_version
464
- warnings << "CLAUDE.md has ClaudeMemory section but no version marker"
465
- return [nil, "no_version_marker", true]
466
- end
467
-
468
- version_status = SetupStatusAnalyzer.determine_version_status(current_version, ClaudeMemory::VERSION)
469
- if version_status == "outdated"
470
- warnings << "Configuration version (v#{current_version}) is older than ClaudeMemory (v#{ClaudeMemory::VERSION}). Consider running upgrade."
471
- end
472
-
473
- [current_version, version_status, true]
474
- end
475
-
476
- def check_hooks_configuration(warnings)
477
- settings_paths = [".claude/settings.json", ".claude/settings.local.json"]
478
- settings_paths.each do |path|
479
- next unless File.exist?(path)
480
- begin
481
- config_data = JSON.parse(File.read(path))
482
- return true if config_data["hooks"]&.any?
483
- rescue JSON::ParserError
484
- warnings << "Invalid JSON in #{path}"
485
- end
486
- end
487
-
488
- warnings << "No hooks configured for automatic ingestion"
489
- false
490
- end
491
-
492
- def build_setup_result(global_db_exists, project_db_exists, claude_md_exists, hooks_configured, current_version, version_status, issues, warnings)
493
- initialized = global_db_exists && claude_md_exists
494
- status = SetupStatusAnalyzer.determine_status(global_db_exists, claude_md_exists, version_status)
495
- recommendations = SetupStatusAnalyzer.generate_recommendations(initialized, version_status, warnings.any?)
496
-
497
- {
498
- status: status,
499
- initialized: initialized,
500
- version: {
501
- current: current_version || "unknown",
502
- latest: ClaudeMemory::VERSION,
503
- status: version_status || "unknown"
504
- },
505
- components: {
506
- global_database: global_db_exists,
507
- project_database: project_db_exists,
508
- claude_md: claude_md_exists,
509
- hooks_configured: hooks_configured
510
- },
511
- issues: issues,
512
- warnings: warnings,
513
- recommendations: recommendations
514
- }
515
- end
516
-
517
- def list_projects
518
- result = {global: nil, current_project: nil, other_projects: []}
519
-
107
+ def get_store_for_scope(scope)
520
108
  if @manager
521
- result[:global] = list_global_database
522
- result[:current_project] = list_current_project
523
- result[:other_projects] = discover_other_projects
524
- elsif @legacy_store
525
- result[:global] = {
526
- exists: true,
527
- path: @legacy_store.db.opts[:database],
528
- facts_active: @legacy_store.facts.where(status: "active").count,
529
- entities: @legacy_store.entities.count
530
- }
531
- end
532
-
533
- result[:project_count] = 1 + result[:other_projects].size
534
- result
535
- end
536
-
537
- def list_global_database
538
- if @manager.global_exists?
539
- @manager.ensure_global!
540
- store = @manager.global_store
541
- {
542
- exists: true,
543
- path: @manager.global_db_path,
544
- facts_active: store.facts.where(status: "active").count,
545
- facts_total: store.facts.count,
546
- entities: store.entities.count
547
- }
548
- else
549
- {exists: false, path: @manager.global_db_path}
550
- end
551
- end
552
-
553
- def list_current_project
554
- if @manager.project_exists?
555
- @manager.ensure_project!
556
- store = @manager.project_store
557
- {
558
- exists: true,
559
- path: @manager.project_path,
560
- db_path: @manager.project_db_path,
561
- facts_active: store.facts.where(status: "active").count,
562
- facts_total: store.facts.count,
563
- entities: store.entities.count
564
- }
109
+ @manager.store_for_scope(scope)
565
110
  else
566
- {exists: false, path: @manager.project_path, db_path: @manager.project_db_path}
567
- end
568
- end
569
-
570
- def discover_other_projects
571
- return [] unless @manager.global_exists?
572
-
573
- @manager.ensure_global!
574
- global = @manager.global_store
575
-
576
- # Find project paths from promoted facts
577
- promoted_paths = global.facts
578
- .where(Sequel.like(:created_from, "promoted:%"))
579
- .select(:created_from)
580
- .distinct
581
- .all
582
- .filter_map { |f|
583
- match = f[:created_from]&.match(/\Apromoted:(.+):\d+\z/)
584
- match[1] if match
585
- }
586
- .uniq
587
-
588
- # Also check for project_path values on facts
589
- fact_paths = global.facts
590
- .exclude(project_path: nil)
591
- .select(:project_path)
592
- .distinct
593
- .all
594
- .map { |f| f[:project_path] }
595
-
596
- all_paths = (promoted_paths + fact_paths).uniq
597
- current = @manager.project_path
598
-
599
- all_paths.filter_map { |path|
600
- next if path == current
601
-
602
- db_path = File.join(path, ".claude", "memory.sqlite3")
603
- entry = {path: path, db_path: db_path, exists: File.exist?(db_path)}
604
-
605
- if entry[:exists]
606
- begin
607
- temp_store = Store::SQLiteStore.new(db_path)
608
- entry[:facts_active] = temp_store.facts.where(status: "active").count
609
- entry[:facts_total] = temp_store.facts.count
610
- entry[:entities] = temp_store.entities.count
611
- temp_store.close
612
- rescue Sequel::DatabaseError, Extralite::Error, IOError => _e
613
- entry[:error] = "Could not read database"
614
- end
615
- end
616
-
617
- entry
618
- }
619
- end
620
-
621
- def db_stats(store)
622
- stats = {
623
- exists: true,
624
- facts_total: store.facts.count,
625
- facts_active: store.facts.where(status: "active").count,
626
- content_items: store.content_items.count,
627
- open_conflicts: store.conflicts.where(status: "open").count,
628
- schema_version: store.schema_version
629
- }
630
-
631
- vec_index = store.vector_index
632
- stats[:vec_available] = vec_index.available?
633
- stats[:vec_indexed] = vec_index.coverage_stats[:vec_indexed] if vec_index.available?
634
-
635
- if fts_legacy?(store)
636
- stats[:fts_legacy] = true
637
- stats[:optimization_hint] = "Run 'claude-memory compact' to reduce database size by ~40%"
638
- end
639
-
640
- stats
641
- end
642
-
643
- def fts_legacy?(store)
644
- row = store.db.fetch("SELECT sql FROM sqlite_master WHERE name = 'content_fts' AND type = 'table'").first
645
- row && !row[:sql].to_s.include?("content=''")
646
- rescue
647
- false
648
- end
649
-
650
- def detailed_stats(store)
651
- active_facts = store.facts.where(status: "active").count
652
-
653
- stats = {
654
- exists: true,
655
- facts: fact_stats(store, active_facts),
656
- entities: entity_stats(store),
657
- content_items: content_stats(store),
658
- provenance: provenance_stats(store, active_facts),
659
- conflicts: conflict_stats(store),
660
- schema_version: store.schema_version
661
- }
662
-
663
- stats[:vec] = vec_stats(store, active_facts)
664
-
665
- stats
666
- end
667
-
668
- def fact_stats(store, active_facts)
669
- stats = {
670
- total: store.facts.count,
671
- active: active_facts,
672
- superseded: store.facts.where(status: "superseded").count
673
- }
674
-
675
- if active_facts > 0
676
- stats[:top_predicates] = store.db[:facts]
677
- .where(status: "active")
678
- .group_and_count(:predicate)
679
- .order(Sequel.desc(:count))
680
- .limit(10)
681
- .all
682
- .map { |row| {predicate: row[:predicate], count: row[:count]} }
683
- end
684
-
685
- stats
686
- end
687
-
688
- def entity_stats(store)
689
- {
690
- total: store.entities.count,
691
- by_type: store.db[:entities]
692
- .group_and_count(:type)
693
- .order(Sequel.desc(:count))
694
- .all
695
- .map { |row| {type: row[:type], count: row[:count]} }
696
- }
697
- end
698
-
699
- def content_stats(store)
700
- count = store.content_items.count
701
- stats = {total: count}
702
-
703
- if count > 0
704
- stats[:date_range] = {
705
- first: store.content_items.min(:occurred_at),
706
- last: store.content_items.max(:occurred_at)
707
- }
111
+ @legacy_store
708
112
  end
709
-
710
- stats
711
- end
712
-
713
- def provenance_stats(store, active_facts)
714
- return {facts_with_sources: 0, total_active_facts: 0, coverage_percentage: 0} if active_facts == 0
715
-
716
- facts_with_provenance = store.db[:provenance]
717
- .join(:facts, id: :fact_id)
718
- .where(Sequel[:facts][:status] => "active")
719
- .select(Sequel[:provenance][:fact_id])
720
- .distinct
721
- .count
722
-
723
- {
724
- facts_with_sources: facts_with_provenance,
725
- total_active_facts: active_facts,
726
- coverage_percentage: (facts_with_provenance * 100.0 / active_facts).round(1)
727
- }
728
- end
729
-
730
- def vec_stats(store, _active_facts)
731
- vec_index = store.vector_index
732
- result = {available: vec_index.available?}
733
- result.merge!(vec_index.coverage_stats) if vec_index.available?
734
- result
735
- end
736
-
737
- def conflict_stats(store)
738
- open = store.conflicts.where(status: "open").count
739
- resolved = store.conflicts.where(status: "resolved").count
740
-
741
- {open: open, resolved: resolved, total: open + resolved}
742
113
  end
743
114
  end
744
115
  end