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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/skills/dashboard/SKILL.md +42 -0
  4. data/.claude-plugin/marketplace.json +1 -1
  5. data/.claude-plugin/plugin.json +1 -1
  6. data/CHANGELOG.md +130 -0
  7. data/CLAUDE.md +30 -6
  8. data/README.md +66 -2
  9. data/db/migrations/015_add_activity_events.rb +26 -0
  10. data/db/migrations/016_add_moment_feedback.rb +22 -0
  11. data/db/migrations/017_add_last_recalled_at.rb +15 -0
  12. data/docs/1_0_punchlist.md +371 -0
  13. data/docs/EXAMPLES.md +41 -2
  14. data/docs/GETTING_STARTED.md +33 -4
  15. data/docs/architecture.md +22 -7
  16. data/docs/audit-queries.md +131 -0
  17. data/docs/dashboard.md +192 -0
  18. data/docs/improvements.md +650 -9
  19. data/docs/influence/cq.md +187 -0
  20. data/docs/plugin.md +13 -6
  21. data/docs/quality_review.md +524 -172
  22. data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
  23. data/lib/claude_memory/activity_log.rb +86 -0
  24. data/lib/claude_memory/commands/census_command.rb +210 -0
  25. data/lib/claude_memory/commands/completion_command.rb +3 -0
  26. data/lib/claude_memory/commands/dashboard_command.rb +54 -0
  27. data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
  28. data/lib/claude_memory/commands/digest_command.rb +273 -0
  29. data/lib/claude_memory/commands/hook_command.rb +61 -2
  30. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
  31. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  32. data/lib/claude_memory/commands/registry.rb +7 -1
  33. data/lib/claude_memory/commands/show_command.rb +90 -0
  34. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  35. data/lib/claude_memory/commands/stats_command.rb +131 -2
  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 +454 -0
  52. data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
  53. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  54. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  55. data/lib/claude_memory/hook/context_injector.rb +97 -3
  56. data/lib/claude_memory/hook/handler.rb +191 -3
  57. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  58. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  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/templates/hooks.example.json +5 -0
  75. data/lib/claude_memory/version.rb +1 -1
  76. data/lib/claude_memory.rb +24 -0
  77. metadata +51 -1
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module ClaudeMemory
8
+ module Hook
9
+ # Mirrors Claude Code auto-memory (~/.claude/projects/<slug>/memory/*.md)
10
+ # into extraction candidates surfaced alongside the SessionStart distillation
11
+ # prompt. Diffs files against a per-project state file (mtime+md5) so only
12
+ # new or changed entries are emitted. Idempotent — unchanged files are
13
+ # skipped on re-run.
14
+ #
15
+ # The emission is a *hint* to Claude that auto-memory has content worth
16
+ # mirroring into claude_memory via `memory.store_extraction`. The mirror
17
+ # never writes facts itself; the normal extraction review flow still applies.
18
+ class AutoMemoryMirror
19
+ MAX_CANDIDATES = 5
20
+ MAX_TEXT_PER_ITEM = 1500
21
+ STATE_FILENAME = "auto_memory_mirror.json"
22
+
23
+ # Derive auto-memory directory from a project path using Claude Code's
24
+ # slug convention (path separators → hyphens). E.g.
25
+ # `/Users/me/src/app` → `~/.claude/projects/-Users-me-src-app/memory`.
26
+ def self.default_dir(project_path, claude_config_dir)
27
+ slug = project_path.to_s.tr("/", "-")
28
+ File.join(claude_config_dir, "projects", slug, "memory")
29
+ end
30
+
31
+ def self.default_state_file(project_path)
32
+ File.join(project_path, ".claude", STATE_FILENAME)
33
+ end
34
+
35
+ def initialize(auto_memory_dir:, state_file:)
36
+ @auto_memory_dir = auto_memory_dir
37
+ @state_file = state_file
38
+ end
39
+
40
+ # @return [Array<Hash>] candidate entries — each {name:, path:, content:, signature:}
41
+ def pending_candidates(limit: MAX_CANDIDATES)
42
+ return [] unless Dir.exist?(@auto_memory_dir)
43
+
44
+ state = load_state
45
+ files = Dir.glob(File.join(@auto_memory_dir, "*.md")).sort_by { |p| -File.mtime(p).to_i }
46
+
47
+ files.each_with_object([]) do |path, candidates|
48
+ break candidates if candidates.size >= limit
49
+ name = File.basename(path)
50
+ signature = file_signature(path)
51
+ prior = state[name]
52
+ next if prior.is_a?(Hash) && prior["md5"] == signature[:md5]
53
+
54
+ candidates << {
55
+ name: name,
56
+ path: path,
57
+ content: Core::TextBuilder.truncate(safe_read(path), MAX_TEXT_PER_ITEM),
58
+ signature: signature
59
+ }
60
+ end
61
+ rescue => e
62
+ ClaudeMemory.logger.warn("AutoMemoryMirror#pending_candidates failed: #{e.message}")
63
+ []
64
+ end
65
+
66
+ # Record the given candidates as the new baseline so they won't be
67
+ # re-emitted until their content changes. Call only after the candidates
68
+ # have actually been surfaced to the user.
69
+ def commit(candidates)
70
+ return if candidates.empty?
71
+
72
+ state = load_state
73
+ candidates.each do |c|
74
+ state[c[:name]] = {"md5" => c[:signature][:md5], "mtime" => c[:signature][:mtime]}
75
+ end
76
+ write_state(state)
77
+ rescue => e
78
+ ClaudeMemory.logger.warn("AutoMemoryMirror#commit failed: #{e.message}")
79
+ end
80
+
81
+ private
82
+
83
+ def file_signature(path)
84
+ bytes = safe_read(path)
85
+ {
86
+ md5: Digest::MD5.hexdigest(bytes),
87
+ mtime: File.mtime(path).to_i
88
+ }
89
+ end
90
+
91
+ def safe_read(path)
92
+ File.read(path)
93
+ rescue => e
94
+ ClaudeMemory.logger.debug("AutoMemoryMirror read failed for #{path}: #{e.message}")
95
+ ""
96
+ end
97
+
98
+ def load_state
99
+ return {} unless File.exist?(@state_file)
100
+ parsed = JSON.parse(File.read(@state_file))
101
+ parsed.is_a?(Hash) ? parsed : {}
102
+ rescue JSON::ParserError
103
+ {}
104
+ end
105
+
106
+ def write_state(state)
107
+ FileUtils.mkdir_p(File.dirname(@state_file))
108
+ File.write(@state_file, JSON.pretty_generate(state))
109
+ end
110
+ end
111
+ end
112
+ end
@@ -11,6 +11,7 @@ module ClaudeMemory
11
11
  MAX_ARCHITECTURE = 5
