claude_memory 0.10.0 → 0.12.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/rules/claude_memory.generated.md +42 -64
  4. data/.claude/skills/release/SKILL.md +44 -6
  5. data/.claude/skills/study-repo/SKILL.md +15 -0
  6. data/.claude-plugin/commands/audit-memory.md +68 -0
  7. data/.claude-plugin/marketplace.json +1 -1
  8. data/.claude-plugin/plugin.json +1 -1
  9. data/CHANGELOG.md +70 -0
  10. data/CLAUDE.md +20 -5
  11. data/README.md +64 -2
  12. data/db/migrations/018_add_otel_telemetry.rb +81 -0
  13. data/docs/1_0_punchlist.md +522 -89
  14. data/docs/GETTING_STARTED.md +3 -1
  15. data/docs/api_stability.md +341 -0
  16. data/docs/architecture.md +3 -3
  17. data/docs/audit_runbook.md +209 -0
  18. data/docs/claude_monitoring.md +956 -0
  19. data/docs/dashboard.md +23 -3
  20. data/docs/improvements.md +329 -5
  21. data/docs/influence/ai-memory-systems-2026.md +403 -0
  22. data/docs/memory_audit_2026-05-21.md +303 -0
  23. data/docs/plugin.md +1 -1
  24. data/docs/quality_review.md +35 -0
  25. data/lib/claude_memory/audit/checks.rb +239 -0
  26. data/lib/claude_memory/audit/finding.rb +33 -0
  27. data/lib/claude_memory/audit/runner.rb +73 -0
  28. data/lib/claude_memory/commands/audit_command.rb +117 -0
  29. data/lib/claude_memory/commands/dashboard_command.rb +2 -1
  30. data/lib/claude_memory/commands/digest_command.rb +95 -3
  31. data/lib/claude_memory/commands/hook_command.rb +27 -2
  32. data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
  33. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
  34. data/lib/claude_memory/commands/otel_command.rb +240 -0
  35. data/lib/claude_memory/commands/registry.rb +5 -1
  36. data/lib/claude_memory/commands/show_command.rb +90 -0
  37. data/lib/claude_memory/commands/stats_command.rb +94 -2
  38. data/lib/claude_memory/configuration.rb +60 -0
  39. data/lib/claude_memory/core/fact_query_builder.rb +1 -0
  40. data/lib/claude_memory/dashboard/api.rb +8 -0
  41. data/lib/claude_memory/dashboard/index.html +140 -1
  42. data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
  43. data/lib/claude_memory/dashboard/server.rb +86 -0
  44. data/lib/claude_memory/dashboard/telemetry.rb +156 -0
  45. data/lib/claude_memory/dashboard/trust.rb +180 -11
  46. data/lib/claude_memory/deprecations.rb +106 -0
  47. data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
  48. data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
  49. data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
  50. data/lib/claude_memory/hook/context_injector.rb +11 -2
  51. data/lib/claude_memory/hook/handler.rb +142 -1
  52. data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
  53. data/lib/claude_memory/otel/attributes.rb +118 -0
  54. data/lib/claude_memory/otel/constants.rb +32 -0
  55. data/lib/claude_memory/otel/ingestor.rb +54 -0
  56. data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
  57. data/lib/claude_memory/otel/prompt_scope.rb +108 -0
  58. data/lib/claude_memory/otel/settings_writer.rb +122 -0
  59. data/lib/claude_memory/otel/status.rb +58 -0
  60. data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
  61. data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
  62. data/lib/claude_memory/resolve/resolver.rb +30 -3
  63. data/lib/claude_memory/shortcuts.rb +61 -18
  64. data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
  65. data/lib/claude_memory/store/schema_manager.rb +1 -1
  66. data/lib/claude_memory/store/sqlite_store.rb +136 -0
  67. data/lib/claude_memory/sweep/maintenance.rb +31 -1
  68. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  69. data/lib/claude_memory/templates/hooks.example.json +5 -0
  70. data/lib/claude_memory/version.rb +1 -1
  71. data/lib/claude_memory.rb +20 -0
  72. metadata +28 -1
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ # Soft-rename / soft-removal mechanism for public-API surfaces.
5
+ # Used to mark an old name (CLI flag, MCP tool, Ruby method, hook
6
+ # field, predicate) as deprecated in `N.x.0` releases while keeping it
7
+ # functional for at least one minor cycle, with explicit removal no
8
+ # earlier than `(N+1).0.0`. This deprecation policy is documented in
9
+ # `docs/api_stability.md`.
10
+ #
11
+ # @example Deprecate a renamed CLI flag
12
+ # ClaudeMemory::Deprecations.warn(
13
+ # name: "claude-memory recall --legacy-mode",
14
+ # replacement: "--mode=legacy",
15
+ # removed_in: "1.0.0"
16
+ # )
17
+ #
18
+ # @example Deprecate a soft-renamed Ruby method
19
+ # ClaudeMemory::Deprecations.warn(
20
+ # name: "ClaudeMemory::Recall#legacy_query",
21
+ # replacement: "Recall#query",
22
+ # removed_in: "1.0.0",
23
+ # message: "Pass `mode: :legacy` to #query instead."
24
+ # )
25
+ #
26
+ # Two suppression mechanisms keep deprecation noise manageable:
27
+ #
28
+ # - **Per-call-site dedupe**: same (name, caller_file:line) pair only
29
+ # emits once per process. Prevents tight loops or repeated callers
30
+ # from drowning the terminal.
31
+ # - **Env var opt-out**: `CLAUDE_MEMORY_NO_DEPRECATIONS=1` silences
32
+ # everything. Recommended for test fixtures and CI runs that
33
+ # knowingly exercise legacy paths.
34
+ module Deprecations
35
+ ENV_OPT_OUT = "CLAUDE_MEMORY_NO_DEPRECATIONS"
36
+
37
+ # Tracks already-emitted (name, caller-location) pairs for dedupe.
38
+ # Bounded — this is a long-lived process state but the cardinality
39
+ # is at most "every deprecated surface × every call site that
40
+ # touches it", which stays small in practice.
41
+ @emitted = {}
42
+ @mutex = Mutex.new
43
+
44
+ class << self
45
+ # Emit a deprecation warning to `output` (stderr by default).
46
+ #
47
+ # @param name [String] the deprecated identifier (CLI flag, method,
48
+ # tool name, etc.). Be specific: "ClaudeMemory::Recall#query(:legacy)"
49
+ # beats "Recall#query".
50
+ # @param replacement [String, nil] what users should switch to.
51
+ # Optional but strongly recommended — a deprecation without a
52
+ # migration path is annoying.
53
+ # @param removed_in [String, nil] target removal version, semver
54
+ # string. Conventionally the next major (`(N+1).0.0`).
55
+ # @param message [String, nil] free-form extra context. Use for
56
+ # subtle migration nuance the replacement string can't capture.
57
+ # @param caller_location [String, nil] override for testing.
58
+ # @param output [IO] override for testing. Default: $stderr.
59
+ # @return [Boolean] true if a warning was emitted, false if
60
+ # suppressed (env opt-out or already-emitted dedupe).
61
+ def warn(name:, replacement: nil, removed_in: nil, message: nil,
62
+ caller_location: nil, output: $stderr)
63
+ return false if suppressed?
64
+
65
+ location = caller_location || derive_caller_location
66
+ key = "#{name}@#{location}"
67
+
68
+ @mutex.synchronize do
69
+ return false if @emitted[key]
70
+ @emitted[key] = true
71
+ end
72
+
73
+ output.puts(format_warning(name: name, replacement: replacement,
74
+ removed_in: removed_in, message: message, location: location))
75
+ true
76
+ end
77
+
78
+ # Wipe the per-call-site dedupe state. Test-only — production
79
+ # callers should rely on the per-process behavior.
80
+ def reset!
81
+ @mutex.synchronize { @emitted.clear }
82
+ end
83
+
84
+ private
85
+
86
+ def suppressed?
87
+ ENV[ENV_OPT_OUT] == "1"
88
+ end
89
+
90
+ def derive_caller_location
91
+ # Skip: 0=this method, 1=warn, 2=actual caller
92
+ loc = caller_locations(3, 1)&.first
93
+ loc ? "#{loc.path}:#{loc.lineno}" : "unknown"
94
+ end
95
+
96
+ def format_warning(name:, replacement:, removed_in:, message:, location:)
97
+ parts = ["[ClaudeMemory] DEPRECATION: #{name} is deprecated"]
98
+ parts << "scheduled for removal in #{removed_in}" if removed_in
99
+ parts << "use #{replacement} instead" if replacement
100
+ head = parts.join(", ") + "."
101
+ head += " #{message}" if message
102
+ "#{head} (called from #{location})"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Distill
5
+ # Catches facts that survived distillation without a reason clause.
6
+ # The SessionStart distillation prompt explicitly requires `decision`
7
+ # and `convention` facts to embed the reason ("— because …", "so that
8
+ # …", "to avoid …", "caused by …", "breaks when …"); facts that ship
9
+ # without one are dead weight once they go stale because nobody can
10
+ # recover the original justification by re-reading the row.
11
+ #
12
+ # This detector is the production-side mirror of that prompt
13
+ # constraint. It exists so the dashboard can quantify how many facts
14
+ # are slipping through the prompt's reason-clause requirement —
15
+ # higher bare-conclusion ratio means the LLM is producing low-quality
16
+ # extractions, which is a hallucination-rate proxy worth surfacing.
17
+ #
18
+ # Pure function, no side effects, safe to call in tight loops.
19
+ class BareConclusionDetector
20
+ # Predicates the prompt requires reasons for. Other predicates
21
+ # (uses_framework, uses_database, etc.) carry their meaning in the
22
+ # subject-predicate-object shape itself, so a bare object is fine.
23
+ GUARDED_PREDICATES = %w[decision convention].freeze
24
+
25
+ # Reason-clause signals lifted from the distill-transcripts skill
26
+ # prompt plus a small set of common natural-language variants. The
27
+ # match is case-insensitive and substring-anchored — any one signal
28
+ # qualifies the fact as "explained" even without an em dash.
29
+ REASON_PATTERNS = [
30
+ /\bbecause\b/i,
31
+ /\bso\s+that\b/i,
32
+ /\bso\s+the\b/i,
33
+ /\bso\s+we\b/i,
34
+ /\bin\s+order\s+to\b/i,
35
+ /\bto\s+avoid\b/i,
36
+ /\bto\s+prevent\b/i,
37
+ /\bto\s+ensure\b/i,
38
+ /\bto\s+support\b/i,
39
+ /\bto\s+allow\b/i,
40
+ /\bto\s+enable\b/i,
41
+ /\bto\s+make\b/i,
42
+ /\bto\s+fix\b/i,
43
+ /\bto\s+handle\b/i,
44
+ /\bcaused\s+by\b/i,
45
+ /\bbreaks\s+when\b/i,
46
+ /\bdue\s+to\b/i,
47
+ /\botherwise\b/i,
48
+ /\bwithout\s+(?:which|this|it)\b/i
49
+ ].freeze
50
+
51
+ # Returns true when the fact has a guarded predicate AND its object
52
+ # text shows no reason-clause signal. Returns false for any fact
53
+ # outside the guarded predicates so the metric isn't polluted by
54
+ # legitimately-bare facts (uses_database "sqlite" doesn't need a
55
+ # rationale embedded in its object).
56
+ #
57
+ # @param fact [Hash] with :predicate and :object_literal keys (or
58
+ # :predicate / :object — accepts both shapes used in the codebase)
59
+ # @return [Boolean]
60
+ def bare_conclusion?(fact)
61
+ predicate = fact[:predicate].to_s
62
+ return false unless GUARDED_PREDICATES.include?(predicate)
63
+
64
+ object = (fact[:object_literal] || fact[:object]).to_s
65
+ return false if object.empty?
66
+
67
+ REASON_PATTERNS.none? { |re| object.match?(re) }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -43,11 +43,28 @@ module ClaudeMemory
43
43
  /\bby\s+[[:upper:]][[:alpha:]'-]+\s+[[:upper:]][[:alpha:]'-]+/
