claude_memory 0.8.0 → 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 (64) 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 +30 -52
  5. data/.claude/settings.local.json +3 -1
  6. data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
  7. data/.claude-plugin/marketplace.json +2 -2
  8. data/.claude-plugin/plugin.json +3 -3
  9. data/.claude-plugin/scripts/hook-runner.sh +14 -0
  10. data/.claude-plugin/scripts/serve-mcp.sh +14 -0
  11. data/.ruby-version +1 -1
  12. data/CHANGELOG.md +41 -0
  13. data/CLAUDE.md +31 -17
  14. data/README.md +35 -0
  15. data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
  16. data/db/migrations/014_canonicalize_predicates.rb +30 -0
  17. data/docs/improvements.md +58 -20
  18. data/docs/influence/claude-mem.md +1 -0
  19. data/docs/influence/claude-supermemory.md +1 -0
  20. data/docs/influence/episodic-memory.md +1 -0
  21. data/docs/influence/grepai.md +1 -0
  22. data/docs/influence/kbs.md +1 -0
  23. data/docs/influence/lossless-claw.md +1 -0
  24. data/docs/influence/qmd.md +1 -0
  25. data/lib/claude_memory/commands/completion_command.rb +1 -31
  26. data/lib/claude_memory/commands/embeddings_command.rb +198 -0
  27. data/lib/claude_memory/commands/help_command.rb +8 -1
  28. data/lib/claude_memory/commands/registry.rb +47 -34
  29. data/lib/claude_memory/commands/reject_command.rb +62 -0
  30. data/lib/claude_memory/commands/restore_command.rb +77 -0
  31. data/lib/claude_memory/commands/skills/distill-transcripts.md +5 -1
  32. data/lib/claude_memory/commands/stats_command.rb +98 -2
  33. data/lib/claude_memory/configuration.rb +14 -1
  34. data/lib/claude_memory/distill/json_schema.md +8 -4
  35. data/lib/claude_memory/distill/null_distiller.rb +2 -0
  36. data/lib/claude_memory/domain/entity.rb +13 -1
  37. data/lib/claude_memory/domain/fact.rb +26 -2
  38. data/lib/claude_memory/embeddings/api_adapter.rb +5 -4
  39. data/lib/claude_memory/embeddings/fastembed_adapter.rb +43 -13
  40. data/lib/claude_memory/embeddings/inspector.rb +91 -0
  41. data/lib/claude_memory/embeddings/model_registry.rb +210 -0
  42. data/lib/claude_memory/embeddings/resolver.rb +32 -6
  43. data/lib/claude_memory/ingest/ingester.rb +17 -0
  44. data/lib/claude_memory/mcp/handlers/management_handlers.rb +24 -0
  45. data/lib/claude_memory/mcp/handlers/stats_handlers.rb +5 -2
  46. data/lib/claude_memory/mcp/instructions_builder.rb +17 -0
  47. data/lib/claude_memory/mcp/server.rb +22 -1
  48. data/lib/claude_memory/mcp/telemetry.rb +86 -0
  49. data/lib/claude_memory/mcp/tool_definitions.rb +86 -3
  50. data/lib/claude_memory/mcp/tools.rb +10 -0
  51. data/lib/claude_memory/publish.rb +40 -5
  52. data/lib/claude_memory/recall.rb +81 -0
  53. data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
  54. data/lib/claude_memory/resolve/resolver.rb +43 -0
  55. data/lib/claude_memory/store/schema_manager.rb +1 -1
  56. data/lib/claude_memory/store/sqlite_store.rb +250 -1
  57. data/lib/claude_memory/store/store_manager.rb +50 -1
  58. data/lib/claude_memory/sweep/maintenance.rb +115 -1
  59. data/lib/claude_memory/sweep/sweeper.rb +3 -0
  60. data/lib/claude_memory/version.rb +1 -1
  61. data/lib/claude_memory.rb +5 -0
  62. metadata +26 -8
  63. data/.claude/memory.sqlite3-shm +0 -0
  64. data/.claude/memory.sqlite3-wal +0 -0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module MCP
