claude_memory 0.5.1 → 0.6.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/CLAUDE.md +1 -1
  3. data/.claude/rules/claude_memory.generated.md +1 -1
  4. data/.claude/settings.json +5 -0
  5. data/.claude/settings.local.json +9 -1
  6. data/.claude-plugin/marketplace.json +5 -2
  7. data/.claude-plugin/plugin.json +16 -3
  8. data/CHANGELOG.md +55 -0
  9. data/CLAUDE.md +27 -13
  10. data/README.md +6 -2
  11. data/Rakefile +22 -0
  12. data/db/migrations/011_add_tool_call_summaries.rb +18 -0
  13. data/db/migrations/012_add_vec_indexing_support.rb +19 -0
  14. data/docs/improvements.md +86 -66
  15. data/docs/influence/claude-mem.md +253 -0
  16. data/docs/influence/claude-supermemory.md +158 -430
  17. data/docs/influence/episodic-memory.md +217 -0
  18. data/docs/influence/grepai.md +163 -839
  19. data/docs/influence/kbs.md +437 -0
  20. data/docs/influence/qmd.md +139 -481
  21. data/hooks/hooks.json +19 -15
  22. data/lefthook.yml +4 -0
  23. data/lib/claude_memory/commands/checks/vec_check.rb +73 -0
  24. data/lib/claude_memory/commands/compact_command.rb +94 -0
  25. data/lib/claude_memory/commands/doctor_command.rb +1 -0
  26. data/lib/claude_memory/commands/export_command.rb +108 -0
  27. data/lib/claude_memory/commands/help_command.rb +2 -0
  28. data/lib/claude_memory/commands/hook_command.rb +110 -9
  29. data/lib/claude_memory/commands/index_command.rb +63 -8
  30. data/lib/claude_memory/commands/initializers/global_initializer.rb +26 -7
  31. data/lib/claude_memory/commands/initializers/project_initializer.rb +35 -12
  32. data/lib/claude_memory/commands/registry.rb +3 -1
  33. data/lib/claude_memory/hook/context_injector.rb +75 -0
  34. data/lib/claude_memory/hook/error_classifier.rb +67 -0
  35. data/lib/claude_memory/hook/handler.rb +21 -1
  36. data/lib/claude_memory/index/vector_index.rb +171 -0
  37. data/lib/claude_memory/infrastructure/schema_validator.rb +5 -1
  38. data/lib/claude_memory/ingest/ingester.rb +26 -1
  39. data/lib/claude_memory/ingest/observation_compressor.rb +177 -0
  40. data/lib/claude_memory/mcp/instructions_builder.rb +76 -0
  41. data/lib/claude_memory/mcp/server.rb +3 -1
  42. data/lib/claude_memory/mcp/tool_definitions.rb +15 -7
  43. data/lib/claude_memory/mcp/tools.rb +125 -2
  44. data/lib/claude_memory/publish.rb +28 -27
  45. data/lib/claude_memory/recall/dual_query_template.rb +1 -12
  46. data/lib/claude_memory/recall.rb +71 -17
  47. data/lib/claude_memory/store/sqlite_store.rb +17 -1
  48. data/lib/claude_memory/sweep/sweeper.rb +30 -0
  49. data/lib/claude_memory/version.rb +1 -1
  50. data/lib/claude_memory.rb +8 -0
  51. data/scripts/hook-runner.sh +14 -0
  52. data/scripts/serve-mcp.sh +14 -0
  53. data/skills/setup-memory/SKILL.md +6 -0
  54. metadata +31 -2
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeMemory
6
+ module Ingest
7
+ # Compresses tool call observations into human-readable summaries.
8
+ # Reduces ~70% token usage vs raw tool I/O in provenance descriptions.
9
+ #
10
+ # @example
11
+ # compressor = ObservationCompressor.new
12
+ # summary = compressor.compress("Edit", '{"file_path":"/src/auth.rb","old_string":"def login","new_string":"def async_login"}')
13
+ # # => "Edited auth.rb: 'def login' → 'def async_login'"
14
+ class ObservationCompressor
15
+ MAX_SUMMARY_LENGTH = 200
16
+
17
+ def compress(tool_name, tool_input_json)
18
+ return nil if tool_input_json.nil? || tool_input_json.empty?
19
+
20
+ input = parse_input(tool_input_json)
21
+ return nil unless input
22
+
23
+ summary = case tool_name
24
+ when "Edit"
25
+ compress_edit(input)
26
+ when "Write"
27
+ compress_write(input)
28
+ when "Bash"
29
+ compress_bash(input)
30
+ when "Read"
31
+ compress_read(input)
32
+ when "Glob"
33
+ compress_glob(input)
34
+ when "Grep"
35
+ compress_grep(input)
36
+ when "Task"
37
+ compress_task(input)
38
+ when "WebFetch"
39
+ compress_web_fetch(input)
40
+ when "WebSearch"
41
+ compress_web_search(input)
42
+ when "NotebookEdit"
43
+ compress_notebook_edit(input)
44
+ else
45
+ compress_generic(tool_name, input)
46
+ end
47
+
48
+ truncate(summary)
49
+ end
50
+
51
+ private
52
+
53
+ def parse_input(json_str)
54
+ JSON.parse(json_str)
55
+ rescue JSON::ParserError
56
+ nil
57
+ end
58
+
59
+ def compress_edit(input)
60
+ file = short_path(input["file_path"])
61
+ old_str = truncate_str(input["old_string"], 40)
62
+ new_str = truncate_str(input["new_string"], 40)
63
+
64
+ if old_str && new_str
65
+ "Edited #{file}: '#{old_str}' → '#{new_str}'"
66
+ elsif file
67
+ "Edited #{file}"
68
+ end
69
+ end
70
+
71
+ def compress_write(input)
72
+ file = short_path(input["file_path"])
73
+ content = input["content"]
74
+ size = content ? content.length : 0
75
+
76
+ "Created #{file} (#{size} chars)"
77
+ end
78
+
79
+ def compress_bash(input)
80
+ command = input["command"]
81
+ return "Ran shell command" unless command
82
+
83
+ desc = input["description"]
84
+ cmd = truncate_str(command, 80)
85
+
86
+ if desc
87
+ "Ran: #{desc}"
88
+ else
89
+ "Ran: #{cmd}"
90
+ end
91
+ end
92
+
93
+ def compress_read(input)
94
+ file = short_path(input["file_path"])
95
+ offset = input["offset"]
96
+ limit = input["limit"]
97
+
98
+ parts = ["Read #{file}"]
99
+ parts << "lines #{offset}-#{offset + limit}" if offset && limit
100
+ parts.join(" ")
101
+ end
102
+
103
+ def compress_glob(input)
104
+ pattern = input["pattern"]
105
+ path = input["path"]
106
+
107
+ if path
108
+ "Glob #{pattern} in #{short_path(path)}"
109
+ else
110
+ "Glob #{pattern}"
111
+ end
112
+ end
113
+
114
+ def compress_grep(input)
115
+ pattern = input["pattern"]
116
+ path = input["path"]
117
+
118
+ if path
119
+ "Searched '#{truncate_str(pattern, 40)}' in #{short_path(path)}"
120
+ else
121
+ "Searched '#{truncate_str(pattern, 40)}'"
122
+ end
123
+ end
124
+
125
+ def compress_task(input)
126
+ desc = input["description"]
127
+ agent_type = input["subagent_type"]
128
+
129
+ if desc
130
+ "Spawned #{agent_type || "agent"}: #{truncate_str(desc, 60)}"
131
+ else
132
+ "Spawned #{agent_type || "agent"}"
133
+ end
134
+ end
135
+
136
+ def compress_web_fetch(input)
137
+ url = input["url"]
138
+ "Fetched #{truncate_str(url, 60)}"
139
+ end
140
+
141
+ def compress_web_search(input)
142
+ query = input["query"]
143
+ "Searched web: '#{truncate_str(query, 60)}'"
144
+ end
145
+
146
+ def compress_notebook_edit(input)
147
+ path = short_path(input["notebook_path"])
148
+ mode = input["edit_mode"] || "replace"
149
+ "Notebook #{mode} in #{path}"
150
+ end
151
+
152
+ def compress_generic(tool_name, input)
153
+ keys = input.keys.first(3).join(", ")
154
+ "#{tool_name}(#{keys})"
155
+ end
156
+
157
+ def short_path(path)
158
+ return "unknown" unless path
159
+
160
+ File.basename(path)
161
+ end
162
+
163
+ def truncate_str(str, max_len)
164
+ return nil unless str
165
+
166
+ str = str.gsub(/\s+/, " ").strip
167
+ (str.length > max_len) ? str[0...max_len] + "..." : str
168
+ end
169
+
170
+ def truncate(summary)
171
+ return nil unless summary
172
+
173
+ (summary.length > MAX_SUMMARY_LENGTH) ? summary[0...MAX_SUMMARY_LENGTH] + "..." : summary
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ # Generates dynamic MCP server instructions from database state.
6
+ # Injected into the LLM system prompt via the initialize response,
7
+ # giving Claude immediate context about memory state without extra tool calls.
8
+ #
9
+ # Source: QMD mcp.ts:91-98 (buildInstructions pattern)
10
+ module InstructionsBuilder
11
+ module_function
12
+
13
+ def build(store_or_manager)
14
+ parts = ["ClaudeMemory v#{ClaudeMemory::VERSION} — long-term memory for Claude Code."]
15
+
16
+ if store_or_manager.is_a?(Store::StoreManager)
17
+ parts << database_summary(store_or_manager)
18
+ parts << conflict_summary(store_or_manager)
19
+ elsif store_or_manager.respond_to?(:facts)
20
+ parts << single_db_summary(store_or_manager)
21
+ end
22
+
23
+ parts << usage_hint
24
+ parts.compact.join("\n\n")
25
+ rescue => _e
26
+ # Never fail initialization — return minimal instructions
27
+ "ClaudeMemory v#{ClaudeMemory::VERSION} — long-term memory for Claude Code."
28
+ end
29
+
30
+ def database_summary(manager)
31
+ lines = []
32
+
33
+ if manager.global_exists?
34
+ manager.ensure_global!
35
+ global = manager.global_store
36
+ g_facts = global.facts.where(status: "active").count
37
+ lines << "Global: #{g_facts} active facts"
38
+ end
39
+
40
+ if manager.project_exists?
41
+ manager.ensure_project!
42
+ project = manager.project_store
43
+ p_facts = project.facts.where(status: "active").count
44
+ lines << "Project: #{p_facts} active facts"
45
+ end
46
+
47
+ return nil if lines.empty?
48
+ "Database state: #{lines.join(", ")}."
49
+ end
50
+
51
+ def single_db_summary(store)
52
+ facts = store.facts.where(status: "active").count
53
+ "Database state: #{facts} active facts."
54
+ end
55
+
56
+ def conflict_summary(manager)
57
+ count = 0
58
+
59
+ if manager.global_exists?
60
+ count += manager.global_store.conflicts.where(status: "open").count
61
+ end
62
+
63
+ if manager.project_exists?
64
+ count += manager.project_store.conflicts.where(status: "open").count
65
+ end
66
+
67
+ return nil if count == 0
68
+ "#{count} open conflict#{"s" unless count == 1} — use memory.conflicts to review."
69
+ end
70
+
71
+ def usage_hint
72
+ "Use memory.recall to search facts, memory.decisions for architectural decisions, memory.conventions for coding style."
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "instructions_builder"
4
5
  require_relative "query_guide"
