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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Groups active facts into the categories a human cares about:
|
|
6
|
+
# decisions, conventions/principles, quality guards, architecture, and
|
|
7
|
+
# hard constraints. This is the bridge between internal predicate
|
|
8
|
+
# vocabulary and the value categories the user expects to see —
|
|
9
|
+
# "what decisions has Claude learned?" not "show me facts where
|
|
10
|
+
# predicate='decision'".
|
|
11
|
+
#
|
|
12
|
+
# Quality guards are a heuristic split inside the conventions section:
|
|
13
|
+
# convention-predicate facts whose object text starts with a prohibitive
|
|
14
|
+
# or imperative ("Never", "Always", "Must", "Do not", "Don't"). These
|
|
15
|
+
# are the rules that catch mistakes, not just describe preferences.
|
|
16
|
+
class Knowledge
|
|
17
|
+
QUALITY_GUARD_RE = /\A\s*(never|always|must|do not|don't)\b/i
|
|
18
|
+
|
|
19
|
+
# Order matches how they appear in the UI — decisions first (highest
|
|
20
|
+
# signal to a skeptical reader), references last (study notes about
|
|
21
|
+
# external projects, kept distinct from conventions the user applies).
|
|
22
|
+
SECTIONS = [
|
|
23
|
+
{key: :decisions, label: "Decisions", description: "Explicit choices with a reason"},
|
|
24
|
+
{key: :quality_guards, label: "Quality guards", description: "Rules that prevent mistakes"},
|
|
25
|
+
{key: :conventions, label: "Conventions & principles", description: "Style, patterns, preferences"},
|
|
26
|
+
{key: :architecture, label: "Architecture", description: "Structural knowledge"},
|
|
27
|
+
{key: :constraints, label: "Constraints", description: "Hard tech-stack facts"},
|
|
28
|
+
{key: :references, label: "References", description: "Study notes about external projects"}
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
TOP_PER_SECTION = 6
|
|
32
|
+
|
|
33
|
+
def initialize(manager)
|
|
34
|
+
@manager = manager
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param params [Hash]
|
|
38
|
+
# "scope" — "project" (default), "global", or "all"
|
|
39
|
+
# "limit" — max facts returned per section (default 6)
|
|
40
|
+
# "section" — when set, returns *all* facts in that section (for the
|
|
41
|
+
# drawer "browse" view) instead of top N per section
|
|
42
|
+
def summary(params = {})
|
|
43
|
+
scope = params["scope"] || "all"
|
|
44
|
+
limit = (params["limit"] || TOP_PER_SECTION).to_i
|
|
45
|
+
section_filter = params["section"]&.to_sym
|
|
46
|
+
|
|
47
|
+
rows = collect_rows(scope)
|
|
48
|
+
sections = SECTIONS.map do |meta|
|
|
49
|
+
all_in_section = rows.select { |r| classify_row(r[:fact]) == meta[:key] }
|
|
50
|
+
shown = section_filter ? all_in_section : all_in_section.first(limit)
|
|
51
|
+
{
|
|
52
|
+
key: meta[:key],
|
|
53
|
+
label: meta[:label],
|
|
54
|
+
description: meta[:description],
|
|
55
|
+
count: all_in_section.size,
|
|
56
|
+
facts: shown.map { |r| r[:presented] }
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if section_filter
|
|
61
|
+
sections = sections.select { |s| s[:key] == section_filter }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
scope: scope,
|
|
66
|
+
section: section_filter,
|
|
67
|
+
totals: {
|
|
68
|
+
project: count_for_scope("project"),
|
|
69
|
+
global: count_for_scope("global")
|
|
70
|
+
},
|
|
71
|
+
sections: sections
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def count_for_scope(scope)
|
|
78
|
+
store = @manager.store_if_exists(scope)
|
|
79
|
+
return 0 unless store
|
|
80
|
+
store.facts.where(status: "active").count
|
|
81
|
+
rescue Sequel::DatabaseError
|
|
82
|
+
0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def collect_rows(scope)
|
|
86
|
+
stores = stores_for(scope)
|
|
87
|
+
stores.flat_map do |source, store|
|
|
88
|
+
rows = store.facts.where(status: "active").order(Sequel.desc(:confidence), Sequel.desc(:created_at)).all
|
|
89
|
+
presenter = FactPresenter.new(store)
|
|
90
|
+
presented = presenter.list_summary(rows)
|
|
91
|
+
rows.zip(presented).map do |raw, p|
|
|
92
|
+
{fact: raw, presented: p.merge(source: source)}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stores_for(scope)
|
|
98
|
+
case scope
|
|
99
|
+
when "project"
|
|
100
|
+
{"project" => @manager.store_if_exists("project")}.compact
|
|
101
|
+
when "global"
|
|
102
|
+
{"global" => @manager.store_if_exists("global")}.compact
|
|
103
|
+
else
|
|
104
|
+
{
|
|
105
|
+
"project" => @manager.store_if_exists("project"),
|
|
106
|
+
"global" => @manager.store_if_exists("global")
|
|
107
|
+
}.compact
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Maps a raw facts row to one of the SECTIONS keys. Uses PredicatePolicy
|
|
112
|
+
# for the base section, then overlays the quality-guard heuristic on
|
|
113
|
+
# convention facts.
|
|
114
|
+
def classify_row(fact_row)
|
|
115
|
+
predicate = fact_row[:predicate]
|
|
116
|
+
object = fact_row[:object_literal].to_s
|
|
117
|
+
|
|
118
|
+
base = Resolve::PredicatePolicy.section_for(predicate)
|
|
119
|
+
case base
|
|
120
|
+
when :decisions
|
|
121
|
+
:decisions
|
|
122
|
+
when :conventions
|
|
123
|
+
object.match?(QUALITY_GUARD_RE) ? :quality_guards : :conventions
|
|
124
|
+
when :constraints
|
|
125
|
+
:constraints
|
|
126
|
+
when :references
|
|
127
|
+
:references
|
|
128
|
+
else
|
|
129
|
+
# :additional — architecture predicate lands here; split it out
|
|
130
|
+
# explicitly since users reason about it differently.
|
|
131
|
+
(predicate == "architecture") ? :architecture : :conventions
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Dashboard
|
|
7
|
+
# Turns the flat activity_events log into enriched "moments" — the user-visible
|
|
8
|
+
# primitive for the feed-first dashboard. Each moment inlines the data needed
|
|
9
|
+
# to render its card (content preview, linked facts, resolved top_fact_ids)
|
|
10
|
+
# so the client never needs a second round trip per row.
|
|
11
|
+
#
|
|
12
|
+
# A moment's {:kind} is a stable narrative category the client uses to pick
|
|
13
|
+
# a card renderer. It's derived from event_type + status so the client
|
|
14
|
+
# doesn't have to re-derive the same mapping.
|
|
15
|
+
class Moments
|
|
16
|
+
DEFAULT_LIMIT = 50
|
|
17
|
+
CONTENT_PREVIEW_BYTES = 800
|
|
18
|
+
FEED_EVENT_TYPES = %w[hook_context recall store_extraction hook_ingest hook_sweep].freeze
|
|
19
|
+
|
|
20
|
+
# Kind → underlying event_type(s). Used to pull only relevant rows from
|
|
21
|
+
# the DB when the caller specifies kinds; without this, a noisy stream
|
|
22
|
+
# of ingests pushes the value moments past the query limit.
|
|
23
|
+
KIND_TO_EVENT_TYPES = {
|
|
24
|
+
"context_injection" => %w[hook_context],
|
|
25
|
+
"context_skipped" => %w[hook_context],
|
|
26
|
+
"recall_hit" => %w[recall],
|
|
27
|
+
"recall_empty" => %w[recall],
|
|
28
|
+
"extraction" => %w[store_extraction],
|
|
29
|
+
"ingest" => %w[hook_ingest],
|
|
30
|
+
"ingest_skipped" => %w[hook_ingest],
|
|
31
|
+
"sweep" => %w[hook_sweep]
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(manager)
|
|
35
|
+
@manager = manager
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param params [Hash]
|
|
39
|
+
# "limit" — max moments (default 50, clamped 1..200)
|
|
40
|
+
# "before" — ISO 8601 cursor; return moments strictly older than this
|
|
41
|
+
# "kinds" — comma-separated kinds to include (default: all feed kinds)
|
|
42
|
+
def list(params = {})
|
|
43
|
+
store = default_store
|
|
44
|
+
return empty_response unless store
|
|
45
|
+
|
|
46
|
+
limit = (params["limit"] || DEFAULT_LIMIT).to_i.clamp(1, 200)
|
|
47
|
+
before = params["before"]
|
|
48
|
+
kinds = parse_kinds(params["kinds"])
|
|
49
|
+
|
|
50
|
+
event_types = resolve_event_types(kinds)
|
|
51
|
+
dataset = store.activity_events
|
|
52
|
+
.where(event_type: event_types)
|
|
53
|
+
.order(Sequel.desc(:occurred_at))
|
|
54
|
+
dataset = dataset.where { occurred_at < before } if before && !before.empty?
|
|
55
|
+
|
|
56
|
+
# Fetch up to 2x limit so per-kind filtering still produces a full
|
|
57
|
+
# page (e.g. recall_hit vs recall_empty both live under event_type=recall).
|
|
58
|
+
rows = dataset.limit(limit * 2).all
|
|
59
|
+
events = rows.map { |r|
|
|
60
|
+
r[:details] = r[:detail_json] ? JSON.parse(r[:detail_json], symbolize_names: true) : nil
|
|
61
|
+
r.delete(:detail_json)
|
|
62
|
+
r
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
moments = events.map { |e| build_moment(store, e) }
|
|
66
|
+
moments = moments.select { |m| kinds.include?(m[:kind]) } unless kinds.empty?
|
|
67
|
+
has_more = moments.size > limit
|
|
68
|
+
moments = moments.first(limit)
|
|
69
|
+
attach_feedback(store, moments)
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
moments: moments,
|
|
73
|
+
next_before: moments.last&.dig(:occurred_at),
|
|
74
|
+
has_more: has_more
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# When no kinds are specified, pull all feed event types. When kinds
|
|
81
|
+
# are specified, union the event_types they map to so the DB query
|
|
82
|
+
# only loads the relevant rows.
|
|
83
|
+
def resolve_event_types(kinds)
|
|
84
|
+
return FEED_EVENT_TYPES if kinds.empty?
|
|
85
|
+
kinds.flat_map { |k| KIND_TO_EVENT_TYPES.fetch(k, []) }.uniq
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def empty_response
|
|
89
|
+
{moments: [], next_before: nil, has_more: false}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_kinds(raw)
|
|
93
|
+
return [] if raw.nil? || raw.empty?
|
|
94
|
+
raw.split(",").map(&:strip).reject(&:empty?)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def default_store
|
|
98
|
+
@manager.default_store(prefer: :project)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Stable narrative kinds drive card rendering on the client. Keep this
|
|
102
|
+
# map small; any edge case becomes "event" with the raw detail exposed.
|
|
103
|
+
def kind_for(event)
|
|
104
|
+
case event[:event_type]
|
|
105
|
+
when "hook_context"
|
|
106
|
+
(event[:status] == "success") ? "context_injection" : "context_skipped"
|
|
107
|
+
when "recall"
|
|
108
|
+
details = event[:details] || {}
|
|
109
|
+
if (details[:result_count] || 0).zero?
|
|
110
|
+
"recall_empty"
|
|
111
|
+
else
|
|
112
|
+
"recall_hit"
|
|
113
|
+
end
|
|
114
|
+
when "store_extraction"
|
|
115
|
+
"extraction"
|
|
116
|
+
when "hook_ingest"
|
|
117
|
+
(event[:status] == "success") ? "ingest" : "ingest_skipped"
|
|
118
|
+
when "hook_sweep"
|
|
119
|
+
"sweep"
|
|
120
|
+
else
|
|
121
|
+
"event"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_moment(store, event)
|
|
126
|
+
details = event[:details] || {}
|
|
127
|
+
kind = kind_for(event)
|
|
128
|
+
base = {
|
|
129
|
+
id: event[:id],
|
|
130
|
+
event_type: event[:event_type],
|
|
131
|
+
status: event[:status],
|
|
132
|
+
kind: kind,
|
|
133
|
+
occurred_at: event[:occurred_at],
|
|
134
|
+
occurred_ago: Core::RelativeTime.format(event[:occurred_at]),
|
|
135
|
+
session_id: event[:session_id],
|
|
136
|
+
duration_ms: event[:duration_ms],
|
|
137
|
+
details: details
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
enrich(base, kind, store, details)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def enrich(moment, kind, store, details)
|
|
144
|
+
case kind
|
|
145
|
+
when "context_injection"
|
|
146
|
+
moment.merge(
|
|
147
|
+
context_preview: details[:preview],
|
|
148
|
+
context_length: details[:context_length],
|
|
149
|
+
fact_count: details[:fact_count] || (details[:top_fact_ids] || []).size,
|
|
150
|
+
top_subjects: details[:top_subjects] || [],
|
|
151
|
+
top_facts: resolve_scoped_facts(details),
|
|
152
|
+
truncated: details[:truncated]
|
|
153
|
+
)
|
|
154
|
+
when "recall_hit", "recall_empty"
|
|
155
|
+
moment.merge(
|
|
156
|
+
tool: details[:tool],
|
|
157
|
+
query: details[:query],
|
|
158
|
+
result_count: details[:result_count] || 0,
|
|
159
|
+
scope: details[:scope],
|
|
160
|
+
top_facts: resolve_scoped_facts(details),
|
|
161
|
+
results_by_scope: details[:results_by_scope]
|
|
162
|
+
)
|
|
163
|
+
when "extraction"
|
|
164
|
+
moment.merge(
|
|
165
|
+
tool: details[:tool],
|
|
166
|
+
facts_created: details[:facts_created] || 0,
|
|
167
|
+
entities_created: details[:entities_created] || 0,
|
|
168
|
+
content_item: resolve_content(store, details[:content_item_id] || details[:content_id]),
|
|
169
|
+
extracted_facts: extracted_facts(store, details[:content_item_id] || details[:content_id])
|
|
170
|
+
)
|
|
171
|
+
when "ingest"
|
|
172
|
+
moment.merge(
|
|
173
|
+
bytes_read: details[:bytes_read],
|
|
174
|
+
content_item: resolve_content(store, details[:content_id]),
|
|
175
|
+
extracted_facts: extracted_facts(store, details[:content_id])
|
|
176
|
+
)
|
|
177
|
+
when "ingest_skipped"
|
|
178
|
+
moment.merge(reason: details[:reason])
|
|
179
|
+
when "sweep"
|
|
180
|
+
moment.merge(
|
|
181
|
+
elapsed_seconds: details[:elapsed_seconds],
|
|
182
|
+
budget_honored: details[:budget_honored]
|
|
183
|
+
)
|
|
184
|
+
else
|
|
185
|
+
moment
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def resolve_scoped_facts(details)
|
|
190
|
+
scoped = ScopedFactResolver.scoped_ids_from_details(details)
|
|
191
|
+
ScopedFactResolver.resolve(@manager, scoped)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def resolve_content(store, id)
|
|
195
|
+
return nil unless id
|
|
196
|
+
row = store.content_items.where(id: id.to_i).first
|
|
197
|
+
return nil unless row
|
|
198
|
+
|
|
199
|
+
raw = row[:raw_text].to_s
|
|
200
|
+
truncated = raw.bytesize > CONTENT_PREVIEW_BYTES
|
|
201
|
+
{
|
|
202
|
+
id: row[:id],
|
|
203
|
+
source: row[:source],
|
|
204
|
+
session_id: row[:session_id],
|
|
205
|
+
byte_len: row[:byte_len],
|
|
206
|
+
occurred_at: row[:occurred_at],
|
|
207
|
+
preview: truncated ? raw.byteslice(0, CONTENT_PREVIEW_BYTES) : raw,
|
|
208
|
+
truncated: truncated
|
|
209
|
+
}
|
|
210
|
+
rescue Sequel::DatabaseError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def attach_feedback(store, moments)
|
|
215
|
+
return if moments.empty?
|
|
216
|
+
ids = moments.map { |m| m[:id] }
|
|
217
|
+
feedback_by_event = store.moment_feedback.where(event_id: ids).all.each_with_object({}) do |row, h|
|
|
218
|
+
h[row[:event_id]] = {
|
|
219
|
+
verdict: row[:verdict],
|
|
220
|
+
note: row[:note],
|
|
221
|
+
recorded_at: row[:recorded_at]
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
moments.each do |m|
|
|
225
|
+
m[:feedback] = feedback_by_event[m[:id]]
|
|
226
|
+
end
|
|
227
|
+
rescue Sequel::DatabaseError
|
|
228
|
+
# Table missing on older DBs — skip silently.
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def extracted_facts(store, content_item_id)
|
|
232
|
+
return [] unless content_item_id
|
|
233
|
+
rows = store.db[:facts]
|
|
234
|
+
.join(:provenance, fact_id: :id)
|
|
235
|
+
.where(Sequel[:provenance][:content_item_id] => content_item_id.to_i)
|
|
236
|
+
.select(Sequel[:facts].*)
|
|
237
|
+
.all
|
|
238
|
+
FactPresenter.new(store).list_summary(rows)
|
|
239
|
+
rescue Sequel::DatabaseError
|
|
240
|
+
[]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Tracks which facts Claude actually *uses* in sessions — the ROI number.
|
|
6
|
+
# A fact that was taught once and then cited 10 times over the following
|
|
7
|
+
# month is earning its keep. A fact that's been sitting active for six
|
|
8
|
+
# months with zero recalls is just database weight.
|
|
9
|
+
#
|
|
10
|
+
# Counts both recall events (explicit memory.recall calls) and context
|
|
11
|
+
# injections (hook_context emitted_fact_ids), because both represent
|
|
12
|
+
# memory shaping what Claude sees.
|
|
13
|
+
class Reuse
|
|
14
|
+
DEFAULT_WINDOW_SECONDS = 7 * 86_400
|
|
15
|
+
DEFAULT_LIMIT = 10
|
|
16
|
+
|
|
17
|
+
COUNTING_EVENT_TYPES = %w[recall hook_context].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(manager)
|
|
20
|
+
@manager = manager
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param params [Hash]
|
|
24
|
+
# "since" — ISO 8601 cutoff, defaults to 7 days ago
|
|
25
|
+
# "limit" — max facts to return (default 10)
|
|
26
|
+
def top(params = {})
|
|
27
|
+
store = @manager.default_store(prefer: :project)
|
|
28
|
+
return {facts: [], window: default_window, event_count: 0} unless store
|
|
29
|
+
|
|
30
|
+
since = params["since"] || (Time.now.utc - DEFAULT_WINDOW_SECONDS).iso8601
|
|
31
|
+
limit = (params["limit"] || DEFAULT_LIMIT).to_i.clamp(1, 50)
|
|
32
|
+
|
|
33
|
+
event_rows = store.activity_events
|
|
34
|
+
.where(event_type: COUNTING_EVENT_TYPES)
|
|
35
|
+
.where(status: "success")
|
|
36
|
+
.where { occurred_at >= since }
|
|
37
|
+
.select(:id, :event_type, :occurred_at, :detail_json)
|
|
38
|
+
.all
|
|
39
|
+
|
|
40
|
+
# Count by (scope, id) pair. Project fact #5 and global fact #5
|
|
41
|
+
# are different facts — never merge their counts.
|
|
42
|
+
counts = Hash.new(0)
|
|
43
|
+
last_seen = {}
|
|
44
|
+
event_rows.each do |row|
|
|
45
|
+
details = row[:detail_json] ? JSON.parse(row[:detail_json]) : {}
|
|
46
|
+
scoped = ScopedFactResolver.scoped_ids_from_details(details)
|
|
47
|
+
ScopedFactResolver.flat_pairs(scoped).each do |pair|
|
|
48
|
+
counts[pair] += 1
|
|
49
|
+
last_seen[pair] = [last_seen[pair], row[:occurred_at]].compact.max
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return {facts: [], window: {since: since}, event_count: event_rows.size} if counts.empty?
|
|
54
|
+
|
|
55
|
+
top_pairs = counts.sort_by { |_, c| -c }.first(limit).to_h.keys
|
|
56
|
+
|
|
57
|
+
# Resolve each (scope, id) in the correct DB, preserving recall
|
|
58
|
+
# count + last_recalled metadata.
|
|
59
|
+
facts = []
|
|
60
|
+
top_pairs.group_by(&:first).each do |scope, pairs|
|
|
61
|
+
s = @manager.store_if_exists(scope)
|
|
62
|
+
next unless s
|
|
63
|
+
ids = pairs.map(&:last)
|
|
64
|
+
rows = s.facts.where(id: ids, status: "active").all
|
|
65
|
+
next if rows.empty?
|
|
66
|
+
presented = FactPresenter.new(s).list_summary(rows)
|
|
67
|
+
rows.zip(presented).each do |raw, p|
|
|
68
|
+
pair = [scope, raw[:id]]
|
|
69
|
+
facts << p.merge(
|
|
70
|
+
source: scope,
|
|
71
|
+
recall_count: counts[pair],
|
|
72
|
+
last_recalled_at: last_seen[pair],
|
|
73
|
+
last_recalled_ago: Core::RelativeTime.format(last_seen[pair])
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
facts.sort_by! { |f| -f[:recall_count] }
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
window: {since: since},
|
|
82
|
+
event_count: event_rows.size,
|
|
83
|
+
facts: facts.first(limit)
|
|
84
|
+
}
|
|
85
|
+
rescue Sequel::DatabaseError, JSON::ParserError => e
|
|
86
|
+
ClaudeMemory.logger.debug("Reuse#top failed: #{e.message}")
|
|
87
|
+
{facts: [], window: default_window, event_count: 0, error: e.message}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def default_window
|
|
93
|
+
{since: (Time.now.utc - DEFAULT_WINDOW_SECONDS).iso8601}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Resolves fact IDs from recall/context-injection event details back to
|
|
6
|
+
# the facts they actually referenced, respecting scope. Fact IDs
|
|
7
|
+
# autoincrement per-DB, so a bare numeric ID is ambiguous — project fact
|
|
8
|
+
# #1 and global fact #1 are different facts.
|
|
9
|
+
#
|
|
10
|
+
# Reads in priority order:
|
|
11
|
+
#
|
|
12
|
+
# 1. top_facts_by_scope (new, authoritative) — already scope-tagged
|
|
13
|
+
# 2. top_fact_ids + single-scope results_by_scope — historical events
|
|
14
|
+
# from before the fix; if the recall only touched one scope, every
|
|
15
|
+
# ID must belong to that scope
|
|
16
|
+
# 3. top_fact_ids alone — last-resort fallback; default to project
|
|
17
|
+
#
|
|
18
|
+
# Every reader in the dashboard goes through this so the scope bug
|
|
19
|
+
# can't reappear in one spot while being fixed in another.
|
|
20
|
+
module ScopedFactResolver
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Normalize event details into a {scope => [ids]} hash. Returns an
|
|
24
|
+
# empty hash when no fact-ID references are present.
|
|
25
|
+
#
|
|
26
|
+
# @param details [Hash] parsed detail_json from an activity_event row
|
|
27
|
+
# @return [Hash{String => Array<Integer>}]
|
|
28
|
+
def scoped_ids_from_details(details)
|
|
29
|
+
return {} unless details.is_a?(Hash)
|
|
30
|
+
authoritative = extract_top_facts_by_scope(details)
|
|
31
|
+
return authoritative if authoritative.any?
|
|
32
|
+
|
|
33
|
+
flat_ids = Array(details[:top_fact_ids] || details["top_fact_ids"]).map(&:to_i).reject(&:zero?)
|
|
34
|
+
return {} if flat_ids.empty?
|
|
35
|
+
|
|
36
|
+
scope = single_scope_from(details[:results_by_scope] || details["results_by_scope"])
|
|
37
|
+
{scope || "project" => flat_ids}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Resolve an entire {scope => [ids]} hash into ordered fact rows.
|
|
41
|
+
# Preserves the input order per scope so "top fact" ordering
|
|
42
|
+
# survives the round trip.
|
|
43
|
+
#
|
|
44
|
+
# @param manager [Store::StoreManager]
|
|
45
|
+
# @return [Array<Hash>] presenter-ready fact summaries with :source
|
|
46
|
+
def resolve(manager, scoped_ids)
|
|
47
|
+
return [] if scoped_ids.nil? || scoped_ids.empty?
|
|
48
|
+
results = []
|
|
49
|
+
scoped_ids.each do |scope, ids|
|
|
50
|
+
next if ids.nil? || ids.empty?
|
|
51
|
+
store = manager.store_if_exists(scope.to_s)
|
|
52
|
+
next unless store
|
|
53
|
+
rows = store.facts.where(id: ids.map(&:to_i)).all
|
|
54
|
+
next if rows.empty?
|
|
55
|
+
index = ids.each_with_index.to_h { |id, i| [id.to_i, i] }
|
|
56
|
+
rows.sort_by! { |r| index[r[:id]] || Float::INFINITY }
|
|
57
|
+
presented = FactPresenter.new(store).list_summary(rows)
|
|
58
|
+
presented.each { |f| results << f.merge(source: scope.to_s) }
|
|
59
|
+
end
|
|
60
|
+
results
|
|
61
|
+
rescue Sequel::DatabaseError => e
|
|
62
|
+
ClaudeMemory.logger.debug("ScopedFactResolver#resolve failed: #{e.message}")
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Flat list of unique scoped pairs — handy for counting unique facts
|
|
67
|
+
# referenced across a set of events.
|
|
68
|
+
#
|
|
69
|
+
# @return [Array<Array(String, Integer)>] [[scope, id], ...]
|
|
70
|
+
def flat_pairs(scoped_ids)
|
|
71
|
+
return [] if scoped_ids.nil? || scoped_ids.empty?
|
|
72
|
+
scoped_ids.flat_map { |scope, ids| ids.map { |id| [scope.to_s, id.to_i] } }.uniq
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def extract_top_facts_by_scope(details)
|
|
76
|
+
raw = details[:top_facts_by_scope] || details["top_facts_by_scope"]
|
|
77
|
+
return {} unless raw.is_a?(Hash)
|
|
78
|
+
raw.each_with_object({}) do |(scope, ids), acc|
|
|
79
|
+
cleaned = Array(ids).map(&:to_i).reject(&:zero?)
|
|
80
|
+
acc[scope.to_s] = cleaned unless cleaned.empty?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# If a recall's results came from exactly one scope, every fact ID
|
|
85
|
+
# must belong to that scope. Returns the scope name, or nil when the
|
|
86
|
+
# recall touched multiple scopes (can't disambiguate) or none.
|
|
87
|
+
def single_scope_from(results_by_scope)
|
|
88
|
+
return nil unless results_by_scope.is_a?(Hash)
|
|
89
|
+
scopes_with_hits = results_by_scope.reject { |_, count| count.nil? || count.zero? }.keys
|
|
90
|
+
return nil unless scopes_with_hits.size == 1
|
|
91
|
+
scopes_with_hits.first.to_s
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|