44
44
  ].freeze
45
45
 
46
- # Predicates we inspect. Decisions stay decisions even when they cite
47
- # external projects ("From QMD restudy: adopt X"); the guard targets
48
- # only `convention`, where misclassification is most common.
46
+ # Predicates inspected for object-text reference signals. Decisions
47
+ # stay decisions even when they cite external projects ("From QMD
48
+ # restudy: adopt X"); the object-text guard targets only
49
+ # `convention`, where misclassification is most common.
49
50
  GUARDED_PREDICATES = %w[convention].freeze
50
51
 
52
+ # Stack-shaping single-value predicates that historically attract
53
+ # hallucinations from CLAUDE.md-style example text ("e.g., this app
54
+ # uses PostgreSQL"). For these predicates we additionally inspect the
55
+ # source quote for example markers — if the LLM extracted a stack
56
+ # fact from documentation example text, it's not a real project
57
+ # commitment. Added 2026-05-21 after the audit found 10 open
58
+ # conflicts driven by recurring example-text extraction.
59
+ QUOTE_GUARDED_PREDICATES = %w[uses_database uses_framework uses_language deployment_platform auth_method].freeze
60
+
61
+ # Example markers that signal the source text is documentation
62
+ # exemplifying a scope/predicate concept, not a real stack claim.
63
+ EXAMPLE_QUOTE_PATTERNS = [
64
+ /\b(?:e\.?g\.?|i\.?e\.?|for example|for instance|such as)[,:]?\s/i,
65
+ /\(\s*(?:e\.?g\.?|i\.?e\.?)[,.]/i
66
+ ].freeze
67
+
51
68
  def reclassify(extraction)
52
69
  return extraction if extraction.facts.nil? || extraction.facts.empty?
53
70
 
@@ -68,11 +85,27 @@ module ClaudeMemory
68
85
  end
69
86
 
70
87
  def reference_material?(fact)
71
- return false unless GUARDED_PREDICATES.include?(fact[:predicate].to_s)
88
+ predicate = fact[:predicate].to_s
89
+ return true if convention_with_reference_object?(fact, predicate)
90
+ return true if stack_predicate_from_example_text?(fact, predicate)
91
+ false
92
+ end
93
+
94
+ private
95
+
96
+ def convention_with_reference_object?(fact, predicate)
97
+ return false unless GUARDED_PREDICATES.include?(predicate)
72
98
  object = fact[:object].to_s
73
99
  return false if object.empty?
74
100
  STRONG_PATTERNS.any? { |re| object.match?(re) }
75
101
  end
102
+
103
+ def stack_predicate_from_example_text?(fact, predicate)
104
+ return false unless QUOTE_GUARDED_PREDICATES.include?(predicate)
105
+ quote = fact[:quote].to_s
106
+ return false if quote.empty?
107
+ EXAMPLE_QUOTE_PATTERNS.any? { |re| quote.match?(re) }
108
+ end
76
109
  end
77
110
  end
78
111
  end
@@ -21,10 +21,14 @@ module ClaudeMemory
21
21
  STATE_FILENAME = "auto_memory_mirror.json"
22
22
 
23
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`.
24
+ # slug convention. Both path separators and underscores are converted
25
+ # to hyphens — e.g. `/Users/me/src/my_app` →
26
+ # `~/.claude/projects/-Users-me-src-my-app/memory`. Before the
27
+ # underscore conversion was added (2026-05-21 audit), this method
28
+ # silently missed auto-memory for any project name containing `_`,
29
+ # including claude_memory itself.
26
30
  def self.default_dir(project_path, claude_config_dir)
27
- slug = project_path.to_s.tr("/", "-")
31
+ slug = project_path.to_s.tr("/_", "-")
28
32
  File.join(claude_config_dir, "projects", slug, "memory")
29
33
  end
30
34
 
@@ -32,11 +32,12 @@ module ClaudeMemory
32
32
  # so a bare ID without scope is ambiguous.
33
33
  attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope
34
34
 
35
- def initialize(manager, source: nil, auto_memory_mirror: nil)
35
+ def initialize(manager, source: nil, auto_memory_mirror: nil, stale_threshold_days: nil)
36
36
  @manager = manager
37
37
  @source = source
38
38
  @recall = Recall.new(manager)
39
39
  @auto_memory_mirror = auto_memory_mirror
40
+ @stale_threshold_days = stale_threshold_days
40
41
  @emitted_fact_ids = []
41
42
  @emitted_subjects = []
42
43
  @emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
@@ -108,11 +109,19 @@ module ClaudeMemory
108
109
  predicate = fact[:predicate]
109
110
  object = fact[:object_literal]
110
111
 
111
- if subject && predicate && object
112
+ line = if subject && predicate && object
112
113
  "#{subject}.#{predicate} = #{object}"
113
114
  elsif object
114
115
  object.to_s
115
116
  end
117
+ return nil unless line
118
+
119
+ marker = Recall::StalenessAnnotator.marker_for(fact, threshold_days: stale_threshold_days)
120
+ marker ? "#{line} #{marker}" : line
121
+ end
122
+
123
+ def stale_threshold_days
124
+ @stale_threshold_days ||= Configuration.new.injection_stale_days
116
125
  end
117
126
 
118
127
  def fetch_undistilled(limit)
@@ -93,6 +93,59 @@ module ClaudeMemory
93
93
  result
94
94
  end
95
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
+ }
147
+ end
148
+
96
149
  def context(payload)
97
150
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
151
 
@@ -105,7 +158,11 @@ module ClaudeMemory
105
158
 
106
159
  log_activity("hook_context",
107
160
  status: context_text ? "success" : "skipped", t0: t0,
108
- details: {context_length: context_text&.length, source: source})
161
+ details: {
162
+ context_length: context_text&.length,
163
+ context_tokens: Core::TokenEstimator.estimate(context_text),
164
+ source: source
165
+ })
109
166
 
110
167
  {status: :ok, context: context_text}
111
168
  rescue => e
@@ -126,6 +183,90 @@ module ClaudeMemory
126
183
  project_path = payload["project_path"] || @config.project_dir
127
184
  Store::StoreManager.new(project_path: project_path, env: @env)
128
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
129
270
  end
130
271
  end
131
272
  end
@@ -284,7 +284,7 @@ module ClaudeMemory
284
284
  },
285
285
  {
286
286
  name: "memory.decisions",
287
- description: "List architectural decisions, constraints, and rules.",
287
+ description: "List facts with predicate=decision from both project and global memory (project first). Returns only `decision`-predicate facts — does not include `uses_database`, `uses_framework`, etc.",
288
288
  inputSchema: {
289
289
  type: "object",
290
290
  properties: {
@@ -295,7 +295,7 @@ module ClaudeMemory
295
295
  },
296
296
  {
297
297
  name: "memory.conventions",
298
- description: "List coding conventions and style preferences from global memory.",
298
+ description: "List facts with predicate=convention from both project and global memory (project first). Use this to see project coding conventions alongside user-wide style preferences.",
299
299
  inputSchema: {
300
300
  type: "object",
301
301
  properties: {
@@ -306,7 +306,7 @@ module ClaudeMemory
306
306
  },
307
307
  {
308
308
  name: "memory.architecture",
309
- description: "List framework choices and architectural patterns.",
309
+ description: "List architecture facts and stack-shaping constraints (predicates: architecture, uses_database, uses_framework, uses_language, deployment_platform, auth_method) from both project and global memory.",
310
310
  inputSchema: {
311
311
  type: "object",
312
312
  properties: {
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeMemory
6
+ module OTel
7
+ # Value object wrapping an OTel attribute hash (already flattened from
8
+ # the OTLP KeyValue representation by OtlpJsonEnvelope). Hides the
9
+ # primitive Hash from callers so panels and ingestors don't reach into
10
+ # raw JSON keys.
11
+ #
12
+ # All accessors return nil when the underlying attribute is missing.
13
+ # Frozen on construction — pass a fresh hash if you need to mutate.
14
+ class Attributes
15
+ # OTel keys we treat as "captured prompt content". Used by
16
+ # #contains_prompt_content? to flag privacy concerns regardless of
17
+ # which content flag was flipped (OTEL_LOG_USER_PROMPTS,
18
+ # OTEL_LOG_TOOL_CONTENT, OTEL_LOG_RAW_API_BODIES).
19
+ PROMPT_CONTENT_KEYS = %w[
20
+ prompt
21
+ body
22
+ tool_input
23
+ tool.output
24
+ full_command
25
+ user_prompt
26
+ ].freeze
27
+
28
+ # @param hash [Hash] flattened attributes (string keys)
29
+ def initialize(hash)
30
+ @hash = (hash || {}).dup.freeze
31
+ freeze
32
+ end
33
+
34
+ # Parse a JSON string into Attributes. Returns Attributes wrapping
35
+ # an empty hash for nil, blank, or unparseable input — matches the
36
+ # tolerance the existing dashboard panels expect.
37
+ #
38
+ # @param json_string [String, nil]
39
+ # @return [Attributes]
40
+ def self.from_json(json_string)
41
+ return new({}) if json_string.nil? || json_string.empty?
42
+ new(JSON.parse(json_string))
43
+ rescue JSON::ParserError
44
+ new({})
45
+ end
46
+
47
+ def to_h
48
+ @hash
49
+ end
50
+
51
+ def to_json(*args)
52
+ @hash.to_json(*args)
53
+ end
54
+
55
+ def [](key)
56
+ @hash[key.to_s]
57
+ end
58
+
59
+ # Claude Code attaches `prompt.id` to events that should be UNION'd into
60
+ # the prompt journey. See docs/claude_monitoring.md.
61
+ def prompt_id
62
+ @hash["prompt.id"] || @hash["prompt_id"]
63
+ end
64
+
65
+ def session_id
66
+ @hash["session.id"] || @hash["session_id"]
67
+ end
68
+
69
+ # GenAI semconv canonical key + Claude Code's `model` alias.
70
+ def model
71
+ @hash["gen_ai.request.model"] || @hash["model"]
72
+ end
73
+
74
+ def tool_name
75
+ @hash["tool_name"]
76
+ end
77
+
78
+ # Cost counter values arrive as the metric value, not as an attribute.
79
+ # The `type` attribute on token.usage tells us input/output/cacheRead/
80
+ # cacheCreation; this is only useful for token rows. Returns nil when
81
+ # missing so callers can guard with #compact.
82
+ def token_type
83
+ @hash["type"]
84
+ end
85
+
86
+ # Token count carried on a token.usage data point.
87
+ # @param row [Hash] otel_metrics row
88
+ def self.token_count(row)
89
+ (row[:value_int] || row[:value_float] || 0).to_i
90
+ end
91
+
92
+ # Tool execution duration in ms when the event is a tool_result.
93
+ # @return [Integer, nil]
94
+ def duration_ms
95
+ value = @hash["duration_ms"]
96
+ value&.to_i
97
+ end
98
+
99
+ def query_source
100
+ @hash["query_source"]
101
+ end
102
+
103
+ def speed
104
+ @hash["speed"]
105
+ end
106
+
107
+ # True when any attribute carries actual prompt or body content. Used
108
+ # by panels to render a one-line privacy notice without auto-flipping
109
+ # OTEL_LOG_USER_PROMPTS.
110
+ def contains_prompt_content?
111
+ PROMPT_CONTENT_KEYS.any? do |key|
112
+ value = @hash[key]
113
+ !value.nil? && !value.to_s.strip.empty?
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module OTel
5
+ # Canonical metric value types stored in otel_metrics.value_type.
6
+ module ValueType
7
+ INT = "int"
8
+ DOUBLE = "double"
9
+ end
10
+
11
+ # Canonical event names emitted by Claude Code's OTel instrumentation.
12
+ # Used by panels for filtering and by the parser when stripping the
13
+ # `claude_code.` prefix off `event.name` attributes.
14
+ module EventName
15
+ USER_PROMPT = "user_prompt"
16
+ TOOL_RESULT = "tool_result"
17
+ API_REQUEST = "api_request"
18
+ API_ERROR = "api_error"
19
+ API_REQUEST_BODY = "api_request_body"
20
+ API_RESPONSE_BODY = "api_response_body"
21
+
22
+ API_PAIR = [API_REQUEST, API_ERROR].freeze
23
+ PROMPT_BODY_FAMILY = [USER_PROMPT, TOOL_RESULT, API_REQUEST_BODY, API_RESPONSE_BODY].freeze
24
+ end
25
+
26
+ # Canonical metric names that the dashboard queries.
27
+ module MetricName
28
+ TOKEN_USAGE = "claude_code.token.usage"
29
+ COST_USAGE = "claude_code.cost.usage"
30
+ end
31
+ end
32
+ end