5
6
  require_relative "text_summary"
6
7
 
@@ -85,7 +86,8 @@ module ClaudeMemory
85
86
  serverInfo: {
86
87
  name: "claude-memory",
87
88
  version: ClaudeMemory::VERSION
88
- }
89
+ },
90
+ instructions: InstructionsBuilder.build(@store_or_manager)
89
91
  }
90
92
  }
91
93
  end
@@ -11,21 +11,21 @@ module ClaudeMemory
11
11
  [
12
12
  {
13
13
  name: "memory.recall",
14
- description: "Search facts matching a query from both global and project memory databases.",
14
+ description: "Search facts matching a query from both global and project memory databases. Returns full facts with provenance (~800 tokens/result, ~300 with compact: true). For token-efficient browsing, use memory.recall_index first (~200 tokens/result), then memory.recall_details for selected facts.",
15
15
  inputSchema: {
16
16
  type: "object",
17
17
  properties: {
18
18
  query: {type: "string", description: "Search query for existing knowledge (e.g., 'authentication flow', 'error handling', 'database setup')"},
19
19
  limit: {type: "integer", description: "Max results", default: 10},
20
20
  scope: {type: "string", enum: ["all", "global", "project"], description: "Filter by scope: 'all' (default), 'global', or 'project'", default: "all"},
21
- compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses", default: false}
21
+ compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses (~800 → ~300 tokens/result)", default: false}
22
22
  },
23
23
  required: ["query"]
24
24
  }
25
25
  },
26
26
  {
27
27
  name: "memory.recall_index",
28
- description: "Lightweight search returning fact previews, IDs, and token costs. Follow up with memory.recall_details for full information.",
28
+ description: "Lightweight search returning fact previews, IDs, and token costs (~200 tokens/result). Step 1 of progressive disclosure: browse results here, then call memory.recall_details with selected fact IDs for full information (~500 tokens/fact). Saves ~60% tokens vs memory.recall when you only need a few facts.",
29
29
  inputSchema: {
30
30
  type: "object",
31
31
  properties: {
@@ -38,7 +38,7 @@ module ClaudeMemory
38
38
  },
39
39
  {
40
40
  name: "memory.recall_details",
41
- description: "Fetch full details for specific fact IDs. Use after memory.recall_index.",
41
+ description: "Fetch full details for specific fact IDs (~500 tokens/fact). Step 2 of progressive disclosure: use after memory.recall_index to get provenance and metadata for selected facts only.",
42
42
  inputSchema: {
43
43
  type: "object",
44
44
  properties: {
@@ -234,7 +234,7 @@ module ClaudeMemory
234
234
  },
235
235
  {
236
236
  name: "memory.recall_semantic",
237
- description: "Search facts using semantic similarity (finds conceptually related facts using vector embeddings)",
237
+ description: "Search facts using semantic similarity (finds conceptually related facts using vector embeddings). ~800 tokens/result, ~300 with compact: true.",
238
238
  inputSchema: {
239
239
  type: "object",
240
240
  properties: {
@@ -242,7 +242,7 @@ module ClaudeMemory
242
242
  mode: {type: "string", enum: ["vector", "text", "both"], default: "both", description: "Search mode: vector (embeddings), text (FTS), or both (hybrid)"},
243
243
  limit: {type: "integer", default: 10, description: "Maximum results to return"},
244
244
  scope: {type: "string", enum: ["all", "global", "project"], default: "all", description: "Filter by scope"},
245
- compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses", default: false}
245
+ compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses (~800 → ~300 tokens/result)", default: false}
246
246
  },
247
247
  required: ["query"]
248
248
  }
@@ -262,7 +262,7 @@ module ClaudeMemory
262
262
  },
263
263
  limit: {type: "integer", default: 10, description: "Maximum results to return"},
264
264
  scope: {type: "string", enum: ["all", "global", "project"], default: "all", description: "Filter by scope"},
265
- compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses", default: false}
265
+ compact: {type: "boolean", description: "Omit provenance receipts for ~60% smaller responses (~800 → ~300 tokens/result)", default: false}
266
266
  },
267
267
  required: ["concepts"]
268
268
  }
@@ -287,6 +287,14 @@ module ClaudeMemory
287
287
  type: "object",
288
288
  properties: {}
289
289
  }
290
+ },
291
+ {
292
+ name: "memory.list_projects",
293
+ description: "List all known memory databases with fact counts and status. Shows global database, current project, and other projects discovered from promoted facts. Helps discover available search scopes before querying.",
294
+ inputSchema: {
295
+ type: "object",
296
+ properties: {}
297
+ }
290
298
  }
291
299
  ]
