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.
Files changed (73) 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 +86 -0
  7. data/CLAUDE.md +21 -5
  8. data/README.md +32 -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 +190 -0
  13. data/docs/EXAMPLES.md +41 -2
  14. data/docs/GETTING_STARTED.md +31 -4
  15. data/docs/architecture.md +22 -7
  16. data/docs/audit-queries.md +131 -0
  17. data/docs/dashboard.md +172 -0
  18. data/docs/improvements.md +465 -9
  19. data/docs/influence/cq.md +187 -0
  20. data/docs/plugin.md +13 -6
  21. data/docs/quality_review.md +489 -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 +181 -0
  29. data/lib/claude_memory/commands/hook_command.rb +34 -0
  30. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  31. data/lib/claude_memory/commands/registry.rb +6 -1
  32. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  33. data/lib/claude_memory/commands/stats_command.rb +38 -1
  34. data/lib/claude_memory/commands/sweep_command.rb +2 -0
  35. data/lib/claude_memory/configuration.rb +16 -0
  36. data/lib/claude_memory/core/relative_time.rb +9 -0
  37. data/lib/claude_memory/dashboard/api.rb +610 -0
  38. data/lib/claude_memory/dashboard/conflicts.rb +279 -0
  39. data/lib/claude_memory/dashboard/efficacy.rb +127 -0
  40. data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
  41. data/lib/claude_memory/dashboard/health.rb +175 -0
  42. data/lib/claude_memory/dashboard/index.html +2707 -0
  43. data/lib/claude_memory/dashboard/knowledge.rb +136 -0
  44. data/lib/claude_memory/dashboard/moments.rb +244 -0
  45. data/lib/claude_memory/dashboard/reuse.rb +97 -0
  46. data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
  47. data/lib/claude_memory/dashboard/server.rb +211 -0
  48. data/lib/claude_memory/dashboard/timeline.rb +68 -0
  49. data/lib/claude_memory/dashboard/trust.rb +285 -0
  50. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  51. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  52. data/lib/claude_memory/hook/context_injector.rb +97 -3
  53. data/lib/claude_memory/hook/handler.rb +50 -3
  54. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  55. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  56. data/lib/claude_memory/mcp/text_summary.rb +29 -0
  57. data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
  58. data/lib/claude_memory/mcp/tools.rb +148 -0
  59. data/lib/claude_memory/publish.rb +13 -21
  60. data/lib/claude_memory/recall/stale_detector.rb +67 -0
  61. data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
  62. data/lib/claude_memory/resolve/resolver.rb +41 -11
  63. data/lib/claude_memory/store/llm_cache.rb +68 -0
  64. data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
  65. data/lib/claude_memory/store/schema_manager.rb +1 -1
  66. data/lib/claude_memory/store/sqlite_store.rb +47 -143
  67. data/lib/claude_memory/store/store_manager.rb +29 -0
  68. data/lib/claude_memory/sweep/maintenance.rb +216 -0
  69. data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
  70. data/lib/claude_memory/sweep/sweeper.rb +2 -0
  71. data/lib/claude_memory/version.rb +1 -1
  72. data/lib/claude_memory.rb +22 -0
  73. metadata +49 -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,64 @@ 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
61
94
  end
62
95
 
63
96
  def context(payload)
97
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+
64
99
  manager = @manager || build_manager(payload)
65
100
  manager.ensure_both!
66
101
 
@@ -68,13 +103,25 @@ module ClaudeMemory
68
103
  injector = ContextInjector.new(manager, source: source)
69
104
  context_text = injector.generate_context
70
105
 
106
+ log_activity("hook_context",
107
+ status: context_text ? "success" : "skipped", t0: t0,
108
+ details: {context_length: context_text&.length, source: source})
109
+
71
110
  {status: :ok, context: context_text}
72
111
  rescue => e
112
+ log_activity("hook_context", status: "error", t0: t0,
113
+ details: {error: e.message})
73
114
  {status: :error, context: nil, message: e.message}
74
115
  end
75
116
 
76
117
  private
77
118
 