12
12
  MAX_UNDISTILLED = 3
13
13
  MAX_TEXT_PER_ITEM = 1500
14
+ MAX_MIRROR_CANDIDATES = 5
14
15
 
15
16
  FRESH_SESSION_SOURCES = %w[startup resume clear].freeze
16
17
 
@@ -20,13 +21,31 @@ module ClaudeMemory
20
21
  architecture: {query: "uses framework implements architecture pattern", scope: "all"}
21
22
  }.freeze
22
23
 
23
- def initialize(manager, source: nil)
24
+ # Fact IDs and subjects that `generate_context` injected on the most recent
25
+ # call. Both are empty until `generate_context` has been invoked. Populated
26
+ # in call order (decisions → conventions → architecture) so benchmark
27
+ # harnesses can attribute sections if they care.
28
+ #
29
+ # emitted_facts_by_scope groups the IDs by the DB they came from
30
+ # ({"project" => [...], "global" => [...]}) so telemetry can resolve
31
+ # each fact from the correct store. Fact IDs autoincrement per-DB,
32
+ # so a bare ID without scope is ambiguous.
33
+ attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope
34
+
35
+ def initialize(manager, source: nil, auto_memory_mirror: nil)
24
36
  @manager = manager
25
37
  @source = source
26
38
  @recall = Recall.new(manager)
39
+ @auto_memory_mirror = auto_memory_mirror
40
+ @emitted_fact_ids = []
41
+ @emitted_subjects = []
42
+ @emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
27
43
  end
