claude_memory 0.1.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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/CLAUDE.md +3 -0
  3. data/.claude/memory.sqlite3 +0 -0
  4. data/.claude/output-styles/memory-aware.md +21 -0
  5. data/.claude/rules/claude_memory.generated.md +21 -0
  6. data/.claude/settings.json +62 -0
  7. data/.claude/settings.local.json +21 -0
  8. data/.claude-plugin/marketplace.json +13 -0
  9. data/.claude-plugin/plugin.json +10 -0
  10. data/.mcp.json +11 -0
  11. data/CHANGELOG.md +36 -0
  12. data/CLAUDE.md +224 -0
  13. data/CODE_OF_CONDUCT.md +10 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +212 -0
  16. data/Rakefile +10 -0
  17. data/commands/analyze.md +29 -0
  18. data/commands/recall.md +17 -0
  19. data/commands/remember.md +26 -0
  20. data/docs/demo.md +126 -0
  21. data/docs/organizational_memory_playbook.md +291 -0
  22. data/docs/plan.md +411 -0
  23. data/docs/plugin.md +202 -0
  24. data/docs/updated_plan.md +453 -0
  25. data/exe/claude-memory +8 -0
  26. data/hooks/hooks.json +59 -0
  27. data/lib/claude_memory/cli.rb +869 -0
  28. data/lib/claude_memory/distill/distiller.rb +11 -0
  29. data/lib/claude_memory/distill/extraction.rb +29 -0
  30. data/lib/claude_memory/distill/json_schema.md +78 -0
  31. data/lib/claude_memory/distill/null_distiller.rb +123 -0
  32. data/lib/claude_memory/hook/handler.rb +49 -0
  33. data/lib/claude_memory/index/lexical_fts.rb +58 -0
  34. data/lib/claude_memory/ingest/ingester.rb +46 -0
  35. data/lib/claude_memory/ingest/transcript_reader.rb +21 -0
  36. data/lib/claude_memory/mcp/server.rb +127 -0
  37. data/lib/claude_memory/mcp/tools.rb +409 -0
  38. data/lib/claude_memory/publish.rb +201 -0
  39. data/lib/claude_memory/recall.rb +360 -0
  40. data/lib/claude_memory/resolve/predicate_policy.rb +30 -0
  41. data/lib/claude_memory/resolve/resolver.rb +152 -0
  42. data/lib/claude_memory/store/sqlite_store.rb +340 -0
  43. data/lib/claude_memory/store/store_manager.rb +139 -0
  44. data/lib/claude_memory/sweep/sweeper.rb +80 -0
  45. data/lib/claude_memory/templates/hooks.example.json +74 -0
  46. data/lib/claude_memory/templates/output-styles/memory-aware.md +21 -0
  47. data/lib/claude_memory/version.rb +5 -0
  48. data/lib/claude_memory.rb +36 -0
  49. data/sig/claude_memory.rbs +4 -0
  50. data/skills/analyze/SKILL.md +126 -0
  51. data/skills/memory/SKILL.md +82 -0
  52. metadata +123 -0
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ class Recall
5
+ SCOPE_PROJECT = "project"
6
+ SCOPE_GLOBAL = "global"
7
+ SCOPE_ALL = "all"
8
+
9
+ def initialize(store_or_manager, fts: nil, project_path: nil, env: ENV)
10
+ @project_path = project_path || env["CLAUDE_PROJECT_DIR"] || Dir.pwd
11
+
12
+ if store_or_manager.is_a?(Store::StoreManager)
13
+ @manager = store_or_manager
14
+ @legacy_mode = false
15
+ else
16
+ @legacy_store = store_or_manager
17
+ @legacy_fts = fts || Index::LexicalFTS.new(store_or_manager)
18
+ @legacy_mode = true
19
+ end
20
+ end
21
+
22
+ def query(query_text, limit: 10, scope: SCOPE_ALL)
23
+ if @legacy_mode
24
+ query_legacy(query_text, limit: limit, scope: scope)
25
+ else
26
+ query_dual(query_text, limit: limit, scope: scope)
27
+ end
28
+ end
29
+
30
+ def explain(fact_id, scope: nil)
31
+ if @legacy_mode
32
+ explain_from_store(@legacy_store, fact_id)
33
+ else
34
+ scope ||= SCOPE_PROJECT
35
+ store = @manager.store_for_scope(scope)
36
+ explain_from_store(store, fact_id)
37
+ end
38
+ end
39
+
40
+ def changes(since:, limit: 50, scope: SCOPE_ALL)
41
+ if @legacy_mode
42
+ changes_legacy(since: since, limit: limit, scope: scope)
43
+ else
44
+ changes_dual(since: since, limit: limit, scope: scope)
45
+ end
46
+ end
47
+
48
+ def conflicts(scope: SCOPE_ALL)
49
+ if @legacy_mode
50
+ conflicts_legacy(scope: scope)
51
+ else
52
+ conflicts_dual(scope: scope)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def query_dual(query_text, limit:, scope:)
59
+ results = []
60
+
61
+ if scope == SCOPE_ALL || scope == SCOPE_PROJECT
62
+ @manager.ensure_project! if @manager.project_exists?
63
+ if @manager.project_store
64
+ project_results = query_single_store(@manager.project_store, query_text, limit: limit, source: :project)
65
+ results.concat(project_results)
66
+ end
67
+ end
68
+
69
+ if scope == SCOPE_ALL || scope == SCOPE_GLOBAL
70
+ @manager.ensure_global! if @manager.global_exists?
71
+ if @manager.global_store
72
+ global_results = query_single_store(@manager.global_store, query_text, limit: limit, source: :global)
73
+ results.concat(global_results)
74
+ end
75
+ end
76
+
77
+ dedupe_and_sort(results, limit)
78
+ end
79
+
80
+ def query_single_store(store, query_text, limit:, source:)
81
+ fts = Index::LexicalFTS.new(store)
82
+ content_ids = fts.search(query_text, limit: limit * 3)
83
+ return [] if content_ids.empty?
84
+
85
+ facts_with_provenance = []
86
+ seen_fact_ids = Set.new
87
+
88
+ content_ids.each do |content_id|
89
+ provenance_records = store.provenance
90
+ .select(:id, :fact_id, :content_item_id, :quote, :strength)
91
+ .where(content_item_id: content_id)
92
+ .all
93
+
94
+ provenance_records.each do |prov|
95
+ next if seen_fact_ids.include?(prov[:fact_id])
96
+
97
+ fact = find_fact_from_store(store, prov[:fact_id])
98
+ next unless fact
99
+
100
+ seen_fact_ids.add(prov[:fact_id])
101
+ facts_with_provenance << {
102
+ fact: fact,
103
+ receipts: find_receipts_from_store(store, prov[:fact_id]),
104
+ source: source
105
+ }
106
+ break if facts_with_provenance.size >= limit
107
+ end
108
+ break if facts_with_provenance.size >= limit
109
+ end
110
+
111
+ facts_with_provenance
112
+ end
113
+
114
+ def dedupe_and_sort(results, limit)
115
+ seen_signatures = Set.new
116
+ unique_results = []
117
+
118
+ results.each do |result|
119
+ fact = result[:fact]
120
+ sig = "#{fact[:subject_name]}:#{fact[:predicate]}:#{fact[:object_literal]}"
121
+ next if seen_signatures.include?(sig)
122
+
123
+ seen_signatures.add(sig)
124
+ unique_results << result
125
+ end
126
+
127
+ unique_results.sort_by do |item|
128
+ source_priority = (item[:source] == :project) ? 0 : 1
129
+ [source_priority, item[:fact][:created_at]]
130
+ end.first(limit)
131
+ end
132
+
133
+ def changes_dual(since:, limit:, scope:)
134
+ results = []
135
+
136
+ if scope == SCOPE_ALL || scope == SCOPE_PROJECT
137
+ @manager.ensure_project! if @manager.project_exists?
138
+ if @manager.project_store
139
+ project_changes = fetch_changes(@manager.project_store, since, limit)
140
+ project_changes.each { |c| c[:source] = :project }
141
+ results.concat(project_changes)
142
+ end
143
+ end
144
+
145
+ if scope == SCOPE_ALL || scope == SCOPE_GLOBAL
146
+ @manager.ensure_global! if @manager.global_exists?
147
+ if @manager.global_store
148
+ global_changes = fetch_changes(@manager.global_store, since, limit)
149
+ global_changes.each { |c| c[:source] = :global }
150
+ results.concat(global_changes)
151
+ end
152
+ end
153
+
154
+ results.sort_by { |c| c[:created_at] }.reverse.first(limit)
155
+ end
156
+
157
+ def fetch_changes(store, since, limit)
158
+ store.facts
159
+ .select(:id, :subject_entity_id, :predicate, :object_literal, :status, :created_at, :scope, :project_path)
160
+ .where { created_at >= since }
161
+ .order(Sequel.desc(:created_at))
162
+ .limit(limit)
163
+ .all
164
+ end
165
+
166
+ def conflicts_dual(scope:)
167
+ results = []
168
+
169
+ if scope == SCOPE_ALL || scope == SCOPE_PROJECT
170
+ @manager.ensure_project! if @manager.project_exists?
171
+ if @manager.project_store
172
+ project_conflicts = @manager.project_store.open_conflicts
173
+ project_conflicts.each { |c| c[:source] = :project }
174
+ results.concat(project_conflicts)
175
+ end
176
+ end
177
+
178
+ if scope == SCOPE_ALL || scope == SCOPE_GLOBAL
179
+ @manager.ensure_global! if @manager.global_exists?
180
+ if @manager.global_store
181
+ global_conflicts = @manager.global_store.open_conflicts
182
+ global_conflicts.each { |c| c[:source] = :global }
183
+ results.concat(global_conflicts)
184
+ end
185
+ end
186
+
187
+ results
188
+ end
189
+
190
+ def explain_from_store(store, fact_id)
191
+ fact = find_fact_from_store(store, fact_id)
192
+ return nil unless fact
193
+
194
+ {
195
+ fact: fact,
196
+ receipts: find_receipts_from_store(store, fact_id),
197
+ superseded_by: find_superseded_by_from_store(store, fact_id),
198
+ supersedes: find_supersedes_from_store(store, fact_id),
199
+ conflicts: find_conflicts_from_store(store, fact_id)
200
+ }
201
+ end
202
+
203
+ def find_fact_from_store(store, fact_id)
204
+ store.facts
205
+ .left_join(:entities, id: :subject_entity_id)
206
+ .select(
207
+ Sequel[:facts][:id],
208
+ Sequel[:facts][:predicate],
209
+ Sequel[:facts][:object_literal],
210
+ Sequel[:facts][:status],
211
+ Sequel[:facts][:confidence],
212
+ Sequel[:facts][:valid_from],
213
+ Sequel[:facts][:valid_to],
214
+ Sequel[:facts][:created_at],
215
+ Sequel[:entities][:canonical_name].as(:subject_name),
216
+ Sequel[:facts][:scope],
217
+ Sequel[:facts][:project_path]
218
+ )
219
+ .where(Sequel[:facts][:id] => fact_id)
220
+ .first
221
+ end
222
+
223
+ def find_receipts_from_store(store, fact_id)
224
+ store.provenance
225
+ .left_join(:content_items, id: :content_item_id)
226
+ .select(
227
+ Sequel[:provenance][:id],
228
+ Sequel[:provenance][:quote],
229
+ Sequel[:provenance][:strength],
230
+ Sequel[:content_items][:session_id],
231
+ Sequel[:content_items][:occurred_at]
232
+ )
233
+ .where(Sequel[:provenance][:fact_id] => fact_id)
234
+ .all
235
+ end
236
+
237
+ def find_superseded_by_from_store(store, fact_id)
238
+ store.fact_links
239
+ .where(to_fact_id: fact_id, link_type: "supersedes")
240
+ .select_map(:from_fact_id)
241
+ end
242
+
243
+ def find_supersedes_from_store(store, fact_id)
244
+ store.fact_links
245
+ .where(from_fact_id: fact_id, link_type: "supersedes")
246
+ .select_map(:to_fact_id)
247
+ end
248
+
249
+ def find_conflicts_from_store(store, fact_id)
250
+ store.conflicts
251
+ .select(:id, :fact_a_id, :fact_b_id, :status)
252
+ .where(Sequel.or(fact_a_id: fact_id, fact_b_id: fact_id))
253
+ .all
254
+ end
255
+
256
+ def query_legacy(query_text, limit:, scope:)
257
+ content_ids = @legacy_fts.search(query_text, limit: limit * 3)
258
+ return [] if content_ids.empty?
259
+
260
+ facts_with_provenance = []
261
+ seen_fact_ids = Set.new
262
+
263
+ content_ids.each do |content_id|
264
+ provenance_records = find_provenance_by_content(content_id)
265
+ provenance_records.each do |prov|
266
+ next if seen_fact_ids.include?(prov[:fact_id])
267
+
268
+ fact = find_fact(prov[:fact_id])
269
+ next unless fact
270
+ next unless fact_matches_scope?(fact, scope)
271
+
272
+ seen_fact_ids.add(prov[:fact_id])
273
+ facts_with_provenance << {
274
+ fact: fact,
275
+ receipts: find_receipts(prov[:fact_id])
276
+ }
277
+ break if facts_with_provenance.size >= limit
278
+ end
279
+ break if facts_with_provenance.size >= limit
280
+ end
281
+
282
+ sort_by_scope_priority(facts_with_provenance)
283
+ end
284
+
285
+ def changes_legacy(since:, limit:, scope:)
286
+ ds = @legacy_store.facts
287
+ .select(:id, :subject_entity_id, :predicate, :object_literal, :status, :created_at, :scope, :project_path)
288
+ .where { created_at >= since }
289
+ .order(Sequel.desc(:created_at))
290
+ .limit(limit)
291
+
292
+ ds = apply_scope_filter(ds, scope)
293
+ ds.all
294
+ end
295
+
296
+ def conflicts_legacy(scope:)
297
+ all_conflicts = @legacy_store.open_conflicts
298
+ return all_conflicts if scope == SCOPE_ALL
299
+
300
+ all_conflicts.select do |conflict|
301
+ fact_a = find_fact(conflict[:fact_a_id])
302
+ fact_b = find_fact(conflict[:fact_b_id])
303
+
304
+ fact_matches_scope?(fact_a, scope) || fact_matches_scope?(fact_b, scope)
305
+ end
306
+ end
307
+
308
+ def fact_matches_scope?(fact, scope)
309
+ return true if scope == SCOPE_ALL
310
+
311
+ fact_scope = fact[:scope] || "project"
312
+ fact_project = fact[:project_path]
313
+
314
+ case scope
315
+ when SCOPE_PROJECT
316
+ fact_scope == "project" && fact_project == @project_path
317
+ when SCOPE_GLOBAL
318
+ fact_scope == "global"
319
+ else
320
+ true
321
+ end
322
+ end
323
+
324
+ def apply_scope_filter(dataset, scope)
325
+ case scope
326
+ when SCOPE_PROJECT
327
+ dataset.where(scope: "project", project_path: @project_path)
328
+ when SCOPE_GLOBAL
329
+ dataset.where(scope: "global")
330
+ else
331
+ dataset
332
+ end
333
+ end
334
+
335
+ def sort_by_scope_priority(facts_with_provenance)
336
+ facts_with_provenance.sort_by do |item|
337
+ fact = item[:fact]
338
+ is_current_project = fact[:project_path] == @project_path
339
+ is_global = fact[:scope] == "global"
340
+
341
+ [is_current_project ? 0 : 1, is_global ? 0 : 1]
342
+ end
343
+ end
344
+
345
+ def find_provenance_by_content(content_id)
346
+ @legacy_store.provenance
347
+ .select(:id, :fact_id, :content_item_id, :quote, :strength)
348
+ .where(content_item_id: content_id)
349
+ .all
350
+ end
351
+
352
+ def find_fact(fact_id)
353
+ find_fact_from_store(@legacy_store, fact_id)
354
+ end
355
+
356
+ def find_receipts(fact_id)
357
+ find_receipts_from_store(@legacy_store, fact_id)
358
+ end
359
+ end
360
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Resolve
5
+ class PredicatePolicy
6
+ POLICIES = {
7
+ "convention" => {cardinality: :multi, exclusive: false},
8
+ "decision" => {cardinality: :multi, exclusive: false},
9
+ "auth_method" => {cardinality: :single, exclusive: true},
10
+ "uses_database" => {cardinality: :single, exclusive: true},
11
+ "uses_framework" => {cardinality: :single, exclusive: true},
12
+ "deployment_platform" => {cardinality: :single, exclusive: true}
13
+ }.freeze
14
+
15
+ DEFAULT_POLICY = {cardinality: :multi, exclusive: false}.freeze
16
+
17
+ def self.policy_for(predicate)
18
+ POLICIES.fetch(predicate, DEFAULT_POLICY)
19
+ end
20
+
21
+ def self.single?(predicate)
22
+ policy_for(predicate)[:cardinality] == :single
23
+ end
24
+
25
+ def self.exclusive?(predicate)
26
+ policy_for(predicate)[:exclusive]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Resolve
5
+ class Resolver
6
+ def initialize(store)
7
+ @store = store
8
+ end
9
+
10
+ def apply(extraction, content_item_id: nil, occurred_at: nil, project_path: nil, scope: "project")
11
+ occurred_at ||= Time.now.utc.iso8601
12
+ @current_project_path = project_path
13
+ @current_scope = scope
14
+
15
+ result = {
16
+ entities_created: 0,
17
+ facts_created: 0,
18
+ facts_superseded: 0,
19
+ conflicts_created: 0,
20
+ provenance_created: 0
21
+ }
22
+
23
+ entity_ids = resolve_entities(extraction.entities)
24
+ result[:entities_created] = entity_ids.size
25
+
26
+ extraction.facts.each do |fact_data|
27
+ outcome = resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
28
+ result[:facts_created] += outcome[:created]
29
+ result[:facts_superseded] += outcome[:superseded]
30
+ result[:conflicts_created] += outcome[:conflicts]
31
+ result[:provenance_created] += outcome[:provenance]
32
+ end
33
+
34
+ result
35
+ end
36
+
37
+ private
38
+
39
+ def resolve_entities(entities)
40
+ entity_ids = {}
41
+ entities.uniq { |e| [e[:type], e[:name]] }.each do |e|
42
+ id = @store.find_or_create_entity(type: e[:type], name: e[:name])
43
+ entity_ids[e[:name]] = id
44
+ end
45
+ entity_ids
46
+ end
47
+
48
+ def resolve_fact(fact_data, entity_ids, content_item_id, occurred_at)
49
+ subject_id = entity_ids[fact_data[:subject]] ||
50
+ @store.find_or_create_entity(type: "repo", name: fact_data[:subject])
51
+
52
+ predicate = fact_data[:predicate]
53
+ object_val = fact_data[:object]
54
+ object_entity_id = entity_ids[object_val]
55
+
56
+ outcome = {created: 0, superseded: 0, conflicts: 0, provenance: 0}
57
+
58
+ existing_facts = @store.facts_for_slot(subject_id, predicate)
59
+
60
+ if PredicatePolicy.single?(predicate) && existing_facts.any?
61
+ matching = existing_facts.find { |f| values_match?(f, object_val, object_entity_id) }
62
+ if matching
63
+ add_provenance(matching[:id], content_item_id, fact_data)
64
+ outcome[:provenance] = 1
65
+ return outcome
66
+ elsif supersession_signal?(fact_data)
67
+ supersede_facts(existing_facts, occurred_at)
68
+ outcome[:superseded] = existing_facts.size
69
+ else
70
+ create_conflict(existing_facts.first[:id], fact_data, subject_id, content_item_id, occurred_at)
71
+ outcome[:conflicts] = 1
72
+ return outcome
73
+ end
74
+ end
75
+
76
+ fact_scope = fact_data[:scope_hint] || @current_scope
77
+ fact_project = (fact_scope == "global") ? nil : @current_project_path
78
+
79
+ fact_id = @store.insert_fact(
80
+ subject_entity_id: subject_id,
81
+ predicate: predicate,
82
+ object_entity_id: object_entity_id,
83
+ object_literal: object_val,
84
+ polarity: fact_data[:polarity] || "positive",
85
+ confidence: fact_data[:confidence] || 1.0,
86
+ valid_from: occurred_at,
87
+ scope: fact_scope,
88
+ project_path: fact_project
89
+ )
90
+ outcome[:created] = 1
91
+
92
+ if existing_facts.any? && outcome[:superseded] > 0
93
+ existing_facts.each do |old_fact|
94
+ @store.insert_fact_link(from_fact_id: fact_id, to_fact_id: old_fact[:id], link_type: "supersedes")
95
+ end
96
+ end
97
+
98
+ add_provenance(fact_id, content_item_id, fact_data)
99
+ outcome[:provenance] = 1
100
+
101
+ outcome
102
+ end
103
+
104
+ def supersession_signal?(fact_data)
105
+ (fact_data[:strength] == "stated") ||
106
+ fact_data.fetch(:supersedes, false)
107
+ end
108
+
109
+ def values_match?(existing_fact, object_val, object_entity_id)
110
+ existing_fact[:object_literal]&.downcase == object_val&.downcase ||
111
+ (object_entity_id && existing_fact[:object_entity_id] == object_entity_id)
112
+ end
113
+
114
+ def supersede_facts(facts, occurred_at)
115
+ facts.each do |fact|
116
+ @store.update_fact(fact[:id], status: "superseded", valid_to: occurred_at)
117
+ end
118
+ end
119
+
120
+ def create_conflict(existing_fact_id, new_fact_data, subject_id, content_item_id, occurred_at)
121
+ new_fact_id = @store.insert_fact(
122
+ subject_entity_id: subject_id,
123
+ predicate: new_fact_data[:predicate],
124
+ object_literal: new_fact_data[:object],
125
+ polarity: new_fact_data[:polarity] || "positive",
126
+ confidence: new_fact_data[:confidence] || 1.0,
127
+ status: "disputed",
128
+ valid_from: occurred_at,
129
+ scope: @current_scope,
130
+ project_path: @current_project_path
131
+ )
132
+
133
+ @store.insert_conflict(
134
+ fact_a_id: existing_fact_id,
135
+ fact_b_id: new_fact_id,
136
+ notes: "Contradicting #{new_fact_data[:predicate]} claims"
137
+ )
138
+
139
+ add_provenance(new_fact_id, content_item_id, new_fact_data)
140
+ end
141
+
142
+ def add_provenance(fact_id, content_item_id, fact_data)
143
+ @store.insert_provenance(
144
+ fact_id: fact_id,
145
+ content_item_id: content_item_id,
146
+ quote: fact_data[:quote],
147
+ strength: fact_data[:strength] || "stated"
148
+ )
149
+ end
150
+ end
151
+ end
152
+ end