119
+ def log_activity(event_type, status:, session_id: nil, t0: nil, details: nil)
120
+ duration_ms = t0 ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round : nil
121
+ ActivityLog.record(@store, event_type: event_type, status: status,
122
+ session_id: session_id, duration_ms: duration_ms, details: details)
123
+ end
124
+
78
125
  def build_manager(payload)
79
126
  project_path = payload["project_path"] || @config.project_dir
80
127
  Store::StoreManager.new(project_path: project_path, env: @env)
@@ -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
@@ -44,11 +44,34 @@ module ClaudeMemory
44
44
  ToolDefinitions.all
45
45
  end
46
46
 
47
+ # Tools that represent recall/query usage - tracked for efficacy
48
+ RECALL_TOOLS = %w[
49
+ memory.recall memory.recall_index memory.recall_semantic
50
+ memory.search_concepts memory.decisions memory.conventions memory.architecture
51
+ ].freeze
52
+
53
+ # Write tools worth tracking
54
+ WRITE_TOOLS = %w[memory.store_extraction].freeze
55
+
56
+ TRACKED_TOOLS = (RECALL_TOOLS + WRITE_TOOLS).freeze
57
+
47
58
  # Dispatch a tool call to the appropriate handler method.
48
59
  # @param name [String] fully-qualified tool name (e.g. "memory.recall")
49
60
  # @param arguments [Hash] tool arguments from the MCP request
50
61
  # @return [Hash] structured result hash for the tool response
51
62
  def call(name, arguments)
63
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+
65
+ result = dispatch(name, arguments)
66
+
67
+ log_tool_activity(name, arguments, result, t0) if TRACKED_TOOLS.include?(name)
68
+
69
+ result
70
+ end
71
+
72
+ private
73
+
74
+ def dispatch(name, arguments)
52
75
  case name
53
76
  when "memory.recall" then recall(arguments)
54
77
  when "memory.recall_index" then recall_index(arguments)
@@ -74,6 +97,7 @@ module ClaudeMemory
74
97
  when "memory.mark_distilled" then mark_distilled(arguments)
75
98
  when "memory.check_setup" then check_setup
76
99
  when "memory.list_projects" then list_projects
100
+ when "memory.activity" then activity(arguments)
77
101
  else {error: "Unknown tool: #{name}"}
78
102
  end
79
103
  end
@@ -111,6 +135,130 @@ module ClaudeMemory
111
135
  @legacy_store
112
136
  end
113
137
  end
