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,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