claude_memory 0.1.0 → 0.2.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/.mind.mv2.aLCUZd +0 -0
  3. data/.claude/memory.sqlite3 +0 -0
  4. data/.claude/rules/claude_memory.generated.md +7 -1
  5. data/.claude/settings.json +0 -4
  6. data/.claude/settings.local.json +4 -1
  7. data/.claude-plugin/plugin.json +1 -1
  8. data/.claude.json +11 -0
  9. data/.ruby-version +1 -0
  10. data/CHANGELOG.md +62 -11
  11. data/CLAUDE.md +87 -24
  12. data/README.md +76 -159
  13. data/docs/EXAMPLES.md +436 -0
  14. data/docs/RELEASE_NOTES_v0.2.0.md +179 -0
  15. data/docs/RUBY_COMMUNITY_POST_v0.2.0.md +582 -0
  16. data/docs/SOCIAL_MEDIA_v0.2.0.md +420 -0
  17. data/docs/architecture.md +360 -0
  18. data/docs/expert_review.md +1718 -0
  19. data/docs/feature_adoption_plan.md +1241 -0
  20. data/docs/feature_adoption_plan_revised.md +2374 -0
  21. data/docs/improvements.md +1325 -0
  22. data/docs/quality_review.md +1544 -0
  23. data/docs/review_summary.md +480 -0
  24. data/lefthook.yml +10 -0
  25. data/lib/claude_memory/cli.rb +16 -844
  26. data/lib/claude_memory/commands/base_command.rb +95 -0
  27. data/lib/claude_memory/commands/changes_command.rb +39 -0
  28. data/lib/claude_memory/commands/conflicts_command.rb +37 -0
  29. data/lib/claude_memory/commands/db_init_command.rb +40 -0
  30. data/lib/claude_memory/commands/doctor_command.rb +147 -0
  31. data/lib/claude_memory/commands/explain_command.rb +65 -0
  32. data/lib/claude_memory/commands/help_command.rb +37 -0
  33. data/lib/claude_memory/commands/hook_command.rb +106 -0
  34. data/lib/claude_memory/commands/ingest_command.rb +47 -0
  35. data/lib/claude_memory/commands/init_command.rb +218 -0
  36. data/lib/claude_memory/commands/promote_command.rb +30 -0
  37. data/lib/claude_memory/commands/publish_command.rb +36 -0
  38. data/lib/claude_memory/commands/recall_command.rb +61 -0
  39. data/lib/claude_memory/commands/registry.rb +55 -0
  40. data/lib/claude_memory/commands/search_command.rb +43 -0
  41. data/lib/claude_memory/commands/serve_mcp_command.rb +16 -0
  42. data/lib/claude_memory/commands/sweep_command.rb +36 -0
  43. data/lib/claude_memory/commands/version_command.rb +13 -0
  44. data/lib/claude_memory/configuration.rb +38 -0
  45. data/lib/claude_memory/core/fact_id.rb +41 -0
  46. data/lib/claude_memory/core/null_explanation.rb +47 -0
  47. data/lib/claude_memory/core/null_fact.rb +30 -0
  48. data/lib/claude_memory/core/result.rb +143 -0
  49. data/lib/claude_memory/core/session_id.rb +37 -0
  50. data/lib/claude_memory/core/token_estimator.rb +33 -0
  51. data/lib/claude_memory/core/transcript_path.rb +37 -0
  52. data/lib/claude_memory/domain/conflict.rb +51 -0
  53. data/lib/claude_memory/domain/entity.rb +51 -0
  54. data/lib/claude_memory/domain/fact.rb +70 -0
  55. data/lib/claude_memory/domain/provenance.rb +48 -0
  56. data/lib/claude_memory/hook/exit_codes.rb +18 -0
  57. data/lib/claude_memory/hook/handler.rb +7 -2
  58. data/lib/claude_memory/index/index_query.rb +89 -0
  59. data/lib/claude_memory/index/index_query_logic.rb +41 -0
  60. data/lib/claude_memory/index/query_options.rb +67 -0
  61. data/lib/claude_memory/infrastructure/file_system.rb +29 -0
  62. data/lib/claude_memory/infrastructure/in_memory_file_system.rb +32 -0
  63. data/lib/claude_memory/ingest/content_sanitizer.rb +42 -0
  64. data/lib/claude_memory/ingest/ingester.rb +3 -0
  65. data/lib/claude_memory/ingest/privacy_tag.rb +48 -0
  66. data/lib/claude_memory/mcp/tools.rb +174 -1
  67. data/lib/claude_memory/publish.rb +29 -20
  68. data/lib/claude_memory/recall.rb +164 -16
  69. data/lib/claude_memory/resolve/resolver.rb +41 -37
  70. data/lib/claude_memory/shortcuts.rb +56 -0
  71. data/lib/claude_memory/store/store_manager.rb +35 -32
  72. data/lib/claude_memory/templates/hooks.example.json +0 -4
  73. data/lib/claude_memory/version.rb +1 -1
  74. data/lib/claude_memory.rb +59 -21
  75. metadata +55 -1
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Index
5
+ class QueryOptions
6
+ SCOPE_ALL = :all
7
+ SCOPE_PROJECT = :project
8
+ SCOPE_GLOBAL = :global
9
+
10
+ DEFAULT_LIMIT = 20
11
+ DEFAULT_SCOPE = SCOPE_ALL
12
+
13
+ attr_reader :query_text, :limit, :scope, :source
14
+
15
+ def initialize(query_text:, limit: DEFAULT_LIMIT, scope: DEFAULT_SCOPE, source: nil)
16
+ @query_text = query_text
17
+ @limit = limit
18
+ @scope = scope.to_sym
19
+ @source = source&.to_sym
20
+ freeze
21
+ end
22
+
23
+ def for_project
24
+ self.class.new(
25
+ query_text: query_text,
26
+ limit: limit,
27
+ scope: scope,
28
+ source: :project
29
+ )
30
+ end
31
+
32
+ def for_global
33
+ self.class.new(
34
+ query_text: query_text,
35
+ limit: limit,
36
+ scope: scope,
37
+ source: :global
38
+ )
39
+ end
40
+
41
+ def with_limit(new_limit)
42
+ self.class.new(
43
+ query_text: query_text,
44
+ limit: new_limit,
45
+ scope: scope,
46
+ source: source
47
+ )
48
+ end
49
+
50
+ def ==(other)
51
+ other.is_a?(QueryOptions) &&
52
+ other.query_text == query_text &&
53
+ other.limit == limit &&
54
+ other.scope == scope &&
55
+ other.source == source
56
+ end
57
+
58
+ def eql?(other)
59
+ self == other
60
+ end
61
+
62
+ def hash
63
+ [query_text, limit, scope, source].hash
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest/sha2"
5
+
6
+ module ClaudeMemory
7
+ module Infrastructure
8
+ # Real filesystem implementation
9
+ # Wraps File and FileUtils for dependency injection
10
+ class FileSystem
11
+ def exist?(path)
12
+ File.exist?(path)
13
+ end
14
+
15
+ def read(path)
16
+ File.read(path)
17
+ end
18
+
19
+ def write(path, content)
20
+ FileUtils.mkdir_p(File.dirname(path))
21
+ File.write(path, content)
22
+ end
23
+
24
+ def file_hash(path)
25
+ Digest::SHA256.file(path).hexdigest
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+
5
+ module ClaudeMemory
6
+ module Infrastructure
7
+ # In-memory filesystem implementation for fast testing
8
+ # Does not touch the real filesystem
9
+ class InMemoryFileSystem
10
+ def initialize
11
+ @files = {}
12
+ end
13
+
14
+ def exist?(path)
15
+ @files.key?(path)
16
+ end
17
+
18
+ def read(path)
19
+ @files.fetch(path) { raise Errno::ENOENT, path }
20
+ end
21
+
22
+ def write(path, content)
23
+ @files[path] = content
24
+ end
25
+
26
+ def file_hash(path)
27
+ content = read(path)
28
+ Digest::SHA256.hexdigest(content)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Ingest
5
+ class ContentSanitizer
6
+ SYSTEM_TAGS = ["claude-memory-context"].freeze
7
+ USER_TAGS = ["private", "no-memory", "secret"].freeze
8
+ MAX_TAG_COUNT = 100
9
+
10
+ def self.strip_tags(text)
11
+ tags = Pure.all_tags
12
+ validate_tag_count!(text, tags)
13
+ Pure.strip_tags(text, tags)
14
+ end
15
+
16
+ def self.validate_tag_count!(text, tags)
17
+ count = Pure.count_tags(text, tags)
18
+ raise Error, "Too many privacy tags (#{count}), possible ReDoS attack" if count > MAX_TAG_COUNT
19
+ end
20
+
21
+ module Pure
22
+ def self.all_tags
23
+ @all_tags ||= begin
24
+ all_tag_names = ContentSanitizer::SYSTEM_TAGS + ContentSanitizer::USER_TAGS
25
+ all_tag_names.map { |name| PrivacyTag.new(name) }
26
+ end
27
+ end
28
+
29
+ def self.strip_tags(text, tags)
30
+ tags.reduce(text) { |result, tag| tag.strip_from(result) }
31
+ end
32
+
33
+ def self.count_tags(text, tags)
34
+ tags.sum do |tag|
35
+ opening_pattern = /<#{Regexp.escape(tag.name)}>/
36
+ text.scan(opening_pattern).size
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -17,6 +17,9 @@ module ClaudeMemory
17
17
 