138
+
139
+ def log_tool_activity(name, arguments, result, t0)
140
+ store = default_store
141
+ return unless store
142
+
143
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
144
+ event_type = WRITE_TOOLS.include?(name) ? "store_extraction" : "recall"
145
+ status = result[:error] ? "error" : "success"
146
+ session_id = extract_session_id(arguments)
147
+
148
+ details = {tool: name}
149
+ if event_type == "recall"
150
+ details[:query] = arguments["query"] || arguments["concepts"]&.join(", ")
151
+ details[:scope] = arguments["scope"]
152
+ details[:result_count] = extract_result_count(result)
153
+ # top_fact_ids is a flat list of the first 5 IDs; top_facts_by_scope
154
+ # groups the same IDs by source so dashboard readers can resolve
155
+ # each ID from the DB it actually came from. Fact IDs autoincrement
156
+ # per-DB, so a bare ID without scope is ambiguous.
157
+ scoped = extract_top_facts_scoped(result)
158
+ details[:top_fact_ids] = scoped.values.flatten.first(5)
159
+ details[:top_facts_by_scope] = scoped if scoped.any?
160
+ details[:results_by_scope] = extract_scope_breakdown(result)
161
+ else
162
+ details[:facts_created] = result[:facts_created]
163
+ details[:entities_created] = result[:entities_created]
164
+ details[:content_item_id] = result[:content_item_id]
165
+ end
166
+
167
+ ActivityLog.record(store, event_type: event_type, status: status,
168
+ session_id: session_id, duration_ms: duration_ms, details: details.compact)
169
+ end
170
+
171
+ # Probe a recall result for a count of returned items. Falls back to
172
+ # counting the first array-valued key among the shapes emitted by the
173
+ # various recall handlers (facts, results, items, concepts).
174
+ def extract_result_count(result)
175
+ return 0 unless result.is_a?(Hash)
176
+ [:fact_count, :count, :results_count].each do |key|
177
+ val = result[key]
178
+ return val if val.is_a?(Integer)
179
+ end
180
+ [:facts, :results, :items, :concepts, :conflicts].each do |key|
181
+ val = result[key]
182
+ return val.size if val.is_a?(Array)
183
+ end
184
+ 0
185
+ end
186
+
187
+ # Capture up to 5 fact ids from a recall result, grouped by source scope.
188
+ # Fact IDs autoincrement per-DB, so without scope a bare ID is ambiguous
189
+ # (project fact #1 and global fact #1 are different facts). Recall rows
190
+ # carry either a :source or :scope field identifying which DB the fact
191
+ # came from; we use that to group.
192
+ #
193
+ # @return [Hash{String => Array<Integer>}] e.g. {"project" => [5, 8], "global" => [1]}
194
+ def extract_top_facts_scoped(result, limit: 5)
195
+ return {} unless result.is_a?(Hash)
196
+ collection = [:facts, :results, :items].map { |k| result[k] }.find { |v| v.is_a?(Array) }
197
+ return {} unless collection
198
+
199
+ grouped = Hash.new { |h, k| h[k] = [] }
200
+ collection.first(limit).each do |row|
201
+ next unless row.is_a?(Hash)
202
+ fact = row[:fact] || row["fact"] || row
203
+ id = fact.is_a?(Hash) ? (fact[:id] || fact["id"]) : nil
204
+ next unless id
205
+ scope = row[:source] || row["source"] || fact[:scope] || fact["scope"] || "project"
206
+ grouped[scope.to_s] << id
207
+ end
208
+ grouped
209
+ end
210
+
211
+ def extract_session_id(arguments)
212
+ (arguments.is_a?(Hash) && arguments["session_id"]) || Configuration.new.session_id
213
+ end
214
+
215
+ # Count returned items grouped by their :scope field so the dashboard
216
+ # can show whether a recall's hits came from global preferences, project
217
+ # facts, or both. Returns nil when the result shape doesn't carry facts.
218
+ # @return [Hash{String => Integer}, nil]
219
+ def extract_scope_breakdown(result)
220
+ return nil unless result.is_a?(Hash)
221
+ collection = [:facts, :results, :items].map { |k| result[k] }.find { |v| v.is_a?(Array) }
222
+ return nil unless collection
223
+
224
+ breakdown = Hash.new(0)
225
+ collection.each { |row|
226
+ next unless row.is_a?(Hash)
227
+ scope = row[:scope] || row["scope"] || row[:source] || row["source"] || "unknown"
228
+ breakdown[scope.to_s] += 1
229
+ }
230
+ breakdown.empty? ? nil : breakdown
231
+ end
232
+
233
+ # Return whichever store is available for activity logging. Delegates
234
+ # to StoreManager#default_store which prefers the project store and
235
+ # falls back to global — preventing silent drops of activity events
236
+ # when the project DB hasn't been initialized yet.
237
+ def default_store
238
+ return @legacy_store unless @manager
239
+ @manager.default_store(prefer: :project)
240
+ end
241
+
242
+ def activity(args)
243
+ store = default_store
244
+ return {error: "No database available"} unless store
245
+
246
+ limit = args["limit"] || 50
247
+ event_type = args["event_type"]
248
+ since = args["since"]
249
+
250
+ events = ActivityLog.recent(store, limit: limit, event_type: event_type, since: since)
251
+ summary = ActivityLog.summary(store, since: since)
252
+
253
+ {
254
+ event_count: events.size,
255
+ summary: summary,
256
+ events: events.map { |e|
257
+ e[:occurred_ago] = Core::RelativeTime.format(e[:occurred_at])
258
+ e
259
+ }
260
+ }
261
+ end
114
262
  end
115
263
  end
116
264
  end