claude_memory 0.9.1 → 0.11.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/skills/dashboard/SKILL.md +42 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +130 -0
- data/CLAUDE.md +30 -6
- data/README.md +66 -2
- data/db/migrations/015_add_activity_events.rb +26 -0
- data/db/migrations/016_add_moment_feedback.rb +22 -0
- data/db/migrations/017_add_last_recalled_at.rb +15 -0
- data/docs/1_0_punchlist.md +371 -0
- data/docs/EXAMPLES.md +41 -2
- data/docs/GETTING_STARTED.md +33 -4
- data/docs/architecture.md +22 -7
- data/docs/audit-queries.md +131 -0
- data/docs/dashboard.md +192 -0
- data/docs/improvements.md +650 -9
- data/docs/influence/cq.md +187 -0
- data/docs/plugin.md +13 -6
- data/docs/quality_review.md +524 -172
- data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
- data/lib/claude_memory/activity_log.rb +86 -0
- data/lib/claude_memory/commands/census_command.rb +210 -0
- data/lib/claude_memory/commands/completion_command.rb +3 -0
- data/lib/claude_memory/commands/dashboard_command.rb +54 -0
- data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
- data/lib/claude_memory/commands/digest_command.rb +273 -0
- data/lib/claude_memory/commands/hook_command.rb +61 -2
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
- data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
- data/lib/claude_memory/commands/registry.rb +7 -1
- data/lib/claude_memory/commands/show_command.rb +90 -0
- data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
- data/lib/claude_memory/commands/stats_command.rb +131 -2
- data/lib/claude_memory/commands/sweep_command.rb +2 -0
- data/lib/claude_memory/configuration.rb +16 -0
- data/lib/claude_memory/core/relative_time.rb +9 -0
- data/lib/claude_memory/dashboard/api.rb +610 -0
- data/lib/claude_memory/dashboard/conflicts.rb +279 -0
- data/lib/claude_memory/dashboard/efficacy.rb +127 -0
- data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
- data/lib/claude_memory/dashboard/health.rb +175 -0
- data/lib/claude_memory/dashboard/index.html +2707 -0
- data/lib/claude_memory/dashboard/knowledge.rb +136 -0
- data/lib/claude_memory/dashboard/moments.rb +244 -0
- data/lib/claude_memory/dashboard/reuse.rb +97 -0
- data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
- data/lib/claude_memory/dashboard/server.rb +211 -0
- data/lib/claude_memory/dashboard/timeline.rb +68 -0
- data/lib/claude_memory/dashboard/trust.rb +454 -0
- data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
- data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
- data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
- data/lib/claude_memory/hook/context_injector.rb +97 -3
- data/lib/claude_memory/hook/handler.rb +191 -3
- data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
- data/lib/claude_memory/mcp/query_guide.rb +11 -0
- data/lib/claude_memory/mcp/text_summary.rb +29 -0
- data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
- data/lib/claude_memory/mcp/tools.rb +148 -0
- data/lib/claude_memory/publish.rb +13 -21
- data/lib/claude_memory/recall/stale_detector.rb +67 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
- data/lib/claude_memory/resolve/resolver.rb +41 -11
- data/lib/claude_memory/store/llm_cache.rb +68 -0
- data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +47 -143
- data/lib/claude_memory/store/store_manager.rb +29 -0
- data/lib/claude_memory/sweep/maintenance.rb +216 -0
- data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
- data/lib/claude_memory/sweep/sweeper.rb +2 -0
- data/lib/claude_memory/templates/hooks.example.json +5 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +24 -0
- metadata +51 -1
|
@@ -44,11 +44,34 @@ module ClaudeMemory
|
|
|
44
44
|
ToolDefinitions.all
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Tools that represent recall/query usage - tracked for efficacy
|
|
48
|
+
RECALL_TOOLS = %w[
|
|
49
|
+
memory.recall memory.recall_index memory.recall_semantic
|
|
50
|
+
memory.search_concepts memory.decisions memory.conventions memory.architecture
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# Write tools worth tracking
|
|
54
|
+
WRITE_TOOLS = %w[memory.store_extraction].freeze
|
|
55
|
+
|
|
56
|
+
TRACKED_TOOLS = (RECALL_TOOLS + WRITE_TOOLS).freeze
|
|
57
|
+
|
|
47
58
|
# Dispatch a tool call to the appropriate handler method.
|
|
48
59
|
# @param name [String] fully-qualified tool name (e.g. "memory.recall")
|
|
49
60
|
# @param arguments [Hash] tool arguments from the MCP request
|
|
50
61
|
# @return [Hash] structured result hash for the tool response
|
|
51
62
|
def call(name, arguments)
|
|
63
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
64
|
+
|
|
65
|
+
result = dispatch(name, arguments)
|
|
66
|
+
|
|
67
|
+
log_tool_activity(name, arguments, result, t0) if TRACKED_TOOLS.include?(name)
|
|
68
|
+
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def dispatch(name, arguments)
|
|
52
75
|
case name
|
|
53
76
|
when "memory.recall" then recall(arguments)
|
|
54
77
|
when "memory.recall_index" then recall_index(arguments)
|
|
@@ -74,6 +97,7 @@ module ClaudeMemory
|
|
|
74
97
|
when "memory.mark_distilled" then mark_distilled(arguments)
|
|
75
98
|
when "memory.check_setup" then check_setup
|
|
76
99
|
when "memory.list_projects" then list_projects
|
|
100
|
+
when "memory.activity" then activity(arguments)
|
|
77
101
|
else {error: "Unknown tool: #{name}"}
|
|
78
102
|
end
|
|
79
103
|
end
|
|
@@ -111,6 +135,130 @@ module ClaudeMemory
|
|
|
111
135
|
@legacy_store
|
|
112
136
|
end
|
|
113
137
|
end
|
|
138
|
+
|
|
139
|
+
def log_tool_activity(name, arguments, result, t0)
|
|
140
|
+
store = default_store
|
|
141
|
+
return unless store
|
|
142
|
+
|
|
143
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
144
|
+
event_type = WRITE_TOOLS.include?(name) ? "store_extraction" : "recall"
|
|
145
|
+
status = result[:error] ? "error" : "success"
|
|
146
|
+
session_id = extract_session_id(arguments)
|
|
147
|
+
|
|
148
|
+
details = {tool: name}
|
|
149
|
+
if event_type == "recall"
|
|
150
|
+
details[:query] = arguments["query"] || arguments["concepts"]&.join(", ")
|
|
151
|
+
details[:scope] = arguments["scope"]
|
|
152
|
+
details[:result_count] = extract_result_count(result)
|
|
153
|
+
# top_fact_ids is a flat list of the first 5 IDs; top_facts_by_scope
|
|
154
|
+
# groups the same IDs by source so dashboard readers can resolve
|
|
155
|
+
# each ID from the DB it actually came from. Fact IDs autoincrement
|
|
156
|
+
# per-DB, so a bare ID without scope is ambiguous.
|
|
157
|
+
scoped = extract_top_facts_scoped(result)
|
|
158
|
+
details[:top_fact_ids] = scoped.values.flatten.first(5)
|
|
159
|
+
details[:top_facts_by_scope] = scoped if scoped.any?
|
|
160
|
+
details[:results_by_scope] = extract_scope_breakdown(result)
|
|
161
|
+
else
|
|
162
|
+
details[:facts_created] = result[:facts_created]
|
|
163
|
+
details[:entities_created] = result[:entities_created]
|
|
164
|
+
details[:content_item_id] = result[:content_item_id]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
ActivityLog.record(store, event_type: event_type, status: status,
|
|
168
|
+
session_id: session_id, duration_ms: duration_ms, details: details.compact)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Probe a recall result for a count of returned items. Falls back to
|
|
172
|
+
# counting the first array-valued key among the shapes emitted by the
|
|
173
|
+
# various recall handlers (facts, results, items, concepts).
|
|
174
|
+
def extract_result_count(result)
|
|
175
|
+
return 0 unless result.is_a?(Hash)
|
|
176
|
+
[:fact_count, :count, :results_count].each do |key|
|
|
177
|
+
val = result[key]
|
|
178
|
+
return val if val.is_a?(Integer)
|
|
179
|
+
end
|
|
180
|
+
[:facts, :results, :items, :concepts, :conflicts].each do |key|
|
|
181
|
+
val = result[key]
|
|
182
|
+
return val.size if val.is_a?(Array)
|
|
183
|
+
end
|
|
184
|
+
0
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Capture up to 5 fact ids from a recall result, grouped by source scope.
|
|
188
|
+
# Fact IDs autoincrement per-DB, so without scope a bare ID is ambiguous
|
|
189
|
+
# (project fact #1 and global fact #1 are different facts). Recall rows
|
|
190
|
+
# carry either a :source or :scope field identifying which DB the fact
|
|
191
|
+
# came from; we use that to group.
|
|
192
|
+
#
|
|
193
|
+
# @return [Hash{String => Array<Integer>}] e.g. {"project" => [5, 8], "global" => [1]}
|
|
194
|
+
def extract_top_facts_scoped(result, limit: 5)
|
|
195
|
+
return {} unless result.is_a?(Hash)
|
|
196
|
+
collection = [:facts, :results, :items].map { |k| result[k] }.find { |v| v.is_a?(Array) }
|
|
197
|
+
return {} unless collection
|
|
198
|
+
|
|
199
|
+
grouped = Hash.new { |h, k| h[k] = [] }
|
|
200
|
+
collection.first(limit).each do |row|
|
|
201
|
+
next unless row.is_a?(Hash)
|
|
202
|
+
fact = row[:fact] || row["fact"] || row
|
|
203
|
+
id = fact.is_a?(Hash) ? (fact[:id] || fact["id"]) : nil
|
|
204
|
+
next unless id
|
|
205
|
+
scope = row[:source] || row["source"] || fact[:scope] || fact["scope"] || "project"
|
|
206
|
+
grouped[scope.to_s] << id
|
|
207
|
+
end
|
|
208
|
+
grouped
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def extract_session_id(arguments)
|
|
212
|
+
(arguments.is_a?(Hash) && arguments["session_id"]) || Configuration.new.session_id
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Count returned items grouped by their :scope field so the dashboard
|
|
216
|
+
# can show whether a recall's hits came from global preferences, project
|
|
217
|
+
# facts, or both. Returns nil when the result shape doesn't carry facts.
|
|
218
|
+
# @return [Hash{String => Integer}, nil]
|
|
219
|
+
def extract_scope_breakdown(result)
|
|
220
|
+
return nil unless result.is_a?(Hash)
|
|
221
|
+
collection = [:facts, :results, :items].map { |k| result[k] }.find { |v| v.is_a?(Array) }
|
|
222
|
+
return nil unless collection
|
|
223
|
+
|
|
224
|
+
breakdown = Hash.new(0)
|
|
225
|
+
collection.each { |row|
|
|
226
|
+
next unless row.is_a?(Hash)
|
|
227
|
+
scope = row[:scope] || row["scope"] || row[:source] || row["source"] || "unknown"
|
|
228
|
+
breakdown[scope.to_s] += 1
|
|
229
|
+
}
|
|
230
|
+
breakdown.empty? ? nil : breakdown
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Return whichever store is available for activity logging. Delegates
|
|
234
|
+
# to StoreManager#default_store which prefers the project store and
|
|
235
|
+
# falls back to global — preventing silent drops of activity events
|
|
236
|
+
# when the project DB hasn't been initialized yet.
|
|
237
|
+
def default_store
|
|
238
|
+
return @legacy_store unless @manager
|
|
239
|
+
@manager.default_store(prefer: :project)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def activity(args)
|
|
243
|
+
store = default_store
|
|
244
|
+
return {error: "No database available"} unless store
|
|
245
|
+
|
|
246
|
+
limit = args["limit"] || 50
|
|
247
|
+
event_type = args["event_type"]
|
|
248
|
+
since = args["since"]
|
|
249
|
+
|
|
250
|
+
events = ActivityLog.recent(store, limit: limit, event_type: event_type, since: since)
|
|
251
|
+
summary = ActivityLog.summary(store, since: since)
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
event_count: events.size,
|
|
255
|
+
summary: summary,
|
|
256
|
+
events: events.map { |e|
|
|
257
|
+
e[:occurred_ago] = Core::RelativeTime.format(e[:occurred_at])
|
|
258
|
+
e
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
end
|
|
114
262
|
end
|
|
115
263
|
end
|
|
116
264
|
end
|
|
@@ -112,38 +112,30 @@ module ClaudeMemory
|
|
|
112
112
|
|
|
113
113
|
# @return [String] Markdown section for decision facts
|
|
114
114
|
def generate_decisions_section(facts)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
lines = ["## Current Decisions\n"]
|
|
119
|
-
decisions.each do |d|
|
|
120
|
-
lines << "- #{d[:object_literal]}"
|
|
115
|
+
generate_section(facts, section: :decisions, title: "Current Decisions") do |d|
|
|
116
|
+
"- #{d[:object_literal]}"
|
|
121
117
|
end
|
|
122
|
-
lines.join("\n") + "\n"
|
|
123
118
|
end
|
|
124
119
|
|
|
125
120
|
# @return [String] Markdown section for convention facts
|
|
126
121
|
def generate_conventions_section(facts)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
lines = ["## Conventions\n"]
|
|
131
|
-
conventions.each do |c|
|
|
132
|
-
lines << "- #{c[:object_literal]}"
|
|
122
|
+
generate_section(facts, section: :conventions, title: "Conventions") do |c|
|
|
123
|
+
"- #{c[:object_literal]}"
|
|
133
124
|
end
|
|
134
|
-
lines.join("\n") + "\n"
|
|
135
125
|
end
|
|
136
126
|
|
|
137
127
|
# @return [String] Markdown section for technical constraint facts
|
|
138
128
|
def generate_constraints_section(facts)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
lines = ["## Technical Constraints\n"]
|
|
143
|
-
constraints.each do |c|
|
|
144
|
-
lines << "- **#{humanize(c[:predicate])}**: #{c[:object_literal]}"
|
|
129
|
+
generate_section(facts, section: :constraints, title: "Technical Constraints") do |c|
|
|
130
|
+
"- **#{humanize(c[:predicate])}**: #{c[:object_literal]}"
|
|
145
131
|
end
|
|
146
|
-
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def generate_section(facts, section:, title:, &row_formatter)
|
|
135
|
+
rows = facts.select { |f| Resolve::PredicatePolicy.section_for(f[:predicate]) == section }
|
|
136
|
+
return "" if rows.empty?
|
|
137
|
+
|
|
138
|
+
(["## #{title}\n"] + rows.map(&row_formatter)).join("\n") + "\n"
|
|
147
139
|
end
|
|
148
140
|
|
|
149
141
|
# @return [String] Markdown section for additional knowledge grouped by predicate
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
class Recall
|
|
5
|
+
# #35 access-based staleness — read-only query layer over the
|
|
6
|
+
# last_recalled_at column populated by Sweep::RecallTimestampRefresher.
|
|
7
|
+
#
|
|
8
|
+
# An active fact is "stale" when:
|
|
9
|
+
# - It hasn't been recalled or context-injected within `threshold_days`
|
|
10
|
+
# (last_recalled_at < cutoff OR last_recalled_at is NULL), AND
|
|
11
|
+
# - It was created before the cutoff too — freshly extracted facts
|
|
12
|
+
# aren't dead weight, they just haven't had a chance to be used.
|
|
13
|
+
#
|
|
14
|
+
# No auto-deletion. The point is to surface a count and a list to the
|
|
15
|
+
# user so they can review and reject; the sweeper never acts on this.
|
|
16
|
+
module StaleDetector
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @param manager [Store::StoreManager]
|
|
20
|
+
# @param threshold_days [Integer] grace window in days
|
|
21
|
+
# @param limit [Integer] max rows per scope (0 = unlimited)
|
|
22
|
+
# @return [Hash] {project: [...], global: [...], total: Int}
|
|
23
|
+
def stale_facts(manager, threshold_days:, limit: 50)
|
|
24
|
+
cutoff = (Time.now.utc - threshold_days * 86_400).iso8601
|
|
25
|
+
result = {project: [], global: [], total: 0}
|
|
26
|
+
|
|
27
|
+
%w[project global].each do |scope|
|
|
28
|
+
store = manager.store_if_exists(scope)
|
|
29
|
+
next unless store
|
|
30
|
+
rows = stale_rows_for(store, cutoff, limit)
|
|
31
|
+
result[scope.to_sym] = rows
|
|
32
|
+
result[:total] += rows.size
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Scope-agnostic count helper for the dashboard sidebar. Avoids
|
|
39
|
+
# materializing rows when only a count is needed.
|
|
40
|
+
#
|
|
41
|
+
# @return [Integer] total stale facts across both stores
|
|
42
|
+
def stale_count(manager, threshold_days:)
|
|
43
|
+
cutoff = (Time.now.utc - threshold_days * 86_400).iso8601
|
|
44
|
+
count = 0
|
|
45
|
+
%w[project global].each do |scope|
|
|
46
|
+
store = manager.store_if_exists(scope)
|
|
47
|
+
next unless store
|
|
48
|
+
count += stale_dataset(store, cutoff).count
|
|
49
|
+
end
|
|
50
|
+
count
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def stale_dataset(store, cutoff)
|
|
54
|
+
store.facts
|
|
55
|
+
.where(status: "active")
|
|
56
|
+
.where { created_at < cutoff }
|
|
57
|
+
.where { (last_recalled_at < cutoff) | {last_recalled_at: nil} }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stale_rows_for(store, cutoff, limit)
|
|
61
|
+
ds = stale_dataset(store, cutoff).order(Sequel.asc(:last_recalled_at)).order_append(:created_at)
|
|
62
|
+
ds = ds.limit(limit) if limit > 0
|
|
63
|
+
ds.all
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -17,6 +17,7 @@ module ClaudeMemory
|
|
|
17
17
|
"convention" => {cardinality: :multi, exclusive: false},
|
|
18
18
|
"decision" => {cardinality: :multi, exclusive: false},
|
|
19
19
|
"architecture" => {cardinality: :multi, exclusive: false},
|
|
20
|
+
"reference" => {cardinality: :multi, exclusive: false},
|
|
20
21
|
"uses_framework" => {cardinality: :multi, exclusive: false},
|
|
21
22
|
"uses_language" => {cardinality: :multi, exclusive: false},
|
|
22
23
|
"uses_database" => {cardinality: :single, exclusive: true},
|
|
@@ -46,6 +47,7 @@ module ClaudeMemory
|
|
|
46
47
|
SECTION_MAP = {
|
|
47
48
|
"decision" => :decisions,
|
|
48
49
|
"convention" => :conventions,
|
|
50
|
+
"reference" => :references,
|
|
49
51
|
"uses_database" => :constraints,
|
|
50
52
|
"uses_framework" => :constraints,
|
|
51
53
|
"uses_language" => :constraints,
|
|
@@ -106,17 +106,26 @@ module ClaudeMemory
|
|
|
106
106
|
end
|
|
107
107
|
|
|
108
108
|
def determine_resolution(existing_facts, fact_data, entity_ids)
|
|
109
|
-
return :insert
|
|
110
|
-
|
|
109
|
+
return :insert if existing_facts.empty?
|
|
110
|
+
|
|
111
|
+
# Always reinforce on an exact match — works for both single- and
|
|
112
|
+
# multi-value predicates. Without this check, multi-value predicates
|
|
113
|
+
# like uses_language and uses_framework accumulated an identical
|
|
114
|
+
# fact every ingest cycle (one ruby fact per Stop hook), because
|
|
115
|
+
# the old :insert fast-path for multi-value never looked at the
|
|
116
|
+
# existing set.
|
|
111
117
|
object_entity_id = entity_ids[fact_data[:object]]
|
|
112
118
|
matching = existing_facts.find { |f| values_match?(f, fact_data[:object], object_entity_id) }
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
return :reinforce if matching
|
|
120
|
+
|
|
121
|
+
# No exact match: for multi-value predicates the new object is
|
|
122
|
+
# genuinely a new coexisting value. For single-value, either the
|
|
123
|
+
# user signaled supersession ("now we use X instead") or the new
|
|
124
|
+
# claim contradicts the current one.
|
|
125
|
+
if PredicatePolicy.single?(fact_data[:predicate])
|
|
126
|
+
supersession_signal?(fact_data) ? :supersede : :conflict
|
|
118
127
|
else
|
|
119
|
-
:
|
|
128
|
+
:insert
|
|
120
129
|
end
|
|
121
130
|
end
|
|
122
131
|
|
|
@@ -141,6 +150,20 @@ module ClaudeMemory
|
|
|
141
150
|
end
|
|
142
151
|
|
|
143
152
|
def apply_conflict(existing_facts, fact_data, subject_id, content_item_id, occurred_at, project_path:, scope:)
|
|
153
|
+
# Before creating a new disputed fact + conflict row, check whether
|
|
154
|
+
# we've already recorded this exact contradiction against the same
|
|
155
|
+
# active slot. Without this guard, every re-extraction of the losing
|
|
156
|
+
# value produced a new disputed fact + conflict row — the production
|
|
157
|
+
# DB accumulated 11 identical sqlite-vs-postgresql conflict rows that
|
|
158
|
+
# way. facts_for_slot defaults to status="active", so the existing
|
|
159
|
+
# disputed fact stayed invisible until we explicitly asked for it.
|
|
160
|
+
existing_disputed = @store.facts_for_slot(subject_id, fact_data[:predicate], status: "disputed")
|
|
161
|
+
matching = existing_disputed.find { |f| values_match?(f, fact_data[:object], nil) }
|
|
162
|
+
if matching
|
|
163
|
+
add_provenance(matching[:id], content_item_id, fact_data)
|
|
164
|
+
return {created: 0, superseded: 0, conflicts: 0, provenance: 1}
|
|
165
|
+
end
|
|
166
|
+
|
|
144
167
|
create_conflict(existing_facts.first[:id], fact_data, subject_id, content_item_id, occurred_at,
|
|
145
168
|
project_path: project_path, scope: scope)
|
|
146
169
|
{created: 0, superseded: 0, conflicts: 1, provenance: 0}
|
|
@@ -162,8 +185,15 @@ module ClaudeMemory
|
|
|
162
185
|
end
|
|
163
186
|
|
|
164
187
|
def insert_new_fact(fact_data, subject_id, entity_ids, occurred_at, project_path:, scope:)
|
|
165
|
-
|
|
166
|
-
|
|
188
|
+
# The fact's scope MUST match the store it's being written to.
|
|
189
|
+
# The distiller may emit scope_hint: "global" when text matches
|
|
190
|
+
# patterns like "always" / "my preference", but scope_hint is
|
|
191
|
+
# advisory — it doesn't route the write. Honoring it as a scope
|
|
192
|
+
# override produced "scope=global" rows inside the project DB
|
|
193
|
+
# (orphaned facts that were never visible to global recall). Users
|
|
194
|
+
# who want a project fact in global memory use `claude-memory
|
|
195
|
+
# promote`, which does the proper cross-store copy.
|
|
196
|
+
fact_project = (scope == "global") ? nil : project_path
|
|
167
197
|
|
|
168
198
|
@store.insert_fact(
|
|
169
199
|
subject_entity_id: subject_id,
|
|
@@ -173,7 +203,7 @@ module ClaudeMemory
|
|
|
173
203
|
polarity: fact_data[:polarity] || "positive",
|
|
174
204
|
confidence: fact_data[:confidence] || 1.0,
|
|
175
205
|
valid_from: occurred_at,
|
|
176
|
-
scope:
|
|
206
|
+
scope: scope,
|
|
177
207
|
project_path: fact_project
|
|
178
208
|
)
|
|
179
209
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Store
|
|
7
|
+
# LLM cache persistence for the SQLiteStore.
|
|
8
|
+
# Keyed on SHA-256 of "{operation}:{model}:{input_hash}" so identical
|
|
9
|
+
# (operation, model, input) tuples collapse to a single row via upsert.
|
|
10
|
+
# Pruning is age-based — callers decide the retention window.
|
|
11
|
+
module LLMCache
|
|
12
|
+
# Look up a cached LLM result by its cache key.
|
|
13
|
+
# @param cache_key [String] SHA-256 hex cache key
|
|
14
|
+
# @return [Hash, nil]
|
|
15
|
+
def llm_cache_lookup(cache_key)
|
|
16
|
+
llm_cache.where(cache_key: cache_key).first
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Store or update a cached LLM result. Uses upsert on the cache_key.
|
|
20
|
+
# @param operation [String] operation name (e.g. "distill", "embed")
|
|
21
|
+
# @param model [String] model identifier
|
|
22
|
+
# @param input_hash [String] SHA-256 hex digest of the input
|
|
23
|
+
# @param result_json [String] JSON-serialized result
|
|
24
|
+
# @param input_tokens [Integer, nil] input tokens consumed
|
|
25
|
+
# @param output_tokens [Integer, nil] output tokens consumed
|
|
26
|
+
# @return [void]
|
|
27
|
+
def llm_cache_store(operation:, model:, input_hash:, result_json:, input_tokens: nil, output_tokens: nil)
|
|
28
|
+
cache_key = Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
|
|
29
|
+
|
|
30
|
+
llm_cache
|
|
31
|
+
.insert_conflict(target: :cache_key, update: {
|
|
32
|
+
result_json: result_json,
|
|
33
|
+
input_tokens: input_tokens,
|
|
34
|
+
output_tokens: output_tokens,
|
|
35
|
+
created_at: Time.now.utc.iso8601
|
|
36
|
+
})
|
|
37
|
+
.insert(
|
|
38
|
+
cache_key: cache_key,
|
|
39
|
+
operation: operation,
|
|
40
|
+
model: model,
|
|
41
|
+
input_hash: input_hash,
|
|
42
|
+
result_json: result_json,
|
|
43
|
+
input_tokens: input_tokens,
|
|
44
|
+
output_tokens: output_tokens,
|
|
45
|
+
created_at: Time.now.utc.iso8601
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Compute the cache key for an LLM operation.
|
|
50
|
+
# @param operation [String] operation name
|
|
51
|
+
# @param model [String] model identifier
|
|
52
|
+
# @param input [String] raw input text
|
|
53
|
+
# @return [String] SHA-256 hex cache key
|
|
54
|
+
def llm_cache_key(operation, model, input)
|
|
55
|
+
input_hash = Digest::SHA256.hexdigest(input)
|
|
56
|
+
Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Delete LLM cache entries older than the given age.
|
|
60
|
+
# @param max_age_seconds [Integer] maximum age in seconds (default: 7 days)
|
|
61
|
+
# @return [Integer] number of rows deleted
|
|
62
|
+
def llm_cache_prune(max_age_seconds: 604_800)
|
|
63
|
+
cutoff = (Time.now - max_age_seconds).utc.iso8601
|
|
64
|
+
llm_cache.where { created_at < cutoff }.delete
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Store
|
|
5
|
+
# Ingestion metrics persistence and aggregation for the SQLiteStore.
|
|
6
|
+
# Records per-distillation LLM token usage and extraction counts, and
|
|
7
|
+
# computes totals + efficiency ratios over the full history.
|
|
8
|
+
module MetricsAggregator
|
|
9
|
+
# Count content items that have not yet been distilled.
|
|
10
|
+
# @param min_length [Integer] minimum byte_len threshold
|
|
11
|
+
# @return [Integer]
|
|
12
|
+
def count_undistilled(min_length: 200)
|
|
13
|
+
content_items
|
|
14
|
+
.left_join(:ingestion_metrics, content_item_id: :id)
|
|
15
|
+
.where(Sequel[:ingestion_metrics][:id] => nil)
|
|
16
|
+
.where { byte_len >= min_length }
|
|
17
|
+
.count
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Record token usage and extraction counts for a distillation run.
|
|
21
|
+
# @param content_item_id [Integer] content item that was distilled
|
|
22
|
+
# @param input_tokens [Integer] LLM input tokens consumed
|
|
23
|
+
# @param output_tokens [Integer] LLM output tokens consumed
|
|
24
|
+
# @param facts_extracted [Integer] number of facts extracted
|
|
25
|
+
# @return [Integer] inserted row id
|
|
26
|
+
def record_ingestion_metrics(content_item_id:, input_tokens:, output_tokens:, facts_extracted:)
|
|
27
|
+
ingestion_metrics.insert(
|
|
28
|
+
content_item_id: content_item_id,
|
|
29
|
+
input_tokens: input_tokens,
|
|
30
|
+
output_tokens: output_tokens,
|
|
31
|
+
facts_extracted: facts_extracted,
|
|
32
|
+
created_at: Time.now.utc.iso8601
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Compute aggregate ingestion metrics across all distillation runs.
|
|
37
|
+
# @return [Hash, nil] totals and efficiency ratio, or nil if no data
|
|
38
|
+
def aggregate_ingestion_metrics
|
|
39
|
+
# standard:disable Performance/Detect (Sequel DSL requires .select{}.first)
|
|
40
|
+
result = ingestion_metrics
|
|
41
|
+
.select {
|
|
42
|
+
[
|
|
43
|
+
sum(:input_tokens).as(:total_input),
|
|
44
|
+
sum(:output_tokens).as(:total_output),
|
|
45
|
+
sum(:facts_extracted).as(:total_facts),
|
|
46
|
+
count(:id).as(:total_ops)
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
.first
|
|
50
|
+
# standard:enable Performance/Detect
|
|
51
|
+
|
|
52
|
+
return nil if result.nil? || result[:total_ops].to_i.zero?
|
|
53
|
+
|
|
54
|
+
total_input = result[:total_input].to_i
|
|
55
|
+
total_output = result[:total_output].to_i
|
|
56
|
+
total_facts = result[:total_facts].to_i
|
|
57
|
+
total_ops = result[:total_ops].to_i
|
|
58
|
+
|
|
59
|
+
efficiency = total_input.zero? ? 0.0 : (total_facts.to_f / total_input * 1000).round(2)
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
total_input_tokens: total_input,
|
|
63
|
+
total_output_tokens: total_output,
|
|
64
|
+
total_facts_extracted: total_facts,
|
|
65
|
+
total_operations: total_ops,
|
|
66
|
+
avg_facts_per_1k_input_tokens: efficiency
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Mark all undistilled content items as distilled with zero token counts.
|
|
71
|
+
# Used for backfilling legacy content that predates the metrics table.
|
|
72
|
+
# @return [Integer] number of items backfilled
|
|
73
|
+
def backfill_distillation_metrics!
|
|
74
|
+
undistilled_ids = content_items
|
|
75
|
+
.left_join(:ingestion_metrics, content_item_id: :id)
|
|
76
|
+
.where(Sequel[:ingestion_metrics][:id] => nil)
|
|
77
|
+
.select_map(Sequel[:content_items][:id])
|
|
78
|
+
|
|
79
|
+
return 0 if undistilled_ids.empty?
|
|
80
|
+
|
|
81
|
+
now = Time.now.utc.iso8601
|
|
82
|
+
undistilled_ids.each do |cid|
|
|
83
|
+
ingestion_metrics.insert(
|
|
84
|
+
content_item_id: cid,
|
|
85
|
+
input_tokens: 0,
|
|
86
|
+
output_tokens: 0,
|
|
87
|
+
facts_extracted: 0,
|
|
88
|
+
created_at: now
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
undistilled_ids.size
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|