292
300
  end
@@ -68,6 +68,8 @@ module ClaudeMemory
68
68
  fact_graph(arguments)
69
69
  when "memory.check_setup"
70
70
  check_setup
71
+ when "memory.list_projects"
72
+ list_projects
71
73
  else
72
74
  {error: "Unknown tool: #{name}"}
73
75
  end
@@ -507,8 +509,112 @@ module ClaudeMemory
507
509
  }
508
510
  end
509
511
 
512
+ def list_projects
513
+ result = {global: nil, current_project: nil, other_projects: []}
514
+
515
+ if @manager
516
+ result[:global] = list_global_database
517
+ result[:current_project] = list_current_project
518
+ result[:other_projects] = discover_other_projects
519
+ elsif @legacy_store
520
+ result[:global] = {
521
+ exists: true,
522
+ path: @legacy_store.db.opts[:database],
523
+ facts_active: @legacy_store.facts.where(status: "active").count,
524
+ entities: @legacy_store.entities.count
525
+ }
526
+ end
527
+
528
+ result[:project_count] = 1 + result[:other_projects].size
529
+ result
530
+ end
531
+
532
+ def list_global_database
533
+ if @manager.global_exists?
534
+ @manager.ensure_global!
535
+ store = @manager.global_store
536
+ {
537
+ exists: true,
538
+ path: @manager.global_db_path,
539
+ facts_active: store.facts.where(status: "active").count,
540
+ facts_total: store.facts.count,
541
+ entities: store.entities.count
542
+ }
543
+ else
544
+ {exists: false, path: @manager.global_db_path}
545
+ end
546
+ end
547
+
548
+ def list_current_project
549
+ if @manager.project_exists?
550
+ @manager.ensure_project!
551
+ store = @manager.project_store
552
+ {
553
+ exists: true,
554
+ path: @manager.project_path,
555
+ db_path: @manager.project_db_path,
556
+ facts_active: store.facts.where(status: "active").count,
557
+ facts_total: store.facts.count,
558
+ entities: store.entities.count
559
+ }
560
+ else
561
+ {exists: false, path: @manager.project_path, db_path: @manager.project_db_path}
562
+ end
563
+ end
564
+
565
+ def discover_other_projects
566
+ return [] unless @manager.global_exists?
567
+
568
+ @manager.ensure_global!
569
+ global = @manager.global_store
570
+
571
+ # Find project paths from promoted facts
572
+ promoted_paths = global.facts
573
+ .where(Sequel.like(:created_from, "promoted:%"))
574
+ .select(:created_from)
575
+ .distinct
576
+ .all
577
+ .filter_map { |f|
578
+ match = f[:created_from]&.match(/\Apromoted:(.+):\d+\z/)
579
+ match[1] if match
580
+ }
581
+ .uniq
582
+
583
+ # Also check for project_path values on facts
584
+ fact_paths = global.facts
585
+ .exclude(project_path: nil)
586
+ .select(:project_path)
587
+ .distinct
588
+ .all
589
+ .map { |f| f[:project_path] }
590
+
591
+ all_paths = (promoted_paths + fact_paths).uniq
592
+ current = @manager.project_path
593
+
594
+ all_paths.filter_map { |path|
595
+ next if path == current
596
+
597
+ db_path = File.join(path, ".claude", "memory.sqlite3")
598
+ entry = {path: path, db_path: db_path, exists: File.exist?(db_path)}
599
+
600
+ if entry[:exists]
601
+ begin
602
+ temp_store = Store::SQLiteStore.new(db_path)
603
+ entry[:facts_active] = temp_store.facts.where(status: "active").count
604
+ entry[:facts_total] = temp_store.facts.count
605
+ entry[:entities] = temp_store.entities.count
606
+ temp_store.close
607
+ rescue => _e
608
+ entry[:error] = "Could not read database"
609
+ end
610
+ end
611
+
612
+ entry
613
+ }
614
+ end
615
+
510
616
  def db_stats(store)