28
44
 
29
45
  def generate_context
46
+ @emitted_fact_ids = []
47
+ @emitted_subjects = []
48
+ @emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
30
49
  sections = []
31
50
 
32
51
  decisions = fetch(:decisions, MAX_DECISIONS)
@@ -41,6 +60,12 @@ module ClaudeMemory
41
60
  if fresh_session?
42
61
  undistilled = fetch_undistilled(MAX_UNDISTILLED)
43
62
  sections << format_distillation_prompt(undistilled) if undistilled.any?
63
+
64
+ mirror_candidates = fetch_mirror_candidates(MAX_MIRROR_CANDIDATES)
65
+ if mirror_candidates.any?
66
+ sections << format_auto_memory_mirror(mirror_candidates)
67
+ auto_memory_mirror.commit(mirror_candidates)
68
+ end
44
69
  end
45
70
 
46
71
  return nil if sections.empty?
@@ -57,7 +82,20 @@ module ClaudeMemory
57
82
  def fetch(category, limit)
58
83
  config = QUERIES.fetch(category)
59
84
  results = @recall.query(config[:query], limit: limit, scope: config[:scope])
60
- results.map { |r| format_fact(r[:fact]) }
85
+ results.filter_map do |r|
86
+ fact = r[:fact]
87
+ next unless fact
88
+ formatted = format_fact(fact)
89
+ next unless formatted
90
+ if fact[:id]
91
+ @emitted_fact_ids << fact[:id]
92
+ scope_key = (r[:source] || fact[:scope] || "project").to_s
93
+ @emitted_facts_by_scope[scope_key] << fact[:id]
94
+ end
95
+ subject = fact[:subject_name] || fact[:subject_entity_id]
96
+ @emitted_subjects << subject.to_s if subject
97
+ formatted
98
+ end
61
99
  rescue => e
62
100
  ClaudeMemory.logger.debug("ContextInjector#fetch(#{category}) failed: #{e.message}")
63
101
  []
@@ -104,7 +142,13 @@ module ClaudeMemory
104
142
  "followed by `memory.mark_distilled` for each item.",
105
143
  "",
106
144
  "**What to extract:** technology decisions, conventions, preferences, architecture",
107
- "**What to skip:** debugging steps, code output, transient errors"
145
+ "**What to skip:** debugging steps, code output, transient errors",
146
+ "",
147
+ "**Reasoning requirement:** decisions and conventions MUST embed a reason",
148
+ "in the object (e.g., \"… because …\", \"… so that …\", \"caused by …\",",
149
+ "\"breaks when …\"). A fact with a reason is recoverable once stale; a",
150
+ "bare conclusion is dead weight. Prefer one fact-with-reason over two",
151
+ "facts-without."
108
152
  ]
109
153
 
110
154
  items.each do |item|
@@ -126,6 +170,56 @@ module ClaudeMemory
126
170
  items.each { |item| lines << "- #{item}" }
127
171
  lines.join("\n")
128
172
  end
173
+
174
+ def fetch_mirror_candidates(limit)
175
+ mirror = auto_memory_mirror
176
+ return [] unless mirror
177
+ mirror.pending_candidates(limit: limit)
178
+ rescue => e
179
+ ClaudeMemory.logger.warn("ContextInjector#fetch_mirror_candidates failed: #{e.message}")
180
+ []
181
+ end
182
+
183
+ def auto_memory_mirror
184
+ @auto_memory_mirror ||= build_default_mirror
185
+ end
186
+
187
+ def build_default_mirror
188
+ project_path = @manager.respond_to?(:project_path) ? @manager.project_path : nil
189
+ return nil unless project_path
190
+
191
+ config = Configuration.new
192
+ AutoMemoryMirror.new(
193
+ auto_memory_dir: AutoMemoryMirror.default_dir(project_path, config.claude_config_dir),
194
+ state_file: AutoMemoryMirror.default_state_file(project_path)
195
+ )
196
+ rescue => e
197
+ ClaudeMemory.logger.debug("ContextInjector#build_default_mirror failed: #{e.message}")
198
+ nil
199
+ end
200
+
201
+ def format_auto_memory_mirror(candidates)
202
+ lines = [
203
+ "## Auto-Memory Mirror Candidates",
204
+ "",
205
+ "The following auto-memory entries (from `~/.claude/projects/<slug>/memory/`)",
206
+ "are new or changed since the last mirror. Consider extracting them into",
207
+ "claude_memory via `memory.store_extraction` so future sessions can recall",
208
+ "them via `memory.conventions` / `memory.recall_semantic`.",
209
+ "",
210
+ "**Review discipline applies:** only extract high-signal entries (gotchas,",
211
+ "feedback, references). Skip transient project state. Preserve the `**Why:**`",
212
+ "and `**How to apply:**` reasoning when present."
213
+ ]
214
+
215
+ candidates.each do |candidate|
216
+ lines << ""
217
+ lines << "### #{candidate[:name]}"
218
+ lines << candidate[:content]
219
+ end
220
+
221
+ lines.join("\n")
222
+ end
129
223
  end