18
18
  return {status: :no_change, bytes_read: 0} if delta.nil?
19
19
 
20
+ # Strip privacy tags before storing
21
+ delta = ContentSanitizer.strip_tags(delta)
22
+
20
23
  resolved_project = project_path || detect_project_path
21
24
 
22
25
  text_hash = Digest::SHA256.hexdigest(delta)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Ingest
5
+ class PrivacyTag
6
+ attr_reader :name
7
+
8
+ def initialize(name)
9
+ @name = name.to_s.strip
10
+ validate!
11
+ freeze
12
+ end
13
+
14
+ def pattern
15
+ /<#{Regexp.escape(@name)}>.*?<\/#{Regexp.escape(@name)}>/m
16
+ end
17
+
18
+ def strip_from(text)
19
+ # Strip repeatedly to handle nested tags
20
+ result = text
21
+ loop do
22
+ new_result = result.gsub(pattern, "")
23
+ break if new_result == result
24
+ result = new_result
25
+ end
26
+ result
27
+ end
28
+
29
+ def ==(other)
30
+ other.is_a?(PrivacyTag) && other.name == name
31
+ end
32
+
33
+ def eql?(other)
34
+ self == other
35
+ end
36
+
37
+ def hash
38
+ name.hash
39
+ end
40
+
41
+ private
42
+
43
+ def validate!
44
+ raise Error, "Tag name cannot be empty" if @name.empty?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -31,6 +31,31 @@ module ClaudeMemory
31
31
  required: ["query"]