5
+ # Records MCP tool invocations into the project database for usage stats.
6
+ # Timing and error capture wrap the tool call; the insert is synchronous
7
+ # and best-effort — telemetry failures are swallowed so they never break
8
+ # a real tool response.
9
+ class Telemetry
10
+ def initialize(store_or_manager)
11
+ @store_or_manager = store_or_manager
12
+ end
13
+
14
+ # Time a tool invocation and record the outcome. Yields to the caller
15
+ # and returns whatever the block returns; re-raises any exception after
16
+ # recording it as an error.
17
+ def record(tool_name, arguments)
18
+ started = monotonic_ms
19
+ begin
20
+ result = yield
21
+ rescue => e
22
+ duration = monotonic_ms - started
23
+ write(
24
+ tool_name: tool_name,
25
+ duration_ms: duration,
26
+ result_count: nil,
27
+ scope: extract_scope(arguments),
28
+ error_class: e.class.name
29
+ )
30
+ raise
31
+ end
32
+
33
+ duration = monotonic_ms - started
34
+ write(
35
+ tool_name: tool_name,
36
+ duration_ms: duration,
37
+ result_count: extract_result_count(result),
38
+ scope: extract_scope(arguments),
39
+ error_class: nil
40
+ )
41
+ result
42
+ end
43
+
44
+ private
45
+
46
+ def monotonic_ms
47
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
48
+ end
49
+
50
+ def write(**row)
51
+ store = writable_store
52
+ return unless store
53
+ store.insert_mcp_tool_call(**row)
54
+ rescue Sequel::DatabaseError, Extralite::Error
55
+ # Telemetry is best-effort; never fail the user's tool call
56
+ # because stats couldn't be written.
57
+ end
58
+
59
+ def writable_store
60
+ if @store_or_manager.is_a?(Store::StoreManager)
61
+ @store_or_manager.ensure_project!
62
+ elsif @store_or_manager.respond_to?(:insert_mcp_tool_call)
63
+ @store_or_manager
64
+ end
65
+ end
66
+
67
+ def extract_scope(arguments)
68
+ return nil unless arguments.is_a?(Hash)
69
+ arguments["scope"] || arguments[:scope]
70
+ end
71
+
72
+ # Inspect a tool result for a countable field. Most query tools
73
+ # return hashes with :facts, :results, :conflicts, or :changes;
74
+ # fall back to nil for shapes we don't recognize.
75
+ def extract_result_count(result)
76
+ return nil unless result.is_a?(Hash)
77
+
78
+ %i[facts results conflicts changes entities items].each do |key|
79
+ value = result[key] || result[key.to_s]
80
+ return value.size if value.is_a?(Array)
81
+ end
82
+ nil
83
+ end
84
+ end
85
+ end
86
+ end
@@ -14,6 +14,59 @@ module ClaudeMemory
14
14
  # Annotations for idempotent writes (safe to retry)
15
15
  WRITE_IDEMPOTENT = {readOnlyHint: false, idempotentHint: true, destructiveHint: false}.freeze
16
16
 
17
+ # Schema for {predicate, count} entries
18
+ PREDICATE_COUNT_SCHEMA = {
19
+ type: "object",
20
+ properties: {
21
+ predicate: {type: "string"},
22
+ count: {type: "integer"}
23
+ },
24
+ required: ["predicate", "count"]
25
+ }.freeze
26
+
27
+ # Schema for per-database stats block returned by memory.stats
28
+ DATABASE_STATS_SCHEMA = {
29
+ type: "object",
30
+ properties: {
31
+ exists: {type: "boolean"},
32
+ schema_version: {type: "integer"},
33
+ facts: {
34
+ type: "object",
35
+ properties: {
36
+ total: {type: "integer"},
37
+ active: {type: "integer"},
38
+ superseded: {type: "integer"},
39
+ top_predicates: {
40
+ type: "array",
41
+ description: "Top 10 predicates by count (known + novel combined)",
42
+ items: PREDICATE_COUNT_SCHEMA
43
+ },
44
+ predicates_known: {
45
+ type: "array",
46
+ description: "Predicates with explicit cardinality policies in PredicatePolicy::POLICIES, sorted by count desc",
47
+ items: PREDICATE_COUNT_SCHEMA
48
+ },
49
+ predicates_novel: {
50
+ type: "array",
51
+ description: "Predicates not in PredicatePolicy::POLICIES, sorted by count desc. Novel predicates with high counts are candidates for promotion to known status with explicit cardinality policies (canonicalization signal).",
52
+ items: PREDICATE_COUNT_SCHEMA
53
+ }
54
+ }
55
+ },
56
+ entities: {
57
+ type: "object",
58
+ properties: {
59
+ total: {type: "integer"},
60
+ by_type: {type: "array", items: {type: "object"}}
61
+ }
62
+ },
63
+ content_items: {type: "object"},
64
+ provenance: {type: "object"},
65
+ conflicts: {type: "object"},
66
+ vec: {type: "object"}
67
+ }
68
+ }.freeze
69
+
17
70
  # Returns array of tool definitions for MCP protocol
