claude_memory 0.8.0 → 0.9.1

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +94 -2
  4. data/.claude/settings.json +30 -52
  5. data/.claude/settings.local.json +3 -1
  6. data/.claude/skills/release/SKILL.md +168 -0
  7. data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
  8. data/.claude-plugin/marketplace.json +2 -2
  9. data/.claude-plugin/plugin.json +3 -3
  10. data/.claude-plugin/scripts/hook-runner.sh +14 -0
  11. data/.claude-plugin/scripts/serve-mcp.sh +14 -0
  12. data/.ruby-version +1 -1
  13. data/CHANGELOG.md +47 -0
  14. data/CLAUDE.md +31 -17
  15. data/README.md +35 -0
  16. data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
  17. data/db/migrations/014_canonicalize_predicates.rb +30 -0
  18. data/docs/improvements.md +58 -20
  19. data/docs/influence/claude-mem.md +1 -0
  20. data/docs/influence/claude-supermemory.md +1 -0
  21. data/docs/influence/episodic-memory.md +1 -0
  22. data/docs/influence/grepai.md +1 -0
  23. data/docs/influence/kbs.md +1 -0
  24. data/docs/influence/lossless-claw.md +1 -0
  25. data/docs/influence/qmd.md +1 -0
  26. data/lib/claude_memory/commands/completion_command.rb +1 -31
  27. data/lib/claude_memory/commands/embeddings_command.rb +198 -0
  28. data/lib/claude_memory/commands/help_command.rb +8 -1
  29. data/lib/claude_memory/commands/registry.rb +47 -34
  30. data/lib/claude_memory/commands/reject_command.rb +62 -0
  31. data/lib/claude_memory/commands/restore_command.rb +77 -0
  32. data/lib/claude_memory/commands/skills/distill-transcripts.md +5 -1
  33. data/lib/claude_memory/commands/stats_command.rb +98 -2
  34. data/lib/claude_memory/configuration.rb +14 -1
  35. data/lib/claude_memory/distill/json_schema.md +8 -4
  36. data/lib/claude_memory/distill/null_distiller.rb +2 -0
  37. data/lib/claude_memory/domain/entity.rb +13 -1
  38. data/lib/claude_memory/domain/fact.rb +26 -2
  39. data/lib/claude_memory/embeddings/api_adapter.rb +5 -4
  40. data/lib/claude_memory/embeddings/fastembed_adapter.rb +43 -13
  41. data/lib/claude_memory/embeddings/inspector.rb +91 -0
  42. data/lib/claude_memory/embeddings/model_registry.rb +210 -0
  43. data/lib/claude_memory/embeddings/resolver.rb +32 -6
  44. data/lib/claude_memory/ingest/ingester.rb +17 -0
  45. data/lib/claude_memory/mcp/handlers/management_handlers.rb +24 -0
  46. data/lib/claude_memory/mcp/handlers/stats_handlers.rb +5 -2
  47. data/lib/claude_memory/mcp/instructions_builder.rb +17 -0
  48. data/lib/claude_memory/mcp/server.rb +30 -3
  49. data/lib/claude_memory/mcp/telemetry.rb +86 -0
  50. data/lib/claude_memory/mcp/tool_definitions.rb +86 -3
  51. data/lib/claude_memory/mcp/tools.rb +10 -0
  52. data/lib/claude_memory/publish.rb +40 -5
  53. data/lib/claude_memory/recall.rb +81 -0
  54. data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
  55. data/lib/claude_memory/resolve/resolver.rb +43 -0
  56. data/lib/claude_memory/store/schema_manager.rb +1 -1
  57. data/lib/claude_memory/store/sqlite_store.rb +250 -1
  58. data/lib/claude_memory/store/store_manager.rb +50 -1
  59. data/lib/claude_memory/sweep/maintenance.rb +115 -1
  60. data/lib/claude_memory/sweep/sweeper.rb +3 -0
  61. data/lib/claude_memory/version.rb +1 -1
  62. data/lib/claude_memory.rb +5 -0
  63. metadata +27 -8
  64. data/.claude/memory.sqlite3-shm +0 -0
  65. data/.claude/memory.sqlite3-wal +0 -0
@@ -129,13 +129,16 @@ module ClaudeMemory
129
129
  }
130
130
 
131
131
  if active_facts > 0
132
- stats[:top_predicates] = store.db[:facts]
132
+ all_predicates = store.db[:facts]
133
133
  .where(status: "active")
134
134
  .group_and_count(:predicate)
135
135
  .order(Sequel.desc(:count))
136
- .limit(10)
137
136
  .all
138
137
  .map { |row| {predicate: row[:predicate], count: row[:count]} }
138
+
139
+ stats[:top_predicates] = all_predicates.first(10)
140
+ stats[:predicates_known], stats[:predicates_novel] =
141
+ all_predicates.partition { |row| Resolve::PredicatePolicy.known_predicates.include?(row[:predicate]) }
139
142
  end
140
143
 
141
144
  stats
@@ -108,9 +108,26 @@ module ClaudeMemory
108
108
 
109
109
  escalation = vec ? "recall_semantic, explain, or fact_graph" : "explain or fact_graph"
110
110
  lines << "Start with fast tools (recall, decisions, conventions) before escalating to #{escalation}."
111
+
112
+ lines << proactive_recall_guidance
111
113
  lines.join("\n")
112
114
  end
113
115
 