32
32
  }
33
33
  },
34
+ {
35
+ name: "memory.recall_index",
36
+ description: "Layer 1: Search for facts and get lightweight index (IDs, previews, token counts). Use this first before fetching full details.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ query: {type: "string", description: "Search query for fact discovery"},
41
+ limit: {type: "integer", description: "Maximum results to return", default: 20},
42
+ scope: {type: "string", enum: ["all", "global", "project"], description: "Scope: 'all' (both), 'global' (user-wide), 'project' (current only)", default: "all"}
43
+ },
44
+ required: ["query"]
45
+ }
46
+ },
47
+ {
48
+ name: "memory.recall_details",
49
+ description: "Layer 2: Fetch full details for specific fact IDs from the index. Use after memory.recall_index to get complete information.",
50
+ inputSchema: {
51
+ type: "object",
52
+ properties: {
53
+ fact_ids: {type: "array", items: {type: "integer"}, description: "Fact IDs from memory.recall_index"},
54
+ scope: {type: "string", enum: ["project", "global"], description: "Database to query", default: "project"}
55
+ },
56
+ required: ["fact_ids"]
57
+ }
58
+ },
34
59
  {
35
60
  name: "memory.explain",
36
61
  description: "Get detailed explanation of a fact with provenance",
@@ -148,6 +173,36 @@ module ClaudeMemory
148
173
  },
