claude_memory 0.9.0 → 0.10.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 +63 -1
- data/.claude/skills/dashboard/SKILL.md +42 -0
- data/.claude/skills/release/SKILL.md +168 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +92 -0
- data/CLAUDE.md +21 -5
- data/README.md +32 -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 +190 -0
- data/docs/EXAMPLES.md +41 -2
- data/docs/GETTING_STARTED.md +31 -4
- data/docs/architecture.md +22 -7
- data/docs/audit-queries.md +131 -0
- data/docs/dashboard.md +172 -0
- data/docs/improvements.md +465 -9
- data/docs/influence/cq.md +187 -0
- data/docs/plugin.md +13 -6
- data/docs/quality_review.md +489 -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 +181 -0
- data/lib/claude_memory/commands/hook_command.rb +34 -0
- data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
- data/lib/claude_memory/commands/registry.rb +6 -1
- data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
- data/lib/claude_memory/commands/stats_command.rb +38 -1
- 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 +285 -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 +50 -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/server.rb +8 -2
- 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/version.rb +1 -1
- data/lib/claude_memory.rb +22 -0
- metadata +50 -1
|
@@ -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
|
|
@@ -8,6 +8,8 @@ require "extralite"
|
|
|
8
8
|
require "sequel/adapters/extralite"
|
|
9
9
|
require_relative "retry_handler"
|
|
10
10
|
require_relative "schema_manager"
|
|
11
|
+
require_relative "llm_cache"
|
|
12
|
+
require_relative "metrics_aggregator"
|
|
11
13
|
|
|
12
14
|
module ClaudeMemory
|
|
13
15
|
module Store
|
|
@@ -19,6 +21,8 @@ module ClaudeMemory
|
|
|
19
21
|
class SQLiteStore
|
|
20
22
|
include RetryHandler
|
|
21
23
|
include SchemaManager
|
|
24
|
+
include LLMCache
|
|
25
|
+
include MetricsAggregator
|
|
22
26
|
|
|
23
27
|
# @return [Sequel::Database] the underlying Sequel database connection
|
|
24
28
|
attr_reader :db
|
|
@@ -101,6 +105,49 @@ module ClaudeMemory
|
|
|
101
105
|
# @return [Sequel::Dataset]
|
|
102
106
|
def mcp_tool_calls = @db[:mcp_tool_calls]
|
|
103
107
|
|
|
108
|
+
# @return [Sequel::Dataset]
|
|
109
|
+
def activity_events = @db[:activity_events]
|
|
110
|
+
|
|
111
|
+
# @return [Sequel::Dataset]
|
|
112
|
+
def moment_feedback = @db[:moment_feedback]
|
|
113
|
+
|
|
114
|
+
# Upsert a thumbs-up/down verdict for a moment. One row per event_id
|
|
115
|
+
# (unique constraint on the column) — repeat clicks overwrite. Returns
|
|
116
|
+
# the persisted row.
|
|
117
|
+
#
|
|
118
|
+
# @param event_id [Integer] activity_events row id
|
|
119
|
+
# @param verdict [String] "up" or "down"
|
|
120
|
+
# @param note [String, nil] optional freeform note
|
|
121
|
+
# @param recorded_at [String, nil] ISO 8601 timestamp (defaults to now UTC)
|
|
122
|
+
# @return [Hash] row after upsert
|
|
123
|
+
def upsert_moment_feedback(event_id:, verdict:, note: nil, recorded_at: nil)
|
|
124
|
+
raise ArgumentError, "verdict must be 'up' or 'down'" unless %w[up down].include?(verdict)
|
|
125
|
+
|
|
126
|
+
ts = recorded_at || Time.now.utc.iso8601
|
|
127
|
+
with_retry do
|
|
128
|
+
@db.transaction do
|
|
129
|
+
existing = moment_feedback.where(event_id: event_id).first
|
|
130
|
+
if existing
|
|
131
|
+
moment_feedback.where(id: existing[:id]).update(
|
|
132
|
+
verdict: verdict, note: note, recorded_at: ts
|
|
133
|
+
)
|
|
134
|
+
moment_feedback.where(id: existing[:id]).first
|
|
135
|
+
else
|
|
136
|
+
id = moment_feedback.insert(
|
|
137
|
+
event_id: event_id, verdict: verdict, note: note, recorded_at: ts
|
|
138
|
+
)
|
|
139
|
+
moment_feedback.where(id: id).first
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Remove the verdict for a moment, if any.
|
|
146
|
+
# @return [Integer] number of rows deleted (0 or 1)
|
|
147
|
+
def clear_moment_feedback(event_id)
|
|
148
|
+
with_retry { moment_feedback.where(event_id: event_id).delete }
|
|
149
|
+
end
|
|
150
|
+
|
|
104
151
|
# Record a single MCP tool invocation for telemetry.
|
|
105
152
|
# Inserts synchronously; callers wrap in with_retry at the call site
|
|
106
153
|
# if needed.
|
|
@@ -497,149 +544,6 @@ module ClaudeMemory
|
|
|
497
544
|
.all
|
|
498
545
|
end
|
|
499
546
|
|
|
500
|
-
# Count content items that have not yet been distilled.
|
|
501
|
-
# @param min_length [Integer] minimum byte_len threshold
|
|
502
|
-
# @return [Integer]
|
|
503
|
-
def count_undistilled(min_length: 200)
|
|
504
|
-
content_items
|
|
505
|
-
.left_join(:ingestion_metrics, content_item_id: :id)
|
|
506
|
-
.where(Sequel[:ingestion_metrics][:id] => nil)
|
|
507
|
-
.where { byte_len >= min_length }
|
|
508
|
-
.count
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
# Record token usage and extraction counts for a distillation run.
|
|
512
|
-
# @param content_item_id [Integer] content item that was distilled
|
|
513
|
-
# @param input_tokens [Integer] LLM input tokens consumed
|
|
514
|
-
# @param output_tokens [Integer] LLM output tokens consumed
|
|
515
|
-
# @param facts_extracted [Integer] number of facts extracted
|
|
516
|
-
# @return [Integer] inserted row id
|
|
517
|
-
def record_ingestion_metrics(content_item_id:, input_tokens:, output_tokens:, facts_extracted:)
|
|
518
|
-
ingestion_metrics.insert(
|
|
519
|
-
content_item_id: content_item_id,
|
|
520
|
-
input_tokens: input_tokens,
|
|
521
|
-
output_tokens: output_tokens,
|
|
522
|
-
facts_extracted: facts_extracted,
|
|
523
|
-
created_at: Time.now.utc.iso8601
|
|
524
|
-
)
|
|
525
|
-
end
|
|
526
|
-
|
|
527
|
-
# Compute aggregate ingestion metrics across all distillation runs.
|
|
528
|
-
# @return [Hash, nil] totals and efficiency ratio, or nil if no data
|
|
529
|
-
def aggregate_ingestion_metrics
|
|
530
|
-
# standard:disable Performance/Detect (Sequel DSL requires .select{}.first)
|
|
531
|
-
result = ingestion_metrics
|
|
532
|
-
.select {
|
|
533
|
-
[
|
|
534
|
-
sum(:input_tokens).as(:total_input),
|
|
535
|
-
sum(:output_tokens).as(:total_output),
|
|
536
|
-
sum(:facts_extracted).as(:total_facts),
|
|
537
|
-
count(:id).as(:total_ops)
|
|
538
|
-
]
|
|
539
|
-
}
|
|
540
|
-
.first
|
|
541
|
-
# standard:enable Performance/Detect
|
|
542
|
-
|
|
543
|
-
return nil if result.nil? || result[:total_ops].to_i.zero?
|
|
544
|
-
|
|
545
|
-
total_input = result[:total_input].to_i
|
|
546
|
-
total_output = result[:total_output].to_i
|
|
547
|
-
total_facts = result[:total_facts].to_i
|
|
548
|
-
total_ops = result[:total_ops].to_i
|
|
549
|
-
|
|
550
|
-
efficiency = total_input.zero? ? 0.0 : (total_facts.to_f / total_input * 1000).round(2)
|
|
551
|
-
|
|
552
|
-
{
|
|
553
|
-
total_input_tokens: total_input,
|
|
554
|
-
total_output_tokens: total_output,
|
|
555
|
-
total_facts_extracted: total_facts,
|
|
556
|
-
total_operations: total_ops,
|
|
557
|
-
avg_facts_per_1k_input_tokens: efficiency
|
|
558
|
-
}
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
# Mark all undistilled content items as distilled with zero token counts.
|
|
562
|
-
# Used for backfilling legacy content that predates the metrics table.
|
|
563
|
-
# @return [Integer] number of items backfilled
|
|
564
|
-
def backfill_distillation_metrics!
|
|
565
|
-
undistilled_ids = content_items
|
|
566
|
-
.left_join(:ingestion_metrics, content_item_id: :id)
|
|
567
|
-
.where(Sequel[:ingestion_metrics][:id] => nil)
|
|
568
|
-
.select_map(Sequel[:content_items][:id])
|
|
569
|
-
|
|
570
|
-
return 0 if undistilled_ids.empty?
|
|
571
|
-
|
|
572
|
-
now = Time.now.utc.iso8601
|
|
573
|
-
undistilled_ids.each do |cid|
|
|
574
|
-
ingestion_metrics.insert(
|
|
575
|
-
content_item_id: cid,
|
|
576
|
-
input_tokens: 0,
|
|
577
|
-
output_tokens: 0,
|
|
578
|
-
facts_extracted: 0,
|
|
579
|
-
created_at: now
|
|
580
|
-
)
|
|
581
|
-
end
|
|
582
|
-
|
|
583
|
-
undistilled_ids.size
|
|
584
|
-
end
|
|
585
|
-
|
|
586
|
-
# --- LLM cache ---
|
|
587
|
-
|
|
588
|
-
# Look up a cached LLM result by its cache key.
|
|
589
|
-
# @param cache_key [String] SHA-256 hex cache key
|
|
590
|
-
# @return [Hash, nil]
|
|
591
|
-
def llm_cache_lookup(cache_key)
|
|
592
|
-
llm_cache.where(cache_key: cache_key).first
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
# Store or update a cached LLM result. Uses upsert on the cache_key.
|
|
596
|
-
# @param operation [String] operation name (e.g. "distill", "embed")
|
|
597
|
-
# @param model [String] model identifier
|
|
598
|
-
# @param input_hash [String] SHA-256 hex digest of the input
|
|
599
|
-
# @param result_json [String] JSON-serialized result
|
|
600
|
-
# @param input_tokens [Integer, nil] input tokens consumed
|
|
601
|
-
# @param output_tokens [Integer, nil] output tokens consumed
|
|
602
|
-
# @return [void]
|
|
603
|
-
def llm_cache_store(operation:, model:, input_hash:, result_json:, input_tokens: nil, output_tokens: nil)
|
|
604
|
-
cache_key = Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
|
|
605
|
-
|
|
606
|
-
llm_cache
|
|
607
|
-
.insert_conflict(target: :cache_key, update: {
|
|
608
|
-
result_json: result_json,
|
|
609
|
-
input_tokens: input_tokens,
|
|
610
|
-
output_tokens: output_tokens,
|
|
611
|
-
created_at: Time.now.utc.iso8601
|
|
612
|
-
})
|
|
613
|
-
.insert(
|
|
614
|
-
cache_key: cache_key,
|
|
615
|
-
operation: operation,
|
|
616
|
-
model: model,
|
|
617
|
-
input_hash: input_hash,
|
|
618
|
-
result_json: result_json,
|
|
619
|
-
input_tokens: input_tokens,
|
|
620
|
-
output_tokens: output_tokens,
|
|
621
|
-
created_at: Time.now.utc.iso8601
|
|
622
|
-
)
|
|
623
|
-
end
|
|
624
|
-
|
|
625
|
-
# Compute the cache key for an LLM operation.
|
|
626
|
-
# @param operation [String] operation name
|
|
627
|
-
# @param model [String] model identifier
|
|
628
|
-
# @param input [String] raw input text
|
|
629
|
-
# @return [String] SHA-256 hex cache key
|
|
630
|
-
def llm_cache_key(operation, model, input)
|
|
631
|
-
input_hash = Digest::SHA256.hexdigest(input)
|
|
632
|
-
Digest::SHA256.hexdigest("#{operation}:#{model}:#{input_hash}")
|
|
633
|
-
end
|
|
634
|
-
|
|
635
|
-
# Delete LLM cache entries older than the given age.
|
|
636
|
-
# @param max_age_seconds [Integer] maximum age in seconds (default: 7 days)
|
|
637
|
-
# @return [Integer] number of rows deleted
|
|
638
|
-
def llm_cache_prune(max_age_seconds: 604_800)
|
|
639
|
-
cutoff = (Time.now - max_age_seconds).utc.iso8601
|
|
640
|
-
llm_cache.where { created_at < cutoff }.delete
|
|
641
|
-
end
|
|
642
|
-
|
|
643
547
|
# --- Meta ---
|
|
644
548
|
|
|
645
549
|
# Set a key-value pair in the meta table (upsert).
|
|
@@ -118,6 +118,35 @@ module ClaudeMemory
|
|
|
118
118
|
end
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
+
# Return the store for an explicit scope only if its database file
|
|
122
|
+
# already exists on disk. Never creates a new DB. Useful for
|
|
123
|
+
# read-only surfaces that want to avoid accidental initialization.
|
|
124
|
+
# @param scope [String] "global" or "project"
|
|
125
|
+
# @return [SQLiteStore, nil]
|
|
126
|
+
def store_if_exists(scope)
|
|
127
|
+
case scope
|
|
128
|
+
when "project"
|
|
129
|
+
return nil unless project_exists?
|
|
130
|
+
ensure_project!
|
|
131
|
+
when "global"
|
|
132
|
+
return nil unless global_exists?
|
|
133
|
+
ensure_global!
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Return whichever store is available, preferring the requested scope.
|
|
138
|
+
# Falls back to the other scope if the preferred DB doesn't exist on
|
|
139
|
+
# disk yet. Returns nil only when both DBs are missing. Intended for
|
|
140
|
+
# "best-effort" surfaces like activity logging and default dashboard
|
|
141
|
+
# reads where the caller just needs some store to talk to.
|
|
142
|
+
# @param prefer [Symbol] :project (default) or :global
|
|
143
|
+
# @return [SQLiteStore, nil]
|
|
144
|
+
def default_store(prefer: :project)
|
|
145
|
+
primary = (prefer == :global) ? "global" : "project"
|
|
146
|
+
fallback = (prefer == :global) ? "project" : "global"
|
|
147
|
+
store_if_exists(primary) || store_if_exists(fallback)
|
|
148
|
+
end
|
|
149
|
+
|
|
121
150
|
# Copy a project-scoped fact (with its entities and provenance) into the
|
|
122
151
|
# global store, making it available across all projects. Runs the global
|
|
123
152
|
# writes in a single transaction for atomicity.
|