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.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +32 -2
- data/.claude/settings.json +30 -52
- data/.claude/settings.local.json +3 -1
- data/.claude/skills/upgrade-dependencies/SKILL.md +154 -0
- data/.claude-plugin/marketplace.json +2 -2
- data/.claude-plugin/plugin.json +3 -3
- data/.claude-plugin/scripts/hook-runner.sh +14 -0
- data/.claude-plugin/scripts/serve-mcp.sh +14 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +41 -0
- data/CLAUDE.md +31 -17
- data/README.md +35 -0
- data/db/migrations/013_add_mcp_tool_calls.rb +26 -0
- data/db/migrations/014_canonicalize_predicates.rb +30 -0
- data/docs/improvements.md +58 -20
- data/docs/influence/claude-mem.md +1 -0
- data/docs/influence/claude-supermemory.md +1 -0
- data/docs/influence/episodic-memory.md +1 -0
- data/docs/influence/grepai.md +1 -0
- data/docs/influence/kbs.md +1 -0
- data/docs/influence/lossless-claw.md +1 -0
- data/docs/influence/qmd.md +1 -0
- data/lib/claude_memory/commands/completion_command.rb +1 -31
- data/lib/claude_memory/commands/embeddings_command.rb +198 -0
- data/lib/claude_memory/commands/help_command.rb +8 -1
- data/lib/claude_memory/commands/registry.rb +47 -34
- data/lib/claude_memory/commands/reject_command.rb +62 -0
- data/lib/claude_memory/commands/restore_command.rb +77 -0
- data/lib/claude_memory/commands/skills/distill-transcripts.md +5 -1
- data/lib/claude_memory/commands/stats_command.rb +98 -2
- data/lib/claude_memory/configuration.rb +14 -1
- data/lib/claude_memory/distill/json_schema.md +8 -4
- data/lib/claude_memory/distill/null_distiller.rb +2 -0
- data/lib/claude_memory/domain/entity.rb +13 -1
- data/lib/claude_memory/domain/fact.rb +26 -2
- data/lib/claude_memory/embeddings/api_adapter.rb +5 -4
- data/lib/claude_memory/embeddings/fastembed_adapter.rb +43 -13
- data/lib/claude_memory/embeddings/inspector.rb +91 -0
- data/lib/claude_memory/embeddings/model_registry.rb +210 -0
- data/lib/claude_memory/embeddings/resolver.rb +32 -6
- data/lib/claude_memory/ingest/ingester.rb +17 -0
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +24 -0
- data/lib/claude_memory/mcp/handlers/stats_handlers.rb +5 -2
- data/lib/claude_memory/mcp/instructions_builder.rb +17 -0
- data/lib/claude_memory/mcp/server.rb +22 -1
- data/lib/claude_memory/mcp/telemetry.rb +86 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +86 -3
- data/lib/claude_memory/mcp/tools.rb +10 -0
- data/lib/claude_memory/publish.rb +40 -5
- data/lib/claude_memory/recall.rb +81 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +63 -3
- data/lib/claude_memory/resolve/resolver.rb +43 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +250 -1
- data/lib/claude_memory/store/store_manager.rb +50 -1
- data/lib/claude_memory/sweep/maintenance.rb +115 -1
- data/lib/claude_memory/sweep/sweeper.rb +3 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +5 -0
- metadata +26 -8
- data/.claude/memory.sqlite3-shm +0 -0
- 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:
|
|
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] ==
|
|
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] ==
|
|
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
|
|
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
|
|
data/lib/claude_memory/recall.rb
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
12
|
-
"
|
|
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])
|