130
224
  end
131
225
  end
@@ -22,6 +22,8 @@ module ClaudeMemory
22
22
  raise PayloadError, "Missing required field: session_id" if session_id.nil? || session_id.empty?
23
23
  raise PayloadError, "Missing required field: transcript_path" if transcript_path.nil? || transcript_path.empty?
24
24
 
25
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
26
+
25
27
  ingester = Ingest::Ingester.new(@store, env: @env)
26
28
  result = ingester.ingest(
27
29
  source: "claude_code",
@@ -36,31 +38,117 @@ module ClaudeMemory
36
38
  )
37
39
  end
38
40
 
41
+ log_activity("hook_ingest",
42
+ status: (result[:status] == :ingested) ? "success" : "skipped",
43
+ session_id: session_id, t0: t0,
44
+ details: {bytes_read: result[:bytes_read], content_id: result[:content_id],
45
+ reason: result[:reason]}.compact)
46
+
39
47
  result
40
48
  rescue Ingest::TranscriptReader::FileNotFoundError => e
41
- # Transcript file doesn't exist (e.g., headless Claude session)
42
- # This is expected, not an error - return success with no-op status
49
+ log_activity("hook_ingest", status: "skipped", session_id: session_id, t0: t0,
50
+ details: {reason: "transcript_not_found"})
43
51
  {status: :skipped, reason: "transcript_not_found", message: e.message}
44
52
  end
45
53
 
46
54
  def sweep(payload)
55
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+
47
57
  budget = payload.fetch("budget", DEFAULT_SWEEP_BUDGET).to_i
48
58
  sweeper = Sweep::Sweeper.new(@store)
49
59
  stats = sweeper.run!(budget_seconds: budget)
60
+ stats[:recall_timestamps_refreshed] = refresh_recall_timestamps
61
+
62
+ log_activity("hook_sweep", status: "success", t0: t0,
63
+ details: {elapsed_seconds: stats[:elapsed_seconds],
64
+ budget_honored: stats[:budget_honored]})
50
65
 
51
66
  {stats: stats}
52
67
  end
53
68
 
69
+ # Sweep-derived staleness data. Skips silently if the sweep was given a
70
+ # store-only handler (no manager); the cross-DB refresher requires the
71
+ # manager because project events can touch global facts and vice versa.
72
+ def refresh_recall_timestamps
73
+ return {project: 0, global: 0} unless @manager
74
+ Sweep::RecallTimestampRefresher.new(@manager).refresh!
75
+ rescue Sequel::DatabaseError => e
76
+ ClaudeMemory.logger.debug("recall timestamp refresh failed: #{e.message}")
77
+ {project: 0, global: 0, error: e.message}
78
+ end
79
+
54
80
  def publish(payload)
81
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+
55
83
  mode = payload.fetch("mode", "shared").to_sym
56
84
  since = payload["since"]
57
85
  rules_dir = payload["rules_dir"]
58
86
 
59
87
  publisher = Publish.new(@store)