511
- {
617
+ stats = {
512
618
  exists: true,
513
619
  facts_total: store.facts.count,
514
620
  facts_active: store.facts.where(status: "active").count,
@@ -516,12 +622,18 @@ module ClaudeMemory
516
622
  open_conflicts: store.conflicts.where(status: "open").count,
517
623
  schema_version: store.schema_version
518
624
  }
625
+
626
+ vec_index = store.vector_index
627
+ stats[:vec_available] = vec_index.available?
628
+ stats[:vec_indexed] = vec_index.coverage_stats[:vec_indexed] if vec_index.available?
629
+
630
+ stats
519
631
  end
520
632
 
521
633
  def detailed_stats(store)
522
634
  active_facts = store.facts.where(status: "active").count
523
635
 
524
- {
636
+ stats = {
525
637
  exists: true,
526
638
  facts: fact_stats(store, active_facts),
527
639
  entities: entity_stats(store),
@@ -530,6 +642,10 @@ module ClaudeMemory
530
642
  conflicts: conflict_stats(store),
531
643
  schema_version: store.schema_version
532
644
  }
645
+
646
+ stats[:vec] = vec_stats(store, active_facts)
647
+
648
+ stats
533
649
  end
534
650
 
535
651
  def fact_stats(store, active_facts)
@@ -594,6 +710,13 @@ module ClaudeMemory
594
710
  }
595
711
  end
596
712
 
713
+ def vec_stats(store, _active_facts)
714
+ vec_index = store.vector_index
715
+ result = {available: vec_index.available?}
716
+ result.merge!(vec_index.coverage_stats) if vec_index.available?
717
+ result
718
+ end
719
+
597
720
  def conflict_stats(store)
598
721
  open = store.conflicts.where(status: "open").count
599
722
  resolved = store.conflicts.where(status: "resolved").count
@@ -14,19 +14,8 @@ module ClaudeMemory
14
14
  end
15
15
 
16
16
  def generate_snapshot(since: nil)
17
- facts = fetch_active_facts
18
- conflicts = @store.open_conflicts
19
- recent_supersessions = fetch_recent_supersessions(since)
20
-
21
- sections = []
22
- sections << generate_decisions_section(facts)
23
- sections << generate_conventions_section(facts)
24
- sections << generate_constraints_section(facts)
25
- sections << generate_conflicts_section(conflicts) if conflicts.any?
26
- sections << generate_supersessions_section(recent_supersessions) if recent_supersessions.any?
27
-
28
17
  header = <<~HEADER
29
- <!--
18
+ <!--
30
19
  This file is auto-generated by claude-memory.
31
20
  Do not edit manually - changes will be overwritten.
32
21
  Generated: #{Time.now.utc.iso8601}
@@ -36,14 +25,15 @@ module ClaudeMemory
36
25
 
37
26
  HEADER
38
27
 
39
- header + sections.compact.reject(&:empty?).join("\n")
28
+ header + generate_body(since: since)
40
29
  end
41
30
 
42
31
  def publish!(mode: :shared, granularity: :repo, since: nil, rules_dir: nil)
43
- content = generate_snapshot(since: since)
44
32
  path = output_path(mode, rules_dir: rules_dir)
33
+ body = generate_body(since: since)
45
34
 
46
- if should_write?(path, content)
35
+ if should_write?(path, body)
36
+ content = generate_snapshot(since: since)
47
37
  @fs.write(path, content)
48
38
  ensure_import_exists(mode, path, rules_dir: rules_dir)
49
39
  {status: :updated, path: path}
@@ -163,22 +153,33 @@ module ClaudeMemory
163
153
  lines.join("\n") + "\n"
164
154
  end
165
155
 
166
- def should_write?(path, content)
167
- return true unless @fs.exist?(path)
156
+ def generate_body(since: nil)
157
+ facts = fetch_active_facts
158
+ conflicts = @store.open_conflicts
159
+ recent_supersessions = fetch_recent_supersessions(since)
160
+
161
+ sections = []
162
+ sections << generate_decisions_section(facts)
163
+ sections << generate_conventions_section(facts)
164
+ sections << generate_constraints_section(facts)
165
+ sections << generate_conflicts_section(conflicts) if conflicts.any?
166
+ sections << generate_supersessions_section(recent_supersessions) if recent_supersessions.any?
168
167
 
169
- # Compare content without timestamp to avoid unnecessary rewrites
170
- existing_content = @fs.read(path)
171
- existing_normalized = normalize_for_comparison(existing_content)
172
- new_normalized = normalize_for_comparison(content)
168
+ sections.compact.reject(&:empty?).join("\n")
169
+ end
170
+
171
+ def should_write?(path, new_body)
172
+ return true unless @fs.exist?(path)
173
173
 
174
- existing_hash = Digest::SHA256.hexdigest(existing_normalized)
175
- new_hash = Digest::SHA256.hexdigest(new_normalized)
176
- existing_hash != new_hash
174
+ existing_body = extract_body(@fs.read(path))
175
+ existing_body != new_body
177
176
  end
178
177
 
179
- def normalize_for_comparison(content)
180
- # Remove timestamp line for comparison to prevent churn on timestamp-only changes
181
- content.gsub(/^ Generated: .+$/, "")
178
+ def extract_body(content)
179
+ # Strip the HTML comment header and "# Project Memory" heading
180
+ content
181
+ .sub(/\A<!--.*?-->\s*/m, "")
182
+ .sub(/\A# Project Memory\s*/m, "")
182
183
  end
183
184
 
184
185
  def ensure_import_exists(mode, path, rules_dir: nil)
@@ -44,20 +44,9 @@ module ClaudeMemory
44
44
  end
45
45
 
46
46
  def query_store(source_label, &operation)
47
- store = (source_label == :project) ? @manager.project_store : @manager.global_store
48
- return [] unless store
49
-
50
- ensure_store!(source_label)
47
+ store = @manager.store_for_scope(source_label.to_s)
51
48
  operation.call(store, source_label)
52
49
  end
53
-
54
- def ensure_store!(source_label)
55
- if source_label == :project
56
- @manager.ensure_project!
57
- else
58
- @manager.ensure_global!
59
- end
60
- end
61
50
  end
62
51
  end
63
52
  end