149
174
  required: ["facts"]
150
175
  }
176
+ },
177
+ {
178
+ name: "memory.decisions",
179
+ description: "Quick access to architectural decisions, constraints, and rules",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ limit: {type: "integer", default: 10, description: "Maximum results to return"}
184
+ }
185
+ }
186
+ },
187
+ {
188
+ name: "memory.conventions",
189
+ description: "Quick access to coding conventions and style preferences (global scope)",
190
+ inputSchema: {
191
+ type: "object",
192
+ properties: {
193
+ limit: {type: "integer", default: 20, description: "Maximum results to return"}
194
+ }
195
+ }
196
+ },
197
+ {
198
+ name: "memory.architecture",
199
+ description: "Quick access to framework choices and architectural patterns",
200
+ inputSchema: {
201
+ type: "object",
202
+ properties: {
203
+ limit: {type: "integer", default: 10, description: "Maximum results to return"}
204
+ }
205
+ }
151
206
  }
152
207
  ]
153
208
  end
@@ -156,6 +211,10 @@ module ClaudeMemory
156
211
  case name
157
212
  when "memory.recall"
158
213
  recall(arguments)
214
+ when "memory.recall_index"
215
+ recall_index(arguments)
216
+ when "memory.recall_details"
217
+ recall_details(arguments)
159
218
  when "memory.explain"
160
219
  explain(arguments)
161
220
  when "memory.changes"
@@ -170,6 +229,12 @@ module ClaudeMemory
170
229
  promote(arguments)
171
230
  when "memory.store_extraction"
172
231
  store_extraction(arguments)
232
+ when "memory.decisions"
233
+ decisions(arguments)
234
+ when "memory.conventions"
235
+ conventions(arguments)
236
+ when "memory.architecture"
237
+ architecture(arguments)
173
238
  else
174
239
  {error: "Unknown tool: #{name}"}
175
240
  end
@@ -195,10 +260,80 @@ module ClaudeMemory
195
260
  }
196
261
  end
197
262
 
