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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +63 -1
  4. data/.claude/skills/dashboard/SKILL.md +42 -0
  5. data/.claude/skills/release/SKILL.md +168 -0
  6. data/.claude-plugin/marketplace.json +1 -1
  7. data/.claude-plugin/plugin.json +1 -1
  8. data/CHANGELOG.md +92 -0
  9. data/CLAUDE.md +21 -5
  10. data/README.md +32 -2
  11. data/db/migrations/015_add_activity_events.rb +26 -0
  12. data/db/migrations/016_add_moment_feedback.rb +22 -0
  13. data/db/migrations/017_add_last_recalled_at.rb +15 -0
  14. data/docs/1_0_punchlist.md +190 -0
  15. data/docs/EXAMPLES.md +41 -2
  16. data/docs/GETTING_STARTED.md +31 -4
  17. data/docs/architecture.md +22 -7
  18. data/docs/audit-queries.md +131 -0
  19. data/docs/dashboard.md +172 -0
  20. data/docs/improvements.md +465 -9
  21. data/docs/influence/cq.md +187 -0
  22. data/docs/plugin.md +13 -6
  23. data/docs/quality_review.md +489 -172
  24. data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
  25. data/lib/claude_memory/activity_log.rb +86 -0
  26. data/lib/claude_memory/commands/census_command.rb +210 -0
  27. data/lib/claude_memory/commands/completion_command.rb +3 -0
  28. data/lib/claude_memory/commands/dashboard_command.rb +54 -0
  29. data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
  30. data/lib/claude_memory/commands/digest_command.rb +181 -0
  31. data/lib/claude_memory/commands/hook_command.rb +34 -0
  32. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  33. data/lib/claude_memory/commands/registry.rb +6 -1
  34. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  35. data/lib/claude_memory/commands/stats_command.rb +38 -1
  36. data/lib/claude_memory/commands/sweep_command.rb +2 -0
  37. data/lib/claude_memory/configuration.rb +16 -0
  38. data/lib/claude_memory/core/relative_time.rb +9 -0
  39. data/lib/claude_memory/dashboard/api.rb +610 -0
  40. data/lib/claude_memory/dashboard/conflicts.rb +279 -0
  41. data/lib/claude_memory/dashboard/efficacy.rb +127 -0
  42. data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
  43. data/lib/claude_memory/dashboard/health.rb +175 -0
  44. data/lib/claude_memory/dashboard/index.html +2707 -0
  45. data/lib/claude_memory/dashboard/knowledge.rb +136 -0
  46. data/lib/claude_memory/dashboard/moments.rb +244 -0
  47. data/lib/claude_memory/dashboard/reuse.rb +97 -0
  48. data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
  49. data/lib/claude_memory/dashboard/server.rb +211 -0
  50. data/lib/claude_memory/dashboard/timeline.rb +68 -0
  51. data/lib/claude_memory/dashboard/trust.rb +285 -0
  52. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  53. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  54. data/lib/claude_memory/hook/context_injector.rb +97 -3
  55. data/lib/claude_memory/hook/handler.rb +50 -3
  56. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  57. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  58. data/lib/claude_memory/mcp/server.rb +8 -2
  59. data/lib/claude_memory/mcp/text_summary.rb +29 -0
  60. data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
  61. data/lib/claude_memory/mcp/tools.rb +148 -0
  62. data/lib/claude_memory/publish.rb +13 -21
  63. data/lib/claude_memory/recall/stale_detector.rb +67 -0
  64. data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
  65. data/lib/claude_memory/resolve/resolver.rb +41 -11
  66. data/lib/claude_memory/store/llm_cache.rb +68 -0
  67. data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
  68. data/lib/claude_memory/store/schema_manager.rb +1 -1
  69. data/lib/claude_memory/store/sqlite_store.rb +47 -143
  70. data/lib/claude_memory/store/store_manager.rb +29 -0
  71. data/lib/claude_memory/sweep/maintenance.rb +216 -0
  72. data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
  73. data/lib/claude_memory/sweep/sweeper.rb +2 -0
  74. data/lib/claude_memory/version.rb +1 -1
  75. data/lib/claude_memory.rb +22 -0
  76. metadata +50 -1
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "json"
5
+
6
+ module ClaudeMemory
7
+ module Dashboard
8
+ class Server
9
+ DEFAULT_PORT = 3377
10
+
11
+ def initialize(manager:, port: DEFAULT_PORT, open_browser: true)
12
+ @manager = manager
13
+ @port = port
14
+ @open_browser = open_browser
15
+ @server = nil
16
+ end
17
+
18
+ def start
19
+ @server = WEBrick::HTTPServer.new(
20
+ Port: @port,
21
+ Logger: WEBrick::Log.new(File::NULL),
22
+ AccessLog: []
23
+ )
24
+
25
+ mount_routes
26
+
27
+ trap("INT") { @server.shutdown }
28
+ trap("TERM") { @server.shutdown }
29
+
30
+ open_browser if @open_browser
31
+ @server.start
32
+ end
33
+
34
+ def stop
35
+ @server&.shutdown
36
+ end
37
+
38
+ private
39
+
40
+ def mount_routes
41
+ api = API.new(@manager)
42
+
43
+ @server.mount_proc("/") { |_req, res| serve_html(res) }
44
+ @server.mount_proc("/api/health") { |_req, res| with_fresh_connections { json_response(res, api.health) } }
45
+ @server.mount_proc("/api/stats") { |_req, res| with_fresh_connections { json_response(res, api.stats) } }
46
+ @server.mount_proc("/api/activity") { |req, res|
47
+ with_fresh_connections {
48
+ if (id = activity_id_from_path(req.path))
49
+ json_response(res, api.activity_detail(id))
50
+ else
51
+ json_response(res, api.activity(req.query))
52
+ end
53
+ }
54
+ }
55
+ @server.mount_proc("/api/facts") { |req, res| with_fresh_connections { handle_facts(api, req, res) } }
56
+ @server.mount_proc("/api/efficacy") { |req, res| with_fresh_connections { json_response(res, api.efficacy(req.query)) } }
57
+ @server.mount_proc("/api/session") { |req, res|
58
+ with_fresh_connections {
59
+ session_id = req.query["session_id"]
60
+ json_response(res, api.session_summary(session_id))
61
+ }
62
+ }
63
+ @server.mount_proc("/api/timeline") { |_req, res| with_fresh_connections { json_response(res, api.timeline) } }
64
+ @server.mount_proc("/api/recall") { |req, res| with_fresh_connections { json_response(res, api.recall(req.query)) } }
65
+ @server.mount_proc("/api/conflicts") { |req, res| with_fresh_connections { handle_conflicts(api, req, res) } }
66
+ @server.mount_proc("/api/moments") { |req, res| with_fresh_connections { handle_moments(api, req, res) } }
67
+ @server.mount_proc("/api/trust") { |_req, res| with_fresh_connections { json_response(res, api.trust) } }
68
+ @server.mount_proc("/api/knowledge") { |req, res| with_fresh_connections { json_response(res, api.knowledge(req.query)) } }
69
+ @server.mount_proc("/api/reuse") { |req, res| with_fresh_connections { json_response(res, api.reuse(req.query)) } }
70
+ end
71
+
72
+ # WAL-mode SQLite caches pages on reader connections; when the MCP
73
+ # server (or hooks, or any other writer) modifies the same DB
74
+ # concurrently, long-lived dashboard connections can see stale pages
75
+ # and surface "database disk image is malformed" errors even though
76
+ # PRAGMA integrity_check reports ok. Releasing connections after each
77
+ # HTTP request forces a fresh connection on the next read, matching
78
+ # what MCP::Server#release_connections does per tool call.
79
+ def with_fresh_connections
80
+ yield
81
+ ensure
82
+ release_connections
83
+ end
84
+
85
+ def release_connections
86
+ return unless @manager
87
+ @manager.global_store&.db&.disconnect
88
+ @manager.project_store&.db&.disconnect
89
+ rescue Sequel::DatabaseError, Extralite::Error
90
+ # Best-effort; next call will reopen.
91
+ end
92
+
93
+ def handle_moments(api, req, res)
94
+ feedback_id = moment_feedback_id_from_path(req.path)
95
+
96
+ if feedback_id && req.request_method == "POST"
97
+ body = parse_json_body(req)
98
+ json_response(res, api.moment_feedback(feedback_id, verdict: body["verdict"], note: body["note"]))
99
+ elsif feedback_id && req.request_method == "DELETE"
100
+ json_response(res, api.clear_moment_feedback(feedback_id))
101
+ else
102
+ json_response(res, api.moments(req.query))
103
+ end
104
+ end
105
+
106
+ def moment_feedback_id_from_path(path)
107
+ match = path.match(%r{\A/api/moments/(\d+)/feedback\z})
108
+ match && match[1]
109
+ end
110
+
111
+ def handle_conflicts(api, req, res)
112
+ reject_id = conflict_reject_id_from_path(req.path)
113
+ detail_id = conflict_id_from_path(req.path)
114
+ is_reject_similar = req.path == "/api/conflicts/reject_similar"
115
+
116
+ if req.request_method == "POST" && is_reject_similar
117
+ body = parse_json_body(req)
118
+ keeper_id = body["keeper_fact_id"]
119
+ reason = body["reason"]
120
+ scope = body["scope"] || req.query["scope"] || "project"
121
+ json_response(res, api.reject_similar_conflicts(keeper_id, reason: reason, scope: scope))
122
+ elsif req.request_method == "POST" && reject_id
123
+ body = parse_json_body(req)
124
+ side = body["side"]
125
+ reason = body["reason"]
126
+ scope = body["scope"] || req.query["scope"] || "project"
127
+ json_response(res, api.reject_conflict_fact(reject_id, side: side, reason: reason, scope: scope))
128
+ elsif detail_id
129
+ scope = req.query["scope"] || "project"
130
+ json_response(res, api.conflict_detail(detail_id, scope))
131
+ else
132
+ json_response(res, api.conflicts(req.query))
133
+ end
134
+ end
135
+
136
+ def parse_json_body(req)
137
+ return {} if req.body.nil? || req.body.empty?
138
+ JSON.parse(req.body)
139
+ rescue JSON::ParserError
140
+ {}
141
+ end
142
+
143
+ def serve_html(res)
144
+ html_path = File.expand_path("index.html", __dir__)
145
+ res["Content-Type"] = "text/html; charset=utf-8"
146
+ res.body = File.read(html_path)
147
+ end
148
+
149
+ def activity_id_from_path(path)
150
+ match = path.match(%r{\A/api/activity/(\d+)\z})
151
+ match && match[1]
152
+ end
153
+
154
+ def fact_id_from_path(path)
155
+ match = path.match(%r{\A/api/facts/(\d+)\z})
156
+ match && match[1]
157
+ end
158
+
159
+ def fact_action_from_path(path)
160
+ match = path.match(%r{\A/api/facts/(\d+)/(reject|promote)\z})
161
+ match ? [match[1], match[2]] : nil
162
+ end
163
+
164
+ def handle_facts(api, req, res)
165
+ action = fact_action_from_path(req.path)
166
+ detail_id = fact_id_from_path(req.path)
167
+
168
+ if req.request_method == "POST" && action
169
+ fact_id, verb = action
170
+ body = parse_json_body(req)
171
+ scope = body["scope"] || req.query["scope"] || "project"
172
+ case verb
173
+ when "reject"
174
+ json_response(res, api.reject_fact(fact_id, reason: body["reason"], scope: scope))
175
+ when "promote"
176
+ json_response(res, api.promote_fact(fact_id))
177
+ end
178
+ elsif detail_id
179
+ scope = req.query["scope"] || "project"
180
+ json_response(res, api.fact_detail(detail_id, scope))
181
+ else
182
+ json_response(res, api.facts(req.query))
183
+ end
184
+ end
185
+
186
+ def conflict_id_from_path(path)
187
+ match = path.match(%r{\A/api/conflicts/(\d+)\z})
188
+ match && match[1]
189
+ end
190
+
191
+ def conflict_reject_id_from_path(path)
192
+ match = path.match(%r{\A/api/conflicts/(\d+)/reject\z})
193
+ match && match[1]
194
+ end
195
+
196
+ def json_response(res, data)
197
+ res["Content-Type"] = "application/json; charset=utf-8"
198
+ res["Access-Control-Allow-Origin"] = "*"
199
+ res.body = JSON.generate(data)
200
+ end
201
+
202
+ def open_browser
203
+ url = "http://localhost:#{@port}"
204
+ Thread.new do
205
+ sleep 0.5
206
+ system("open", url) || system("xdg-open", url) || system("start", url)
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Dashboard
5
+ # Daily activity rollup for the dashboard timeline view. Aggregates three
6
+ # event sources (fact creation, content ingestion, activity events) into
7
+ # per-day buckets covering the last 30 days. Returns the empty shape
8
+ # ({days: []}) when no project store is available so the dashboard can
9
+ # render before the first ingest.
10
+ class Timeline
11
+ LOOKBACK_DAYS = 30
12
+
13
+ def initialize(manager)
14
+ @manager = manager
15
+ end
16
+
17
+ def days
18
+ store = @manager.default_store(prefer: :project)
19
+ return {days: []} unless store
20
+
21
+ cutoff = (Time.now - LOOKBACK_DAYS * 86_400).utc.iso8601
22
+ {days: build_days(store, cutoff)}
23
+ end
24
+
25
+ private
26
+
27
+ def build_days(store, cutoff)
28
+ fact_rows = group_count(store.facts, cutoff_field: :created_at, cutoff: cutoff)
29
+ content_rows = group_count(store.content_items, cutoff_field: :ingested_at, cutoff: cutoff)
30
+ event_rows = activity_event_rows(store, cutoff)
31
+
32
+ all_days = (fact_rows + content_rows + event_rows).map { |r| r[:day] }.uniq.sort
33
+ all_days.map { |day| compose_day(day, fact_rows, content_rows, event_rows) }
34
+ end
35
+
36
+ def group_count(dataset, cutoff_field:, cutoff:)
37
+ dataset
38
+ .where { Sequel[cutoff_field] >= cutoff }
39
+ .select_group(Sequel.lit("DATE(#{cutoff_field})").as(:day))
40
+ .select_append { count(id).as(:count) }
41
+ .order(:day)
42
+ .all
43
+ end
44
+
45
+ def activity_event_rows(store, cutoff)
46
+ return [] unless store.db.table_exists?(:activity_events)
47
+
48
+ store.activity_events
49
+ .where { occurred_at >= cutoff }
50
+ .select_group(Sequel.lit("DATE(occurred_at)").as(:day), :event_type)
51
+ .select_append { count(id).as(:count) }
52
+ .order(:day)
53
+ .all
54
+ end
55
+
56
+ def compose_day(day, fact_rows, content_rows, event_rows)
57
+ day_events = event_rows.select { |r| r[:day] == day }
58
+ {
59
+ date: day,
60
+ facts_created: fact_rows.find { |r| r[:day] == day }&.dig(:count) || 0,
61
+ content_ingested: content_rows.find { |r| r[:day] == day }&.dig(:count) || 0,
62
+ hook_events: day_events.sum { |r| r[:count] },
63
+ recalls: day_events.select { |r| r[:event_type] == "recall" }.sum { |r| r[:count] }
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Dashboard
5
+ # Sidebar data for the feed-first dashboard. Three things:
6
+ #
7
+ # 1. Moments this week + week-over-week delta — the headline value number.
8
+ # A moment is any meaningful activity event (recall hit, extraction,
9
+ # context injection, conflict detected). Ingest-only events don't count
10
+ # because they're not directly user-visible value.
11
+ #
12
+ # 2. "What memory knows about you" — up to 5 global facts rendered as
13
+ # plain English. This is the trust panel's most compelling surface:
14
+ # users can sanity-check what's being injected into their sessions.
15
+ #
16
+ # 3. Needs review — open conflicts plus facts that have gone stale
17
+ # (active but never recalled in the last N days). A single actionable
18
+ # count; the feed surfaces the individual items.
19
+ class Trust
20
+ WEEK_SECONDS = 7 * 86_400
21
+ UTILIZATION_DAYS = 30
22
+ VALUE_EVENT_TYPES = %w[hook_context recall store_extraction].freeze
23
+
24
+ def initialize(manager)
25
+ @manager = manager
26
+ end
27
+
28
+ def snapshot
29
+ {
30
+ weekly_moments: weekly_moments,
31
+ fingerprint: fingerprint,
32
+ needs_review: needs_review,
33
+ utilization: utilization,
34
+ feedback: feedback_summary
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def weekly_moments
41
+ store = @manager.default_store(prefer: :project)
42
+ return {this_week: 0, last_week: 0, delta: 0, by_kind: {}} unless store
43
+
44
+ now = Time.now.utc
45
+ this_week_since = (now - WEEK_SECONDS).iso8601
46
+ last_week_since = (now - 2 * WEEK_SECONDS).iso8601
47
+
48
+ this_rows = valuable_events(store, this_week_since)
49
+ last_rows = valuable_events(store, last_week_since, before: this_week_since)
50
+
51
+ by_kind = this_rows.group_by { |r| r[:event_type] }.transform_values(&:size)
52
+
53
+ {
54
+ this_week: this_rows.size,
55
+ last_week: last_rows.size,
56
+ delta: this_rows.size - last_rows.size,
57
+ by_kind: by_kind
58
+ }
59
+ rescue Sequel::DatabaseError => e
60
+ ClaudeMemory.logger.debug("Trust#weekly_moments failed: #{e.message}")
61
+ {this_week: 0, last_week: 0, delta: 0, by_kind: {}}
62
+ end
63
+
64
+ def valuable_events(store, since, before: nil)
65
+ dataset = store.activity_events
66
+ .where(event_type: VALUE_EVENT_TYPES)
67
+ .where(status: "success")
68
+ .where { occurred_at >= since }
69
+ dataset = dataset.where { occurred_at < before } if before
70
+ dataset.all
71
+ end
72
+
73
+ # Up to 5 global facts rendered as plain-English sentences so a skeptical
74
+ # user can verify at-a-glance what's being injected into their Claude
75
+ # sessions. Prefers high-signal predicates (convention, decision,
76
+ # uses_framework, uses_database) and falls back to most-recent active.
77
+ def fingerprint
78
+ store = @manager.store_if_exists("global")
79
+ return [] unless store
80
+
81
+ preferred_predicates = %w[convention decision uses_framework uses_database uses_language]
82
+ rows = store.facts
83
+ .where(status: "active", scope: "global")
84
+ .where(predicate: preferred_predicates)
85
+ .order(Sequel.desc(:confidence), Sequel.desc(:created_at))
86
+ .limit(5)
87
+ .all
88
+
89
+ if rows.size < 5
90
+ extra = store.facts
91
+ .where(status: "active", scope: "global")
92
+ .exclude(id: rows.map { |r| r[:id] })
93
+ .order(Sequel.desc(:created_at))
94
+ .limit(5 - rows.size)
95
+ .all
96
+ rows += extra
97
+ end
98
+
99
+ presenter = FactPresenter.new(store)
100
+ presenter.list_summary(rows).map { |f| render_sentence(f) }
101
+ rescue Sequel::DatabaseError => e
102
+ ClaudeMemory.logger.debug("Trust#fingerprint failed: #{e.message}")
103
+ []
104
+ end
105
+
106
+ def render_sentence(fact)
107
+ predicate = fact[:predicate]
108
+ object = fact[:object]
109
+ subject = fact[:subject]
110
+
111
+ sentence = case predicate
112
+ when "convention"
113
+ object
114
+ when "decision"
115
+ object
116
+ when "uses_framework", "uses_language"
117
+ "Uses #{object}"
118
+ when "uses_database"
119
+ "Uses #{object} for storage"
120
+ when "deployment_platform"
121
+ "Deploys to #{object}"
122
+ when "auth_method"
123
+ "Auth via #{object}"
124
+ else
125
+ "#{subject} #{predicate.tr("_", " ")} #{object}"
126
+ end
127
+
128
+ {
129
+ id: fact[:id],
130
+ docid: fact[:docid],
131
+ sentence: sentence.to_s.strip,
132
+ predicate: predicate,
133
+ confidence: fact[:confidence]
134
+ }
135
+ end
136
+
137
+ def needs_review
138
+ {
139
+ open_conflicts: count_open_conflicts,
140
+ stale_facts: count_stale_facts,
141
+ empty_recalls: count_empty_recalls
142
+ }
143
+ end
144
+
145
+ def count_open_conflicts
146
+ Conflicts.new(@manager).distinct_open_counts
147
+ rescue Sequel::DatabaseError
148
+ {project: 0, global: 0, total: 0}
149
+ end
150
+
151
+ # User-supplied thumbs on feed moments. The ratio answers "when Claude
152
+ # surfaces something from memory, is the user signaling it was helpful?"
153
+ # Only moments recorded in the last UTILIZATION_DAYS count toward the
154
+ # ratio so old clicks don't distort an active week's signal.
155
+ #
156
+ # Shape: {up: Int, down: Int, net: Int, ratio_pct: Int, window_days: Int}
157
+ # ratio_pct = up / (up + down) × 100, or nil when there's no feedback.
158
+ def feedback_summary
159
+ store = @manager.default_store(prefer: :project)
160
+ return feedback_zero unless store
161
+
162
+ cutoff = (Time.now.utc - UTILIZATION_DAYS * 86_400).iso8601
163
+ rows = store.moment_feedback.where { recorded_at >= cutoff }.all
164
+ up = rows.count { |r| r[:verdict] == "up" }
165
+ down = rows.count { |r| r[:verdict] == "down" }
166
+ total = up + down
167
+ ratio_pct = total.zero? ? nil : ((up.to_f / total) * 100).round
168
+
169
+ {up: up, down: down, net: up - down, ratio_pct: ratio_pct, window_days: UTILIZATION_DAYS}
170
+ rescue Sequel::DatabaseError
171
+ feedback_zero
172
+ end
173
+
174
+ def feedback_zero
175
+ {up: 0, down: 0, net: 0, ratio_pct: nil, window_days: UTILIZATION_DAYS}
176
+ end
177
+
178
+ # "Stale" = active facts whose last_recalled_at is older than the
179
+ # configured threshold (or never set, with a grace window so freshly
180
+ # extracted facts don't show up as stale on day one).
181
+ #
182
+ # Backed by Recall::StaleDetector, which reads the column populated by
183
+ # Sweep::RecallTimestampRefresher. Replaces the older "active facts
184
+ # minus seen-in-recalls" approximation, which couldn't distinguish a
185
+ # never-touched 6-month-old fact from a freshly stored one.
186
+ def count_stale_facts
187
+ threshold = Configuration.new.stale_days
188
+ Recall::StaleDetector.stale_count(@manager, threshold_days: threshold)
189
+ rescue Sequel::DatabaseError, JSON::ParserError => e
190
+ ClaudeMemory.logger.debug("Trust#count_stale_facts failed: #{e.message}")
191
+ 0
192
+ end
193
+
194
+ # The ROI signal: of the facts Claude has extracted into memory over the
195
+ # last UTILIZATION_DAYS, how many has Claude actually *used* (appeared
196
+ # in any recall or context injection's top_fact_ids)? Low ratios are
197
+ # themselves a signal — it means memory is accumulating knowledge but
198
+ # Claude isn't reaching for it. Anomalies worth surfacing honestly.
199
+ #
200
+ # Shape: {extracted: Int, used: Int, ratio_pct: Int, window_days: Int}
201
+ # Both counts are scope-union (project + global) so the headline number
202
+ # reflects everything memory did, not just one store.
203
+ def utilization
204
+ cutoff = (Time.now.utc - UTILIZATION_DAYS * 86_400).iso8601
205
+ extracted_pairs = extracted_fact_pairs(cutoff)
206
+ used_pairs = used_fact_pairs(cutoff)
207
+
208
+ extracted = extracted_pairs.size
209
+ # "Used" counted against the extracted set — a fact used but not
210
+ # extracted in this window (taught earlier, used now) is still
211
+ # re-use worth recognizing; count it too.
212
+ used_from_extracted = (used_pairs & extracted_pairs).size
213
+ used_total = used_pairs.size
214
+
215
+ ratio_pct = extracted.zero? ? 0 : ((used_from_extracted.to_f / extracted) * 100).round
216
+
217
+ {
218
+ extracted: extracted,
219
+ used: used_total,
220
+ used_from_extracted: used_from_extracted,
221
+ ratio_pct: ratio_pct,
222
+ window_days: UTILIZATION_DAYS
223
+ }
224
+ rescue Sequel::DatabaseError, JSON::ParserError => e
225
+ ClaudeMemory.logger.debug("Trust#utilization failed: #{e.message}")
226
+ {extracted: 0, used: 0, used_from_extracted: 0, ratio_pct: 0, window_days: UTILIZATION_DAYS}
227
+ end
228
+ public :utilization
229
+
230
+ # Facts that were extracted (distilled + stored) within the window.
231
+ # Returns (scope, id) pairs across both stores.
232
+ def extracted_fact_pairs(cutoff)
233
+ pairs = Set.new
234
+ %w[project global].each do |scope|
235
+ store = @manager.store_if_exists(scope)
236
+ next unless store
237
+ store.facts
238
+ .where(status: "active")
239
+ .where { created_at >= cutoff }
240
+ .select(:id)
241
+ .all
242
+ .each { |r| pairs << [scope, r[:id]] }
243
+ end
244
+ pairs
245
+ end
246
+
247
+ # Facts that appeared as top_fact_ids in any recall or context injection
248
+ # within the window. Returns (scope, id) pairs.
249
+ def used_fact_pairs(cutoff)
250
+ store = @manager.default_store(prefer: :project)
251
+ return Set.new unless store
252
+ pairs = Set.new
253
+ store.activity_events
254
+ .where(event_type: %w[recall hook_context], status: "success")
255
+ .where { occurred_at >= cutoff }
256
+ .select(:detail_json)
257
+ .all
258
+ .each do |row|
259
+ details = row[:detail_json] ? JSON.parse(row[:detail_json]) : {}
260
+ scoped = ScopedFactResolver.scoped_ids_from_details(details)
261
+ ScopedFactResolver.flat_pairs(scoped).each { |pair| pairs << pair }
262
+ end
263
+ pairs
264
+ end
265
+
266
+ def count_empty_recalls
267
+ store = @manager.default_store(prefer: :project)
268
+ return 0 unless store
269
+
270
+ cutoff = (Time.now.utc - WEEK_SECONDS).iso8601
271
+ store.activity_events
272
+ .where(event_type: "recall")
273
+ .where(status: "success")
274
+ .where { occurred_at >= cutoff }
275
+ .all
276
+ .count do |row|
277
+ details = row[:detail_json] ? JSON.parse(row[:detail_json]) : {}
278
+ (details["result_count"] || 0).zero?
279
+ end
280
+ rescue Sequel::DatabaseError, JSON::ParserError
281
+ 0
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Distill
5
+ # Guards against the LLM distiller mislabeling reference material as
6
+ # `convention`. Audited in production data on 2026-04-24: project facts
7
+ # labeled `predicate=convention` with objects like "Cloud-backed Claude
8
+ # Code plugin (~1,195 LOC JavaScript) using Supermemory API…" and
9
+ # "Claude Code plugin with marketplace.json, 5,700+ stars, by Tobi Lütke."
10
+ # These are descriptions of external projects, not conventions the user
11
+ # applies. Leaving them under `convention` pollutes the Knowledge-base
12
+ # sidebar and the `memory.conventions` MCP tool.
13
+ #
14
+ # Heuristic: only conventions are re-examined (decisions and architecture
15
+ # notes about external projects are legitimately those predicates). A
16
+ # convention is retagged to `reference` when its object text matches any
17
+ # of the descriptive patterns below. Kept deliberately conservative —
18
+ # false-positive retagging is worse than occasionally missing a case, so
19
+ # the patterns target telltale numeric/attribution phrases that rarely
20
+ # appear in real conventions.
21
+ class ReferenceMaterialDetector
22
+ # Strong signals — any one of these on its own justifies reclassification.
23
+ # Kept tight to avoid false positives on real conventions that happen
24
+ # to quote external project names.
25
+ STRONG_PATTERNS = [
26
+ # Line-of-code counts: "~1,195 LOC", "1200 lines of code"
27
+ /~?\d+[,.]?\d*\s*(?:LOC|lines of code)/i,
28
+ # Star counts: "5,700+ stars", "3.2k stars"
29
+ /\d[\d,.]*\+?\s*(?:k\s+)?stars?\b/i,
30
+ # "X is a (plugin|library|tool|gem|service|framework|extension) …"
31
+ /\b(?:is\s+an?|are)\s+(?:cloud-backed\s+)?(?:plugin|library|tool|gem|service|framework|extension|cli|mcp\s+server)\b/i,
32
+ # Leading descriptor: "Plugin that…", "Library for…"
33
+ /\A(?:cloud-backed\s+)?(?:plugin|library|tool|gem|service|framework|extension|cli|mcp\s+server)(?:\s+(?:with|using|for|that))/i
34
+ ].freeze
35
+
36
+ # Weak signals — only fire in combination with a strong signal.
37
+ # Author attribution ("by Jane Doe") was originally a standalone
38
+ # trigger, but production text like "MCP launched by Claude Code run
39
+ # from PATH" contains the same surface pattern inside a legitimate
40
+ # convention. Requiring a co-occurring strong signal keeps the guard
41
+ # conservative.
42
+ WEAK_PATTERNS = [
43
+ /\bby\s+[[:upper:]][[:alpha:]'-]+\s+[[:upper:]][[:alpha:]'-]+/
44
+ ].freeze
45
+
46
+ # Predicates we inspect. Decisions stay decisions even when they cite
47
+ # external projects ("From QMD restudy: adopt X"); the guard targets
48
+ # only `convention`, where misclassification is most common.
49
+ GUARDED_PREDICATES = %w[convention].freeze
50
+
51
+ def reclassify(extraction)
52
+ return extraction if extraction.facts.nil? || extraction.facts.empty?
53
+
54
+ new_facts = extraction.facts.map do |fact|
55
+ if reference_material?(fact)
56
+ fact.merge(predicate: "reference")
57
+ else
58
+ fact
59
+ end
60
+ end
61
+
62
+ Distill::Extraction.new(
63
+ entities: extraction.entities,
64
+ facts: new_facts,
65
+ decisions: extraction.decisions,
66
+ signals: extraction.signals
67
+ )
68
+ end
69
+
70
+ def reference_material?(fact)
71
+ return false unless GUARDED_PREDICATES.include?(fact[:predicate].to_s)
72
+ object = fact[:object].to_s
73
+ return false if object.empty?
74
+ STRONG_PATTERNS.any? { |re| object.match?(re) }
75
+ end
76
+ end
77
+ end
78
+ end