claude_memory 0.9.1 → 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/skills/dashboard/SKILL.md +42 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +86 -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/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 +49 -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
|