263
+ def recall_index(args)
264
+ scope = args["scope"] || "all"
265
+ results = @recall.query_index(args["query"], limit: args["limit"] || 20, scope: scope)
266
+
267
+ total_tokens = results.sum { |r| r[:token_estimate] }
268
+
269
+ {
270
+ query: args["query"],
271
+ scope: scope,
272
+ result_count: results.size,
273
+ total_estimated_tokens: total_tokens,
274
+ facts: results.map do |r|
275
+ {
276
+ id: r[:id],
277
+ subject: r[:subject],
278
+ predicate: r[:predicate],
279
+ object_preview: r[:object_preview],
280
+ status: r[:status],
281
+ scope: r[:scope],
282
+ confidence: r[:confidence],
283
+ tokens: r[:token_estimate],
284
+ source: r[:source]
285
+ }
286
+ end
287
+ }
288
+ end
289
+
290
+ def recall_details(args)
291
+ fact_ids = args["fact_ids"]
292
+ scope = args["scope"] || "project"
293
+
294
+ # Batch fetch detailed explanations
295
+ explanations = fact_ids.map do |fact_id|
296
+ explanation = @recall.explain(fact_id, scope: scope)
297
+ next nil if explanation.is_a?(Core::NullExplanation)
298
+
299
+ {
300
+ fact: {
301
+ id: explanation[:fact][:id],
302
+ subject: explanation[:fact][:subject_name],
303
+ predicate: explanation[:fact][:predicate],
304
+ object: explanation[:fact][:object_literal],
305
+ status: explanation[:fact][:status],
306
+ confidence: explanation[:fact][:confidence],
307
+ scope: explanation[:fact][:scope],
308
+ valid_from: explanation[:fact][:valid_from],
309
+ valid_to: explanation[:fact][:valid_to]
310
+ },
311
+ receipts: explanation[:receipts].map { |r|
312
+ {
313
+ quote: r[:quote],
314
+ strength: r[:strength],
315
+ session_id: r[:session_id],
316
+ occurred_at: r[:occurred_at]
317
+ }
318
+ },
319
+ relationships: {
320
+ supersedes: explanation[:supersedes],
321
+ superseded_by: explanation[:superseded_by],
322
+ conflicts: explanation[:conflicts].map { |c| {id: c[:id], status: c[:status]} }
323
+ }
324
+ }
325
+ end.compact
326
+
327
+ {
328
+ fact_count: explanations.size,
329
+ facts: explanations
330
+ }
331
+ end
332
+
198
333
  def explain(args)
199
334
  scope = args["scope"] || "project"
200
335
  explanation = @recall.explain(args["fact_id"], scope: scope)
201
- return {error: "Fact not found in #{scope} database"} unless explanation
336
+ return {error: "Fact not found in #{scope} database"} if explanation.is_a?(Core::NullExplanation)
202
337
 