18
71
  # @return [Array<Hash>] Tool definitions with name, description, and inputSchema
19
72
  def self.all
@@ -123,13 +176,29 @@ module ClaudeMemory
123
176
  },
124
177
  {
125
178
  name: "memory.stats",
126
- description: "Get detailed statistics about the memory system (facts by predicate, entities by type, provenance coverage, conflicts, database sizes)",
179
+ description: "Get detailed statistics about the memory system (facts by predicate, entities by type, provenance coverage, conflicts, database sizes).",
127
180
  inputSchema: {
128
181
  type: "object",
129
182
  properties: {
130
183
  scope: {type: "string", enum: ["all", "global", "project"], description: "Show stats for: all (default), global, or project", default: "all"}
131
184
  }
132
185
  },
186
+ outputSchema: {
187
+ type: "object",
188
+ properties: {
189
+ scope: {type: "string", enum: ["all", "global", "project"]},
190
+ databases: {
191
+ type: "object",
192
+ description: "Per-database stats. Keys are 'global', 'project', or 'legacy' depending on connection mode.",
193
+ properties: {
194
+ global: DATABASE_STATS_SCHEMA,
195
+ project: DATABASE_STATS_SCHEMA,
196
+ legacy: DATABASE_STATS_SCHEMA
197
+ }
198
+ }
199
+ },
200
+ required: ["scope", "databases"]
201
+ },
133
202
  annotations: READ_ONLY
134
203
  },
135
204
  {
@@ -144,6 +213,20 @@ module ClaudeMemory
144
213
  },
145
214
  annotations: WRITE_IDEMPOTENT
146
215
  },