60
- publisher.publish!(mode: mode, since: since, rules_dir: rules_dir)
88
+ result = publisher.publish!(mode: mode, since: since, rules_dir: rules_dir)
89
+
90
+ log_activity("hook_publish", status: "success", t0: t0,
91
+ details: {mode: mode.to_s, publish_status: result[:status].to_s})
92
+
93
+ result
94
+ end
95
+
96
+ # First-week ROI nudge. Computes per-session metrics (facts
97
+ # contributed via Stop-hook ingest, percentage of those Claude
98
+ # actually used in recall/context-injection) and decides whether
99
+ # to print to the user. Quiets after MAX_NUDGES successful runs
100
+ # or when CLAUDE_MEMORY_NO_NUDGE=1.
101
+ #
102
+ # The "first ~10 sessions" gate is enforced by counting prior
103
+ # `roi_nudge` activity events with status=success across both
104
+ # stores. Once the user has seen the nudge enough times, memory
105
+ # gets out of the way; trust is established or it isn't.
106
+ MAX_NUDGES = 10
107
+ ENV_NUDGE_OPT_OUT = "CLAUDE_MEMORY_NO_NUDGE"
108
+
109
+ def nudge(payload)
110
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
+ session_id = payload["session_id"] || @config.session_id
112
+
113
+ # Cleanly silent on opt-out — no activity event, no record of
114
+ # having tried. Users who set the env var don't want a paper
115
+ # trail of suppressed nudges.
116
+ return {status: :silent, reason: "opt_out"} if @env[ENV_NUDGE_OPT_OUT] == "1"
117
+ return {status: :silent, reason: "no_session_id"} if session_id.nil? || session_id.empty?
118
+
119
+ prior = prior_nudge_count
120
+ if prior >= MAX_NUDGES
121
+ return {status: :silent, reason: "first_week_complete", prior_count: prior}
122
+ end
123
+
124
+ contributed_ids = session_contributed_facts(session_id)
125
+ n = contributed_ids.size
126
+
127
+ if n.zero?
128
+ # Don't burn one of the user's 10 nudge slots on an empty
129
+ # session. Memory contributed nothing → no trust signal to
130
+ # surface; come back next session with real data.
131
+ return {status: :silent, reason: "no_contributions", prior_count: prior}
132
+ end
133
+
134
+ used = session_used_facts(session_id, contributed_ids)
135
+ pct = (used * 100.0 / n).round
136
+ message = "memory contributed #{n} fact#{"s" unless n == 1} this session, %used = #{pct}%"
137
+
138
+ log_activity("roi_nudge", status: "success", session_id: session_id, t0: t0,
139
+ details: {n: n, used: used, pct: pct, prior_count: prior})
140
+
141
+ {
142
+ status: :emitted,
143
+ message: message,
144
+ n: n, used: used, pct: pct,
145
+ remaining: MAX_NUDGES - prior - 1
146
+ }
61
147
  end
62
148
 
63
149
  def context(payload)
150
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
151
+
64
152
  manager = @manager || build_manager(payload)
65
153
  manager.ensure_both!
66
154
 
@@ -68,17 +156,117 @@ module ClaudeMemory
68
156
  injector = ContextInjector.new(manager, source: source)
69
157
  context_text = injector.generate_context
70
158
 
159
+ log_activity("hook_context",
160
+ status: context_text ? "success" : "skipped", t0: t0,
161
+ details: {
162
+ context_length: context_text&.length,
163
+ context_tokens: Core::TokenEstimator.estimate(context_text),
164
+ source: source
165
+ })
166
+
71
167
  {status: :ok, context: context_text}
72
168
  rescue => e
169
+ log_activity("hook_context", status: "error", t0: t0,
170
+ details: {error: e.message})
73
171
  {status: :error, context: nil, message: e.message}
74
172
  end
75
173
 
76
174
  private
77
175
 
176
+ def log_activity(event_type, status:, session_id: nil, t0: nil, details: nil)
177
+ duration_ms = t0 ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round : nil
178
+ ActivityLog.record(@store, event_type: event_type, status: status,
179
+ session_id: session_id, duration_ms: duration_ms, details: details)
180
+ end
181
+
78
182
  def build_manager(payload)
79
183
  project_path = payload["project_path"] || @config.project_dir