203
338
  {
204
339
  fact: {
@@ -394,6 +529,44 @@ module ClaudeMemory
394
529
  end
395
530
  end
396
531
 
532
+ def decisions(args)
533
+ return {error: "Decisions shortcut requires StoreManager"} unless @manager
534
+
535
+ results = Recall.recent_decisions(@manager, limit: args["limit"] || 10)
536
+ format_shortcut_results(results, "decisions")
537
+ end
538
+
539
+ def conventions(args)
540
+ return {error: "Conventions shortcut requires StoreManager"} unless @manager
541
+
542
+ results = Recall.conventions(@manager, limit: args["limit"] || 20)
543
+ format_shortcut_results(results, "conventions")
544
+ end
545
+
546
+ def architecture(args)
547
+ return {error: "Architecture shortcut requires StoreManager"} unless @manager
548
+
549
+ results = Recall.architecture_choices(@manager, limit: args["limit"] || 10)
550
+ format_shortcut_results(results, "architecture")
551
+ end
552
+
553
+ def format_shortcut_results(results, category)
554
+ {
555
+ category: category,
556
+ count: results.size,
557
+ facts: results.map do |r|
558
+ {
559
+ id: r[:fact][:id],
560
+ subject: r[:fact][:subject_name],
561
+ predicate: r[:fact][:predicate],
562
+ object: r[:fact][:object_literal],
563
+ scope: r[:fact][:scope],
564
+ source: r[:source]
565
+ }
566
+ end
567
+ }
568
+ end
569
+
397
570
  def db_stats(store)
398
571
  {
399
572
  exists: true,
@@ -8,8 +8,9 @@ module ClaudeMemory
8
8
  RULES_DIR = ".claude/rules"
9
9
  GENERATED_FILE = "claude_memory.generated.md"
10
10
 
11
- def initialize(store)
11
+ def initialize(store, file_system: Infrastructure::FileSystem.new)
12
12
  @store = store
13
+ @fs = file_system
13
14
  end
14
15
 
15
16
  def generate_snapshot(since: nil)
@@ -38,15 +39,13 @@ module ClaudeMemory
38
39
  header + sections.compact.reject(&:empty?).join("\n")
39
40
  end
40
41
 
41
- def publish!(mode: :shared, granularity: :repo, since: nil)
42
+ def publish!(mode: :shared, granularity: :repo, since: nil, rules_dir: nil)
42
43
  content = generate_snapshot(since: since)
43
- path = output_path(mode)
44
-
45
- FileUtils.mkdir_p(File.dirname(path))
44
+ path = output_path(mode, rules_dir: rules_dir)
46
45
 
47
46
  if should_write?(path, content)
48
- File.write(path, content)
49
- ensure_import_exists(mode, path)
47
+ @fs.write(path, content)
48
+ ensure_import_exists(mode, path, rules_dir: rules_dir)
50
49
  {status: :updated, path: path}
51
50
  else
52
51
  {status: :unchanged, path: path}
@@ -55,17 +54,19 @@ module ClaudeMemory
55
54
 
56
55
  private
57
56
 
58
- def output_path(mode)
57
+ def output_path(mode, rules_dir: nil)
59
58
  case mode
60
59
  when :shared
61
- File.join(RULES_DIR, GENERATED_FILE)
60
+ dir = rules_dir || RULES_DIR
61
+ File.join(dir, GENERATED_FILE)
62
62
  when :local
63
63
  ".claude_memory.local.md"
64
64
  when :home
65
65
  project_name = File.basename(Dir.pwd)
66
66
  File.join(Dir.home, ".claude", "claude_memory", "#{project_name}.md")
67
67
  else
68
- File.join(RULES_DIR, GENERATED_FILE)
68
+ dir = rules_dir || RULES_DIR
69
+ File.join(dir, GENERATED_FILE)
69
70
  end
70
71
  end
71
72
 
@@ -163,34 +164,42 @@ module ClaudeMemory
163
164
  end
164
165
 
165
166
  def should_write?(path, content)
166
- return true unless File.exist?(path)
167
+ return true unless @fs.exist?(path)
167
168
 
168
- existing_hash = Digest::SHA256.file(path).hexdigest
169
+ existing_hash = @fs.file_hash(path)
169
170
  new_hash = Digest::SHA256.hexdigest(content)
170
171
  existing_hash != new_hash
171
172
  end
172
173
 
173
- def ensure_import_exists(mode, path)
174
+ def ensure_import_exists(mode, path, rules_dir: nil)
174
175
  return if mode == :local
175
176
 
176
- claude_md = ".claude/CLAUDE.md"
177
+ # Determine CLAUDE.md location based on rules_dir
178
+ claude_md = if rules_dir
179
+ # If rules_dir is provided (e.g., /tmp/xyz/.claude/rules),
180
+ # CLAUDE.md should be in parent dir (e.g., /tmp/xyz/.claude/CLAUDE.md)
181
+ File.join(File.dirname(rules_dir), "CLAUDE.md")
182
+ else
183
+ ".claude/CLAUDE.md"
184
+ end
185
+
177
186
  import_line = case mode
178
187
  when :shared
179
- "@#{RULES_DIR}/#{GENERATED_FILE}"
188
+ dir = rules_dir || RULES_DIR
189
+ "@#{dir}/#{GENERATED_FILE}"
180
190
  when :home
181
191
  "@~/#{path.sub(Dir.home + "/", "")}"
182
192
  else
183
193
  "@#{path}"
184
194
  end
185
195
 
186
- if File.exist?(claude_md)
187
- content = File.read(claude_md)
196
+ if @fs.exist?(claude_md)
197
+ content = @fs.read(claude_md)
188
198
  return if content.include?(import_line)
189
199
 
190
- File.write(claude_md, content + "\n#{import_line}\n")
200
+ @fs.write(claude_md, content + "\n#{import_line}\n")
191
201
  else
192
- FileUtils.mkdir_p(".claude")
193
- File.write(claude_md, "# Project Memory\n\n#{import_line}\n")
202
+ @fs.write(claude_md, "# Project Memory\n\n#{import_line}\n")
194
203
  end
195
204
  end
196
205