216
+ {
217
+ name: "memory.reject_fact",
218
+ description: "Mark a fact as rejected (e.g. a distiller hallucination). Sets status to 'rejected' and closes any open conflicts involving the fact. Use when the user confirms a fact is wrong.",
219
+ inputSchema: {
220
+ type: "object",
221
+ properties: {
222
+ fact_id: {type: "integer", description: "Fact ID to reject"},
223
+ docid: {type: "string", description: "8-char docid (alternative to fact_id)"},
224
+ reason: {type: "string", description: "Why the fact is wrong (recorded in conflict notes)"},
225
+ scope: {type: "string", enum: ["project", "global"], description: "Database scope", default: "project"}
226
+ }
227
+ },
228
+ annotations: WRITE_IDEMPOTENT
229
+ },
147
230
  {
148
231
  name: "memory.store_extraction",
149
232
  description: "Store extracted facts, entities, and decisions from a conversation. Call this to persist knowledge you've learned during the session.",
@@ -156,7 +239,7 @@ module ClaudeMemory
156
239
  items: {
157
240
  type: "object",
158
241
  properties: {
159
- type: {type: "string", description: "Entity type: database, framework, language, platform, repo, module, person, service"},
242
+ type: {type: "string", description: "Entity type. Common types: database, framework, language, platform, repo, module, person, service, tool, library, concept. You may use other types if needed."},
160
243
  name: {type: "string", description: "Canonical name"},
161
244
  confidence: {type: "number", description: "0.0-1.0 extraction confidence"}
162
245
  },
@@ -170,7 +253,7 @@ module ClaudeMemory
170
253
  type: "object",
171
254
  properties: {
172
255
  subject: {type: "string", description: "Entity name or 'repo' for project-level facts"},
173
- predicate: {type: "string", description: "Relationship type: uses_database, uses_framework, convention, decision, auth_method, deployment_platform"},
256
+ predicate: {type: "string", description: "Relationship type. Known predicates: #{ClaudeMemory::Resolve::PredicatePolicy.known_predicates.join(", ")}. You may use other snake_case predicates for relations that don't fit these — be specific and reuse existing predicates when possible."},
174
257
  object: {type: "string", description: "The value or target entity"},
175
258
  confidence: {type: "number", description: "0.0-1.0 how confident"},
176
259
  quote: {type: "string", description: "Source text excerpt (max 200 chars)"},
@@ -16,6 +16,9 @@ require_relative "handlers/setup_handlers"
16
16
 
17
17
  module ClaudeMemory
18
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.
19
22
  class Tools
20
23
  include ToolHelpers
21
24
  include Handlers::QueryHandlers
@@ -25,6 +28,7 @@ module ClaudeMemory
25
28
  include Handlers::StatsHandlers
26
29
  include Handlers::SetupHandlers
27
30
 
31
+ # @param store_or_manager [Store::SQLiteStore, Store::StoreManager] database backend
28
32
  def initialize(store_or_manager)
29
33
  @recall = Recall.new(store_or_manager)
30
34
 
@@ -35,10 +39,15 @@ module ClaudeMemory
35
39
  end
36
40
  end
37
41
 
42
+ # @return [Array<Hash>] MCP tool definition hashes for tools/list
38
43
  def definitions
39
44
  ToolDefinitions.all
40
45
  end
41
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
42
51
  def call(name, arguments)
43
52
  case name
44
53
  when "memory.recall" then recall(arguments)
@@ -51,6 +60,7 @@ module ClaudeMemory
51
60
  when "memory.status" then status
52
61
  when "memory.stats" then stats(arguments)
53
62
  when "memory.promote" then promote(arguments)
63
+ when "memory.reject_fact" then reject_fact(arguments)
54
64
  when "memory.store_extraction" then store_extraction(arguments)
55
65
  when "memory.decisions" then decisions(arguments)
56
66
  when "memory.conventions" then conventions(arguments)
@@ -4,15 +4,22 @@ require "digest"
4
4
  require "fileutils"
5
5
 
6
6
  module ClaudeMemory
7
+ # Generates Markdown snapshots from active facts for use as project memory.
8
+ # Publishes to .claude/rules/ (shared), a local file, or the home directory.
7
9
  class Publish
8
10
  RULES_DIR = ".claude/rules"
9
11
  GENERATED_FILE = "claude_memory.generated.md"
10
12
 
13
+ # @param store [Store::SQLiteStore] database store for reading facts
14
+ # @param file_system [Infrastructure::FileSystem] filesystem abstraction for I/O
11
15
  def initialize(store, file_system: Infrastructure::FileSystem.new)
12
16
  @store = store
13
17
  @fs = file_system
14
18
  end
15
19
 
20
+ # Generate a complete Markdown snapshot with header and body
21
+ # @param since [String, nil] ISO 8601 timestamp to include recent supersessions
22
+ # @return [String] full Markdown document
16
23
  def generate_snapshot(since: nil)
17
24
  header = <<~HEADER
18
25
  <!--
@@ -28,6 +35,12 @@ module ClaudeMemory
28
35
  header + generate_body(since: since)
29
36
  end
30
37
 
38
+ # Write snapshot to disk if content has changed
39
+ # @param mode [Symbol] output target (:shared, :local, or :home)
40
+ # @param granularity [Symbol] snapshot granularity (currently only :repo)
41
+ # @param since [String, nil] ISO 8601 timestamp for recent supersessions
42
+ # @param rules_dir [String, nil] override rules directory path
43
+ # @return [Hash] result with :status (:updated or :unchanged) and :path
31
44
  def publish!(mode: :shared, granularity: :repo, since: nil, rules_dir: nil)
32
45
  path = output_path(mode, rules_dir: rules_dir)
33
46
  body = generate_body(since: since)
@@ -97,8 +110,9 @@ module ClaudeMemory
97
110
  .all
98
111
  end
99
112
 
113
+ # @return [String] Markdown section for decision facts
100
114
  def generate_decisions_section(facts)
101
- decisions = facts.select { |f| f[:predicate] == "decision" || f[:predicate]&.start_with?("decided_") }
115
+ decisions = facts.select { |f| Resolve::PredicatePolicy.section_for(f[:predicate]) == :decisions }
102
116
  return "" if decisions.empty?
103
117
 
104
118
  lines = ["## Current Decisions\n"]
@@ -108,8 +122,9 @@ module ClaudeMemory
108
122
  lines.join("\n") + "\n"
109
123
  end
110
124
 
125
+ # @return [String] Markdown section for convention facts
111
126
  def generate_conventions_section(facts)
112
- conventions = facts.select { |f| f[:predicate] == "convention" || f[:predicate]&.include?("_convention") }
127
+ conventions = facts.select { |f| Resolve::PredicatePolicy.section_for(f[:predicate]) == :conventions }
113
128
  return "" if conventions.empty?
114
129
 
115
130
  lines = ["## Conventions\n"]
@@ -119,10 +134,9 @@ module ClaudeMemory
119
134
  lines.join("\n") + "\n"
120
135
  end
121
136
 
137
+ # @return [String] Markdown section for technical constraint facts
122
138
  def generate_constraints_section(facts)
123
- constraints = facts.select do |f|
124
- %w[uses_database uses_framework deployment_platform auth_method].include?(f[:predicate])
125
- end
139
+ constraints = facts.select { |f| Resolve::PredicatePolicy.section_for(f[:predicate]) == :constraints }
126
140
  return "" if constraints.empty?
127
141
 
128
142
  lines = ["## Technical Constraints\n"]
@@ -132,6 +146,25 @@ module ClaudeMemory
132
146
  lines.join("\n") + "\n"
133
147
  end
134
148
 
149
+ # @return [String] Markdown section for additional knowledge grouped by predicate
150
+ def generate_additional_section(facts)
151
+ additional = facts.select { |f| Resolve::PredicatePolicy.section_for(f[:predicate]) == :additional }
152
+ return "" if additional.empty?
153
+
154
+ grouped = additional.group_by { |f| f[:predicate] }
155
+ lines = ["## Additional Knowledge\n"]
156
+ grouped.each do |predicate, group_facts|
157
+ lines << "### #{humanize(predicate)}\n"
158
+ group_facts.each do |f|
159
+ subject = f[:subject_name] || "repo"
160
+ lines << "- #{subject}: #{f[:object_literal]}"
161
+ end
162
+ lines << ""
163
+ end
164
+ lines.join("\n") + "\n"
165
+ end
166
+
167
+ # @return [String] Markdown section for open conflicts
135
168
  def generate_conflicts_section(conflicts)
136
169
  return "" if conflicts.empty?
137
170
 
@@ -143,6 +176,7 @@ module ClaudeMemory
143
176
  lines.join("\n") + "\n"
144
177
  end
145
178
 
179
+ # @return [String] Markdown section for recently superseded facts
146
180
  def generate_supersessions_section(supersessions)
147
181
  return "" if supersessions.empty?
148
182
 
@@ -162,6 +196,7 @@ module ClaudeMemory
162
196
  sections << generate_decisions_section(facts)
163
197
  sections << generate_conventions_section(facts)
164
198
  sections << generate_constraints_section(facts)
199
+ sections << generate_additional_section(facts)
165
200
  sections << generate_conflicts_section(conflicts) if conflicts.any?
166
201
  sections << generate_supersessions_section(recent_supersessions) if recent_supersessions.any?
167
202
 
@@ -1,29 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeMemory
4
+ # Query interface for facts across dual databases (global + project).
5
+ # Delegates to DualEngine or LegacyEngine depending on the store type.
4
6
  class Recall
7
+ # @return [String] query only project-scoped facts
5
8
  SCOPE_PROJECT = "project"
9
+ # @return [String] query only global-scoped facts
6
10
  SCOPE_GLOBAL = "global"
11
+ # @return [String] query both project and global facts (default)
7
12
  SCOPE_ALL = "all"
8
13
 
9
14
  class << self
15
+ # @param manager [Store::StoreManager] dual-database manager
16
+ # @param limit [Integer] max results
17
+ # @return [Array<Hash>] recent decision facts
10
18
  def recent_decisions(manager, limit: 10)
11
19
  Shortcuts.for(:decisions, manager, limit: limit)
12
20
  end
13
21
 
22
+ # @param manager [Store::StoreManager] dual-database manager
23
+ # @param limit [Integer] max results
24
+ # @return [Array<Hash>] architecture-related facts
14
25
  def architecture_choices(manager, limit: 10)
15
26
  Shortcuts.for(:architecture, manager, limit: limit)
16
27
  end
17
28
 
29
+ # @param manager [Store::StoreManager] dual-database manager
30
+ # @param limit [Integer] max results
31
+ # @return [Array<Hash>] convention facts
18
32
  def conventions(manager, limit: 20)
19
33
  Shortcuts.for(:conventions, manager, limit: limit)
20
34
  end
21
35
 
36
+ # @param manager [Store::StoreManager] dual-database manager
37
+ # @param limit [Integer] max results
38
+ # @return [Array<Hash>] project configuration facts
22
39
  def project_config(manager, limit: 10)
23
40
  Shortcuts.for(:project_config, manager, limit: limit)
24
41
  end
25
42
  end
26
43
 
44
+ # @param store_or_manager [Store::SQLiteStore, Store::StoreManager] database store or dual-database manager
45
+ # @param fts [Index::LexicalFTS, nil] full-text search index (used only with legacy single-store)
46
+ # @param project_path [String, nil] project root path (defaults to Configuration#project_dir)
47
+ # @param env [Hash] environment variables
48
+ # @param embedding_generator [Object, nil] vector embedding generator for semantic search
27
49
  def initialize(store_or_manager, fts: nil, project_path: nil, env: ENV, embedding_generator: nil)
28
50
  config = Configuration.new(env)
29
51
  resolved_project_path = project_path || config.project_dir
@@ -45,46 +67,105 @@ module ClaudeMemory
45
67
  end
46
68
  end
47
69
 
70
+ # Search facts by text query using FTS5
71
+ # @param query_text [String] search terms
72
+ # @param limit [Integer] max results
73
+ # @param scope [String] one of SCOPE_ALL, SCOPE_PROJECT, SCOPE_GLOBAL
74
+ # @param include_raw_text [Boolean] include source content text in results
75
+ # @param intent [String, nil] query intent hint for ranking
76
+ # @return [Array<Hash>] matching facts with provenance
48
77
  def query(query_text, limit: 10, scope: SCOPE_ALL, include_raw_text: false, intent: nil)
49
78
  @engine.query(query_text, limit: limit, scope: scope, include_raw_text: include_raw_text, intent: intent)
50
79
  end
51
80
 
81
+ # Search content items (not facts) via FTS5 index
82
+ # @param query_text [String] search terms
83
+ # @param limit [Integer] max results
84
+ # @param scope [String] one of SCOPE_ALL, SCOPE_PROJECT, SCOPE_GLOBAL
85
+ # @param intent [String, nil] query intent hint for ranking
86
+ # @return [Array<Hash>] matching content items
52
87
  def query_index(query_text, limit: 20, scope: SCOPE_ALL, intent: nil)
53
88
  @engine.query_index(query_text, limit: limit, scope: scope, intent: intent)
54
89
  end
55
90
 
91
+ # Traverse fact relationships (supersessions, conflicts) as a graph
92
+ # @param fact_id [Integer] starting fact ID
93
+ # @param depth [Integer] traversal depth
94
+ # @param scope [String, nil] optional scope filter
95
+ # @return [Hash] graph with nodes and edges
56
96
  def fact_graph(fact_id, depth: 2, scope: nil)
57
97
  @engine.fact_graph(fact_id, depth: depth, scope: scope)
58
98
  end
59
99
 
100
+ # Show provenance chain for a fact
101
+ # @param fact_id_or_docid [Integer, String] fact ID or document ID
102
+ # @param scope [String, nil] optional scope filter
103
+ # @return [Hash] provenance details including source content
60
104
  def explain(fact_id_or_docid, scope: nil)
61
105
  @engine.explain(fact_id_or_docid, scope: scope)
62
106
  end
63
107
 
108
+ # List facts created or modified since a given time
109
+ # @param since [String] ISO 8601 timestamp
110
+ # @param limit [Integer] max results
111
+ # @param scope [String] one of SCOPE_ALL, SCOPE_PROJECT, SCOPE_GLOBAL
112
+ # @return [Array<Hash>] recently changed facts
64
113
  def changes(since:, limit: 50, scope: SCOPE_ALL)
65
114
  @engine.changes(since: since, limit: limit, scope: scope)
66
115
  end
67
116
 
117
+ # List open fact conflicts
118
+ # @param scope [String] one of SCOPE_ALL, SCOPE_PROJECT, SCOPE_GLOBAL
119
+ # @return [Array<Hash>] unresolved conflicts
68
120
  def conflicts(scope: SCOPE_ALL)
69
121
  @engine.conflicts(scope: scope)
70
122
  end
71
123
 
124
+ # Find facts associated with a git branch
125
+ # @param branch_name [String] git branch name
126
+ # @param limit [Integer] max results
127
+ # @param scope [String] one of SCOPE_ALL, SCOPE_PROJECT, SCOPE_GLOBAL
128
+ # @return [Array<Hash>] facts from the given branch
72
129
  def facts_by_branch(branch_name, limit: 20, scope: SCOPE_ALL)
73
130
  @engine.facts_by_branch(branch_name, limit: limit, scope: scope)
74
131
  end
75
132
 
133
+ # Find facts associated with a working directory
134
+ # @param cwd [String] directory path
135
+ # @param limit [Integer] max results
136
+ # @param scope [String] one of SCOPE_ALL, SCOPE_PROJECT, SCOPE_GLOBAL
137
+ # @return [Array<Hash>] facts from the given directory
76
138
  def facts_by_directory(cwd, limit: 20, scope: SCOPE_ALL)
77
139
  @engine.facts_by_directory(cwd, limit: limit, scope: scope)
78
140
  end
79
141
 
142
+ # Find facts associated with a specific tool
143
+ # @param tool_name [String] tool name (e.g., "Read", "Bash")
144
+ # @param limit [Integer] max results
145
+ # @param scope [String] one of SCOPE_ALL, SCOPE_PROJECT, SCOPE_GLOBAL
146
+ # @return [Array<Hash>] facts from sessions using the given tool
80
147
  def facts_by_tool(tool_name, limit: 20, scope: SCOPE_ALL)
81
148
  @engine.facts_by_tool(tool_name, limit: limit, scope: scope)
82
149
  end
83
150
 
151
+ # Search facts using vector embeddings (semantic similarity)
152
+ # @param text [String] natural language query
153
+ # @param limit [Integer] max results
154
+ # @param scope [String] one of SCOPE_ALL, SCOPE_PROJECT, SCOPE_GLOBAL
155
+ # @param mode [Symbol] :vector, :lexical, or :both (hybrid RRF)
156
+ # @param explain [Boolean] include scoring breakdown in results
157
+ # @param intent [String, nil] query intent hint for ranking
158
+ # @return [Array<Hash>] semantically similar facts
84
159
  def query_semantic(text, limit: 10, scope: SCOPE_ALL, mode: :both, explain: false, intent: nil)
85
160
  @engine.query_semantic(text, limit: limit, scope: scope, mode: mode, explain: explain, intent: intent)
86
161
  end
87
162
 
163
+ # Find facts at the intersection of multiple concepts
164
+ # @param concepts [Array<String>] 2-5 concept terms to intersect
165
+ # @param limit [Integer] max results
166
+ # @param scope [String] one of SCOPE_ALL, SCOPE_PROJECT, SCOPE_GLOBAL
167
+ # @return [Array<Hash>] facts matching all given concepts
168
+ # @raise [ArgumentError] if concepts count is not 2-5
88
169
  def query_concepts(concepts, limit: 10, scope: SCOPE_ALL)
89
170
  raise ArgumentError, "Must provide 2-5 concepts" unless (2..5).cover?(concepts.size)
90
171
 
@@ -3,17 +3,77 @@
3
3
  module ClaudeMemory
4
4
  module Resolve
5
5
  class PredicatePolicy
6
+ # Canonical predicate vocabulary. Curated after a multi-project survey
7
+ # of real memory databases under ~/src — predicates with zero facts
8
+ # across every database were pruned; predicates observed in the wild
9
+ # but missing from the policy (architecture, uses_language) were added.
10
+ #
11
+ # - convention / decision: workhorse multi-value predicates
12
+ # - uses_framework / uses_language: multi-value (projects use multiple)
13
+ # - uses_database / deployment_platform / auth_method: single-value,
14
+ # correctly 1:1 per project in observed data
15
+ # - architecture: multi-value structural knowledge (was implicit)
6
16
  POLICIES = {
7
17
  "convention" => {cardinality: :multi, exclusive: false},
8
18
  "decision" => {cardinality: :multi, exclusive: false},
9
- "auth_method" => {cardinality: :single, exclusive: true},
19
+ "architecture" => {cardinality: :multi, exclusive: false},
20
+ "uses_framework" => {cardinality: :multi, exclusive: false},
21
+ "uses_language" => {cardinality: :multi, exclusive: false},
10
22
  "uses_database" => {cardinality: :single, exclusive: true},
11
- "uses_framework" => {cardinality: :single, exclusive: true},
12
- "deployment_platform" => {cardinality: :single, exclusive: true}
23
+ "deployment_platform" => {cardinality: :single, exclusive: true},
24
+ "auth_method" => {cardinality: :single, exclusive: true}
13
25
  }.freeze
14
26
 
15
27
  DEFAULT_POLICY = {cardinality: :multi, exclusive: false}.freeze
16
28
 
29
+ # Drift canonicalization. Maps predicate names the distiller has
30
+ # organically coined onto the canonical form in POLICIES. Consulted
31
+ # at insert time by the Resolver so synonym drift never fragments
32
+ # the knowledge graph.
33
+ #
34
+ # Entries observed in real project DBs:
35
+ # - has_convention (chaos_to_the_rescue): prefix-drift of convention
36
+ # - primary_language (prior policy entry): supplanted by uses_language
37
+ # which the distiller emits naturally and has multi-value semantics
38
+ SYNONYMS = {
39
+ "has_convention" => "convention",
40
+ "primary_language" => "uses_language"
41
+ }.freeze
42
+
43
+ # Section classification for the published snapshot. Keeps Publish
44
+ # from hard-coding predicate names; adding a new predicate to the
45
+ # policy and the section map in one place updates everything.
46
+ SECTION_MAP = {
47
+ "decision" => :decisions,
48
+ "convention" => :conventions,
49
+ "uses_database" => :constraints,
50
+ "uses_framework" => :constraints,
51
+ "uses_language" => :constraints,
52
+ "deployment_platform" => :constraints,
53
+ "auth_method" => :constraints
54
+ # architecture intentionally falls through to :additional for now
55
+ }.freeze
56
+
57
+ def self.known_predicates
58
+ POLICIES.keys
59
+ end
60
+
61
+ # Return the canonical form of a predicate name, applying known
62
+ # synonym mappings. Leaves unmapped predicates unchanged.
63
+ def self.canonicalize(predicate)
64
+ return predicate if predicate.nil?
65
+ SYNONYMS.fetch(predicate, predicate)
66
+ end
67
+
68
+ # Return the snapshot section a predicate belongs to.
69
+ # Respects legacy prefix/suffix patterns (decided_*, *_convention)
70
+ # that pre-date the policy.
71
+ def self.section_for(predicate)
72
+ return :decisions if predicate&.start_with?("decided_")
73
+ return :conventions if predicate&.include?("_convention")
74
+ SECTION_MAP.fetch(predicate, :additional)
75
+ end
76
+
17
77
  def self.policy_for(predicate)
18
78
  POLICIES.fetch(predicate, DEFAULT_POLICY)
19
79
  end
@@ -2,11 +2,32 @@
2
2
 
3
3
  module ClaudeMemory
4
4
  module Resolve
5
+ # Truth maintenance engine that processes distilled extractions into stored facts.
6
+ # Wraps entity resolution, fact insertion, supersession, and conflict detection
7
+ # in a single database transaction.
8
+ #
9
+ # @example
10
+ # resolver = Resolver.new(store)
11
+ # result = resolver.apply(extraction, content_item_id: 42, scope: "project")
12
+ # result[:facts_created] #=> 3
13
+ # result[:facts_superseded] #=> 1
5
14
  class Resolver
15
+ # @param store [Store::SQLiteStore] backing database for reads and writes
6
16
  def initialize(store)
7
17
  @store = store
8
18
  end
9
19
 
20
+ # Apply a distilled extraction, resolving each fact against existing knowledge.
21
+ # Facts may be inserted, reinforce an existing fact, supersede old facts, or
22
+ # create a conflict when the resolution is ambiguous.
23
+ #
24
+ # @param extraction [#entities, #facts] distilled extraction with entities and facts
25
+ # @param content_item_id [Integer, nil] source content item for provenance
26
+ # @param occurred_at [String, nil] ISO 8601 timestamp (defaults to now)
27
+ # @param project_path [String, nil] project path for scoped facts
28
+ # @param scope [String] default scope for facts ("project" or "global")
29
+ # @return [Hash] counts keyed by :entities_created, :facts_created,
30
+ # :facts_superseded, :conflicts_created, :provenance_created
10
31
  def apply(extraction, content_item_id: nil, occurred_at: nil, project_path: nil, scope: "project")
11
32
  occurred_at ||= Time.now.utc.iso8601
12
33
 
@@ -49,6 +70,21 @@ module ClaudeMemory
49
70
  end
50
71
 
51
72
  def resolve_fact(fact_data, entity_ids, content_item_id, occurred_at, project_path:, scope:)
73
+ # Canonicalize drift-prone predicate synonyms (has_convention →
74
+ # convention, primary_language → uses_language) before anything
75
+ # else looks at the predicate.
76
+ original_predicate = fact_data[:predicate]
77
+ canonical = PredicatePolicy.canonicalize(original_predicate)
78
+ if canonical != original_predicate
79
+ ClaudeMemory.logger.debug("resolve",
80
+ message: "Canonicalized predicate",
81
+ from: original_predicate,
82
+ to: canonical)
83
+ fact_data = fact_data.merge(predicate: canonical)
84
+ end
85
+
86
+ log_novel_predicate(canonical) unless PredicatePolicy.known_predicates.include?(canonical)
87
+
52
88
  subject_id = resolve_subject(fact_data, entity_ids)
53
89
  existing_facts = @store.facts_for_slot(subject_id, fact_data[:predicate])
54
90
  resolution = determine_resolution(existing_facts, fact_data, entity_ids)
@@ -57,6 +93,13 @@ module ClaudeMemory
57
93
  project_path: project_path, scope: scope)
58
94
  end
59
95
 
96
+ def log_novel_predicate(predicate)
97
+ ClaudeMemory.logger.warn("resolve",
98
+ message: "Novel predicate encountered",
99
+ predicate: predicate,
100
+ hint: "add to PredicatePolicy::POLICIES or PredicatePolicy::SYNONYMS to canonicalize")
101
+ end
102
+
60
103
  def resolve_subject(fact_data, entity_ids)
61
104
  entity_ids[fact_data[:subject]] ||
62
105
  @store.find_or_create_entity(type: "repo", name: fact_data[:subject])