80
184
  Store::StoreManager.new(project_path: project_path, env: @env)
81
185
  end
186
+
187
+ # Cross-scope nudge counter. Counts both stores so a user with
188
+ # global facts only doesn't bypass the first-week limit.
189
+ def prior_nudge_count
190
+ manager_or_self.then do |m|
191
+ %w[project global].sum do |scope|
192
+ store = m.respond_to?(:store_if_exists) ? m.store_if_exists(scope) : nil
193
+ next 0 unless store
194
+ store.activity_events.where(event_type: "roi_nudge", status: "success").count
195
+ end
196
+ end
197
+ rescue Sequel::DatabaseError
198
+ # If we can't read the count, err on the side of "still in
199
+ # first week" so users keep getting feedback while we figure
200
+ # out what's wrong with the DB.
201
+ 0
202
+ end
203
+
204
+ # Facts whose provenance points to content_items captured in
205
+ # this session. Active facts only — superseded/rejected ones
206
+ # don't count as memory contributing.
207
+ def session_contributed_facts(session_id)
208
+ return [] unless @store
209
+ @store.facts
210
+ .join(:provenance, fact_id: :id)
211
+ .join(:content_items, id: Sequel[:provenance][:content_item_id])
212
+ .where(Sequel[:content_items][:session_id] => session_id)
213
+ .where(Sequel[:facts][:status] => "active")
214
+ .select(Sequel[:facts][:id])
215
+ .distinct
216
+ .map { |row| row[:id] }
217
+ rescue Sequel::DatabaseError => e
218
+ ClaudeMemory.logger.debug("session_contributed_facts failed: #{e.message}")
219
+ []
220
+ end
221
+
222
+ # Of the given fact ids, how many appear in top_fact_ids of any
223
+ # recall or hook_context activity event tagged with this
224
+ # session_id?
225
+ def session_used_facts(session_id, fact_ids)
226
+ return 0 if fact_ids.empty?
227
+ return 0 unless @store
228
+ target = fact_ids.to_set
229
+ used = Set.new
230
+
231
+ @store.activity_events
232
+ .where(event_type: %w[recall hook_context], status: "success")
233
+ .where(session_id: session_id)
234
+ .select(:detail_json)
235
+ .all
236
+ .each do |row|
237
+ details = row[:detail_json] ? JSON.parse(row[:detail_json]) : {}
238
+ (details["top_fact_ids"] || []).each { |id| used << id if target.include?(id) }
239
+ end
240
+
241
+ used.size
242
+ rescue Sequel::DatabaseError, JSON::ParserError => e
243
+ ClaudeMemory.logger.debug("session_used_facts failed: #{e.message}")
244
+ 0
245
+ end
246
+
247
+ def manager_or_self
248
+ return @manager if @manager
249
+ # When the Handler was given only a single store (no manager),
250
+ # we still want to count nudges; treat the store like a single-
251
+ # scope manager via a tiny wrapper.
252
+ @_handler_store_facade ||= SingleStoreFacade.new(@store)
253
+ end
254
+
255
+ class SingleStoreFacade
256
+ def initialize(store)
257
+ @store = store
258
+ end
259
+
260
+ def store_if_exists(scope)
261
+ # The manager's store_if_exists returns nil for the absent
262
+ # scope; we don't know which scope this single store
263
+ # represents, so return it for "project" and nil for
264
+ # "global". Counts undercount global-only setups, which is
265
+ # acceptable — global-only users would normally pass a
266
+ # manager.
267
+ (scope == "project") ? @store : nil
268
+ end
269
+ end
82
270
  end
83
271
  end
84
272
  end
@@ -29,6 +29,13 @@ module ClaudeMemory
29
29
  signals: []
30
30
  )
31
31
 
32
+ # Guard against the LLM distiller labeling descriptions of external
33
+ # projects (LOC counts, star counts, "X is a plugin by …") as
34
+ # `convention`. Retag those as `reference` before resolution so
35
+ # they don't pollute the Knowledge-base conventions list or get
36
+ # returned by `memory.conventions`.
37
+ extraction = Distill::ReferenceMaterialDetector.new.reclassify(extraction)
38
+
32
39
  resolver = Resolve::Resolver.new(store)