116
+ # Directive guidance for when Claude should proactively consult memory.
117
+ # Validated by A/B testing: without these directives, Claude writes code
118
+ # using known-dangerous patterns (e.g. Sequel.sqlite) and hallucinates
119
+ # file paths instead of consulting memory for the correct structure.
120
+ def proactive_recall_guidance
121
+ <<~GUIDANCE.strip
122
+ IMPORTANT — check memory proactively in these situations:
123
+ - Before writing code: call memory.conventions to verify project patterns and avoid known gotchas
124
+ - Before explaining architecture: call memory.architecture for structural knowledge without file traversal
125
+ - Before refactoring: call memory.decisions to understand why past choices were made
126
+ - When asked about preferences: global facts store user environment and style preferences across all projects
127
+ - When adding to the codebase: recall which files and patterns to follow (memory knows correct paths and relationships)
128
+ GUIDANCE
129
+ end
130
+
114
131
  def count_by_predicates(store, predicates)
115
132
  store.facts
116
133
  .where(status: "active")
@@ -5,20 +5,30 @@ require_relative "instructions_builder"
5
5
  require_relative "query_guide"
6
6
  require_relative "text_summary"
7
7
  require_relative "error_classifier"
8
+ require_relative "telemetry"
8
9
 
9
10
  module ClaudeMemory
10
11
  module MCP
12
+ # MCP JSON-RPC server over stdio.
13
+ # Reads newline-delimited JSON requests from input, dispatches to Tools,
14
+ # and writes JSON responses to output.
11
15
  class Server
12
16
  PROTOCOL_VERSION = "2024-11-05"
13
17
 
18
+ # @param store_or_manager [Store::SQLiteStore, Store::StoreManager] database backend
19
+ # @param input [IO] input stream for JSON-RPC requests (default: $stdin)
20
+ # @param output [IO] output stream for JSON-RPC responses (default: $stdout)
14
21
  def initialize(store_or_manager, input: $stdin, output: $stdout)
15
22
  @store_or_manager = store_or_manager
16
23
  @tools = Tools.new(store_or_manager)
24
+ @telemetry = Telemetry.new(store_or_manager)
17
25
  @input = input
18
26
  @output = output
19
27
  @running = false
20
28
  end
21
29
 
30
+ # Start the read loop, blocking until input is exhausted or stop is called.
31
+ # @return [void]
22
32
  def run
23
33
  @running = true
24
34
  while @running
@@ -29,12 +39,15 @@ module ClaudeMemory
29
39
  end
30
40
  end
31
41
 
42
+ # Signal the read loop to exit after the current message.
43
+ # @return [void]
32
44
  def stop
33
45
  @running = false
34
46
  end
35
47
 
36
48
  private
37
49
 
50
+ # @return [void]
38
51
  def handle_message(line)
39
52
  return if line.empty?
40
53
 
@@ -46,15 +59,22 @@ module ClaudeMemory
46
59
  rescue JSON::ParserError => e
47
60
  send_error(-32700, "Parse error: #{e.message}", 0)
48
61
  rescue => e
49
- request_id = request&.fetch("id", nil) || 0
50
- send_error(-32603, "Internal error: #{e.message}", request_id)
62
+ # Per JSON-RPC 2.0: never respond to notifications, even on error
63
+ request_id = request&.fetch("id", nil)
64
+ send_error(-32603, "Internal error: #{e.message}", request_id) unless request_id.nil?
51
65
  end
52
66
  end
53
67
 
68
+ # @return [Hash, nil] JSON-RPC response hash, or nil for notifications
54
69
  def process_request(request)
55
70
  id = request["id"]
56
71
  method = request["method"]
57
72
 
73
+ # Per JSON-RPC 2.0: a request without an id is a notification and
74
+ # MUST NOT receive a response. MCP relies on this for
75
+ # `notifications/initialized` after the initialize handshake.
76
+ return nil if id.nil?
77
+
58
78
  case method
59
79
  when "initialize"
60
80
  handle_initialize(id, request["params"])
@@ -74,6 +94,7 @@ module ClaudeMemory
74
94
  end
75
95
  end
76
96
 
97
+ # @return [Hash] initialize response with capabilities and server info
77
98
  def handle_initialize(id, _params)
78
99
  {
79
100
  jsonrpc: "2.0",
@@ -93,6 +114,7 @@ module ClaudeMemory
93
114
  }
94
115
  end
95
116
 
117
+ # @return [Hash] list of available tool definitions
96
118
  def handle_tools_list(id)
97
119
  {
98
120
  jsonrpc: "2.0",
@@ -103,11 +125,14 @@ module ClaudeMemory
103
125
  }
104
126
  end
105
127
 
128
+ # @return [Hash] tool result with dual content/structuredContent
106
129
  def handle_tools_call(id, params)
107
130
  name = params["name"]
108
131
  arguments = params["arguments"] || {}
109
132
 
110
- result = @tools.call(name, arguments)
133
+ result = @telemetry.record(name, arguments) do
134
+ @tools.call(name, arguments)
135
+ end
111
136
 
112
137
  # Release database connections after each tool call
113
138
  # This prevents lock contention with hook commands
@@ -128,6 +153,7 @@ module ClaudeMemory
128
153
  }
129
154
  end
130
155
 
156
+ # @return [Hash] list of available prompt definitions
131
157
  def handle_prompts_list(id)
132
158
  {
133
159
  jsonrpc: "2.0",
@@ -138,6 +164,7 @@ module ClaudeMemory
138
164
  }
139
165
  end
140
166
 
167
+ # @return [Hash] prompt content or error if unknown
141
168
  def handle_prompts_get(id, params)
142
169
  name = params&.dig("name")
143
170
 
@@ -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