33
40
  result = resolver.apply(
34
41
  extraction,
@@ -41,6 +48,7 @@ module ClaudeMemory
41
48
  {
42
49
  success: true,
43
50
  scope: scope,
51
+ content_item_id: content_item_id,
44
52
  entities_created: result[:entities_created],
45
53
  facts_created: result[:facts_created],
46
54
  facts_superseded: result[:facts_superseded],
@@ -11,6 +11,17 @@ module ClaudeMemory
11
11
  PROMPT_TEXT = <<~GUIDE
12
12
  # ClaudeMemory Search Strategy Guide
13
13
 
14
+ ## Mental Model — V = R/C
15
+
16
+ Memory's value per interaction is governed by one relation:
17
+
18
+ - **R** — judgment retained from prior interactions (decisions, corrections, reasons, why-lines)
19
+ - **C** — context that must be rebuilt from scratch each session
20
+
21
+ **Goal**: raise R, lower C. Recall relevant facts (↑R) using the cheapest tool that answers the question (↓C). Repeat-corrections and re-derivations are the clearest failure signals — both mean R didn't propagate.
22
+
23
+ Every tool below is an instrument for one of those two levers. Pick accordingly.
24
+
14
25
  ## Tool Escalation — Cheap to Expensive
15
26
 
16
27
  Start with fast, cheap tools. Escalate only when you need more detail.
@@ -31,6 +31,8 @@ module ClaudeMemory
31
31
  when "memory.undistilled" then summarize_undistilled(result)
32
32
  when "memory.mark_distilled" then summarize_mark_distilled(result)
33
33
  when "memory.check_setup" then summarize_check_setup(result)
34
+ when "memory.activity" then summarize_activity(result)
35
+ when "memory.list_projects" then summarize_list_projects(result)
34
36
  else JSON.generate(result)
35
37
  end
36
38
  end
@@ -275,6 +277,33 @@ module ClaudeMemory
275
277
  lines.join("\n")
276
278
  end
277
279
 
280
+ def self.summarize_activity(result)
281
+ events = result[:events] || []
282
+ return "No activity events recorded." if events.empty?
283
+
284
+ summary = result[:summary] || {}
285
+ lines = ["#{result[:event_count]} event(s):"]
286
+ summary.each do |type, counts|
287
+ parts = counts.map { |status, count| "#{count} #{status}" }
288
+ lines << " #{type}: #{parts.join(", ")}"
289
+ end
290
+ lines.join("\n")
291
+ end
292
+
293
+ def self.summarize_list_projects(result)
294
+ lines = ["Projects:"]
295
+ if result[:global]
296
+ lines << "- Global: #{result[:global][:facts_active] || 0} active facts"
297
+ end
298
+ if result[:current_project]
299
+ lines << "- Current: #{result[:current_project][:facts_active] || 0} active facts"
300
+ end
301
+ (result[:other_projects] || []).each do |p|
302
+ lines << "- #{p[:path]}: #{p[:facts_active] || 0} active facts"
303
+ end
304
+ lines.join("\n")
305
+ end
306
+
278
307
  # Format fact identifier: prefer docid if available, fall back to integer id
279
308
  def self.fact_label(fact)
280
309
  fact[:docid] || fact[:id]
@@ -438,6 +438,19 @@ module ClaudeMemory
438
438
  properties: {}
439
439
  },
440
440
  annotations: READ_ONLY
441
+ },
442
+ {
443
+ name: "memory.activity",
444
+ description: "View recent activity events (hook executions, recalls, context injections). Shows what happened behind the scenes for debugging and observability.",
445
+ inputSchema: {
446
+ type: "object",
447
+ properties: {
448
+ limit: {type: "integer", default: 50, description: "Maximum events to return"},
449
+ event_type: {type: "string", enum: %w[hook_ingest hook_context hook_sweep hook_publish recall store_extraction], description: "Filter by event type"},
450
+ since: {type: "string", description: "ISO 8601 timestamp lower bound"}
451
+ }
452
+ },
453
+ annotations: READ_ONLY
441
454
  }
442
455
  ]
443
456
  end