claude_memory 0.11.0 → 0.12.1

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 +54 -85
  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 +2 -4
  9. data/CHANGELOG.md +50 -0
  10. data/CLAUDE.md +11 -4
  11. data/README.md +40 -1
  12. data/db/migrations/018_add_otel_telemetry.rb +81 -0
  13. data/docs/1_0_punchlist.md +318 -66
  14. data/docs/api_stability.md +346 -0
  15. data/docs/audit_runbook.md +209 -0
  16. data/docs/claude_monitoring.md +956 -0
  17. data/docs/improvements.md +148 -9
  18. data/docs/influence/ai-memory-systems-2026.md +403 -0
  19. data/docs/memory_audit_2026-05-21.md +303 -0
  20. data/docs/plugin.md +1 -1
  21. data/docs/soak/audit_2026-06-03_agent-training-program.json +53 -0
  22. data/docs/soak/audit_2026-06-03_agentic.json +31 -0
  23. data/docs/soak/audit_2026-06-03_ai-software-architect.json +19 -0
  24. data/docs/soak/audit_2026-06-03_chaos_to_the_rescue.json +60 -0
  25. data/docs/soak/audit_2026-06-03_claude_memory.json +55 -0
  26. data/docs/soak/audit_2026-06-03_daily-vibe.json +59 -0
  27. data/docs/soak/audit_2026-06-03_minerva-sky.json +19 -0
  28. data/docs/soak/audit_2026-06-03_nowreading.dev.json +19 -0
  29. data/docs/soak/audit_2026-06-03_ups.dev.json +55 -0
  30. data/docs/soak/baseline_2026-06-03.md +145 -0
  31. data/lib/claude_memory/audit/checks.rb +239 -0
  32. data/lib/claude_memory/audit/finding.rb +33 -0
  33. data/lib/claude_memory/audit/runner.rb +73 -0
  34. data/lib/claude_memory/commands/audit_command.rb +117 -0
  35. data/lib/claude_memory/commands/checks/embeddings_check.rb +97 -0
  36. data/lib/claude_memory/commands/dashboard_command.rb +2 -1
  37. data/lib/claude_memory/commands/doctor_command.rb +1 -0
  38. data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
  39. data/lib/claude_memory/commands/otel_command.rb +240 -0
  40. data/lib/claude_memory/commands/registry.rb +5 -1
  41. data/lib/claude_memory/commands/setup_vectors_command.rb +182 -0
  42. data/lib/claude_memory/configuration.rb +60 -0
  43. data/lib/claude_memory/core/fact_query_builder.rb +1 -0
  44. data/lib/claude_memory/dashboard/api.rb +8 -0
  45. data/lib/claude_memory/dashboard/index.html +140 -1
  46. data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
  47. data/lib/claude_memory/dashboard/server.rb +86 -0
  48. data/lib/claude_memory/dashboard/telemetry.rb +156 -0
  49. data/lib/claude_memory/deprecations.rb +106 -0
  50. data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
  51. data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
  52. data/lib/claude_memory/hook/context_injector.rb +11 -2
  53. data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
  54. data/lib/claude_memory/otel/attributes.rb +118 -0
  55. data/lib/claude_memory/otel/constants.rb +32 -0
  56. data/lib/claude_memory/otel/ingestor.rb +54 -0
  57. data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
  58. data/lib/claude_memory/otel/prompt_scope.rb +108 -0
  59. data/lib/claude_memory/otel/settings_writer.rb +122 -0
  60. data/lib/claude_memory/otel/status.rb +58 -0
  61. data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
  62. data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
  63. data/lib/claude_memory/resolve/resolver.rb +30 -3
  64. data/lib/claude_memory/shortcuts.rb +61 -18
  65. data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
  66. data/lib/claude_memory/store/schema_manager.rb +1 -1
  67. data/lib/claude_memory/store/sqlite_store.rb +136 -0
  68. data/lib/claude_memory/sweep/maintenance.rb +31 -1
  69. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  70. data/lib/claude_memory/version.rb +1 -1
  71. data/lib/claude_memory.rb +20 -0
  72. metadata +38 -1
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Dashboard
5
+ # Cost & Tokens dashboard panel. Aggregates Claude Code's OTel metric
6
+ # exports — server-side via Sequel datasets so the API returns
7
+ # final-rendered bins and the JS does no reduce.
8
+ #
9
+ # Returns the empty shape ({status:, cost_over_time: [], ...}) when no
10
+ # store or no rows exist so the dashboard renders before the first
11
+ # ingest.
12
+ class Telemetry
13
+ LOOKBACK_DAYS = 7
14
+ TOP_TOOLS_LIMIT = 10
15
+
16
+ def initialize(manager)
17
+ @manager = manager
18
+ end
19
+
20
+ def snapshot
21
+ store = @manager.default_store(prefer: :global)
22
+ return empty_snapshot(store) unless store&.db&.table_exists?(:otel_metrics)
23
+
24
+ cutoff = (Time.now - LOOKBACK_DAYS * 86_400).utc.iso8601
25
+ metrics = store.otel_metrics.where { recorded_at >= cutoff }
26
+ events = events_dataset(store, cutoff)
27
+
28
+ {
29
+ status: status_payload(store),
30
+ cost_over_time: cost_over_time(metrics),
31
+ tokens_by_model: tokens_by_model(metrics),
32
+ top_tools_by_latency: top_tools(events),
33
+ error_rate: error_rate(events),
34
+ recent_metrics: recent_metrics(metrics),
35
+ contains_prompt_content: contains_prompt_content?(events)
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def empty_snapshot(store)
42
+ {
43
+ status: status_payload(store),
44
+ cost_over_time: [],
45
+ tokens_by_model: [],
46
+ top_tools_by_latency: [],
47
+ error_rate: {total: 0, errors: 0, ratio: 0.0},
48
+ recent_metrics: [],
49
+ contains_prompt_content: false
50
+ }
51
+ end
52
+
53
+ def status_payload(store)
54
+ OTel::Status.new(store, configuration: ClaudeMemory::Configuration.new).snapshot
55
+ end
56
+
57
+ def cost_over_time(metrics)
58
+ rows = metrics
59
+ .where(name: OTel::MetricName::COST_USAGE)
60
+ .select_group(Sequel.lit("substr(recorded_at, 1, 13)").as(:hour))
61
+ .select_append { sum(value_float).as(:cost_usd) }
62
+ .select_append { count(id).as(:requests) }
63
+ .order(:hour)
64
+ .all
65
+ rows.map { |r|
66
+ {
67
+ hour: r[:hour],
68
+ cost_usd: (r[:cost_usd] || 0.0).to_f.round(6),
69
+ requests: r[:requests].to_i
70
+ }
71
+ }
72
+ end
73
+
74
+ # SQLite's json_extract was added in 3.38.0 (2022-02). Sequel runs it
75
+ # via Sequel.lit so we group by (model, type) at the DB layer instead
76
+ # of materializing the whole window into Ruby.
77
+ def tokens_by_model(metrics)
78
+ model_expr = Sequel.lit("json_extract(attributes_json, '$.model')")
79
+ type_expr = Sequel.lit("json_extract(attributes_json, '$.type')")
80
+ rows = metrics
81
+ .where(name: OTel::MetricName::TOKEN_USAGE)
82
+ .select_group(model_expr.as(:model), type_expr.as(:type))
83
+ .select_append { sum(Sequel.function(:coalesce, :value_int, :value_float)).as(:tokens) }
84
+ .order(Sequel.desc(:tokens))
85
+ .all
86
+ rows.map { |r|
87
+ {model: r[:model] || "unknown", type: r[:type] || "unknown", tokens: r[:tokens].to_i}
88
+ }
89
+ end
90
+
91
+ def top_tools(events)
92
+ return [] if events.nil?
93
+ tool_expr = Sequel.lit("json_extract(attributes_json, '$.tool_name')")
94
+ duration_expr = Sequel.lit("json_extract(attributes_json, '$.duration_ms')")
95
+ rows = events
96
+ .where(event_name: OTel::EventName::TOOL_RESULT)
97
+ .select_group(tool_expr.as(:tool))
98
+ .select_append { count(id).as(:count) }
99
+ .select_append { avg(duration_expr).as(:avg_duration_ms) }
100
+ .order(Sequel.desc(:avg_duration_ms))
101
+ .limit(TOP_TOOLS_LIMIT)
102
+ .all
103
+ rows.map { |r|
104
+ {tool: r[:tool] || "unknown", count: r[:count].to_i, avg_duration_ms: r[:avg_duration_ms].to_i}
105
+ }
106
+ end
107
+
108
+ def error_rate(events)
109
+ return {total: 0, errors: 0, ratio: 0.0} if events.nil?
110
+ total = events.where(event_name: OTel::EventName::API_PAIR).count
111
+ errors = events.where(event_name: OTel::EventName::API_ERROR).count
112
+ ratio = total.zero? ? 0.0 : (errors.to_f / total).round(4)
113
+ {total: total, errors: errors, ratio: ratio}
114
+ end
115
+
116
+ def recent_metrics(metrics)
117
+ rows = metrics
118
+ .where(name: OTel::MetricName::TOKEN_USAGE)
119
+ .order(Sequel.desc(:recorded_at))
120
+ .limit(100)
121
+ .all
122
+ rows.map { |row|
123
+ attrs = OTel::Attributes.from_json(row[:attributes_json])
124
+ {
125
+ recorded_at: row[:recorded_at],
126
+ model: attrs.model,
127
+ type: attrs.token_type,
128
+ tokens: OTel::Attributes.token_count(row),
129
+ session_id: attrs.session_id,
130
+ prompt_id: attrs.prompt_id
131
+ }.compact
132
+ }
133
+ end
134
+
135
+ def events_dataset(store, cutoff)
136
+ return nil unless store.db.table_exists?(:otel_events)
137
+ store.otel_events.where { occurred_at >= cutoff }
138
+ end
139
+
140
+ # SQL pre-filter via LIKE on each prompt-content key, short-circuited
141
+ # by .any?. JSON encodes object keys as `"key":` (compact), so the
142
+ # patterns can't false-match longer keys (e.g. "prompt_length").
143
+ def contains_prompt_content?(events)
144
+ return false if events.nil?
145
+ clauses = OTel::Attributes::PROMPT_CONTENT_KEYS.map { |key|
146
+ Sequel.lit("attributes_json LIKE ?", %("#{key}":))
147
+ }
148
+ events
149
+ .where(event_name: OTel::EventName::PROMPT_BODY_FAMILY)
150
+ .where(Sequel.|(*clauses))
151
+ .limit(1)
152
+ .any?
153
+ end
154
+ end
155
+ end
156
+ end
@@ -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
@@ -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)
@@ -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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../core/result"
4
+
5
+ module ClaudeMemory
6
+ module OTel
7
+ # Imperative shell for OTel ingestion. Takes the parsed-row hashes
8
+ # produced by OtlpJsonEnvelope and writes them in a single batched
9
+ # transaction. Returns Core::Result so the HTTP server can map outcome
10
+ # to status code without rescue clauses.
11
+ #
12
+ # The ingestor accepts a `:metrics`, `:events`, or `:traces` payload —
13
+ # one kind per call, matching how OTLP/HTTP separates the three
14
+ # endpoints. Wrap each batch in transaction_with_retry so a partial
15
+ # failure mid-insert leaves zero rows behind.
16
+ class Ingestor
17
+ def initialize(store)
18
+ @store = store
19
+ end
20
+
21
+ # @param payload [Hash] one of {metrics: [...]}, {events: [...]},
22
+ # {traces: [...]}. Other keys are ignored.
23
+ # @return [Core::Result] success carries inserted-count Hash;
24
+ # failure carries an error message
25
+ def ingest(payload)
26
+ return Core::Result.failure("payload must be a Hash") unless payload.is_a?(Hash)
27
+
28
+ counts = {metrics: 0, events: 0, traces: 0}
29
+ @store.transaction_with_retry do
30
+ counts[:metrics] = insert_metrics(payload[:metrics] || payload["metrics"])
31
+ counts[:events] = insert_events(payload[:events] || payload["events"])
32
+ counts[:traces] = insert_traces(payload[:traces] || payload["traces"])
33
+ end
34
+ Core::Result.success(counts)
35
+ rescue Sequel::DatabaseError, Extralite::Error, ArgumentError, KeyError => e
36
+ Core::Result.failure(e.message)
37
+ end
38
+
39
+ private
40
+
41
+ def insert_metrics(rows)
42
+ rows.is_a?(Array) ? @store.bulk_insert_otel_metrics(rows) : 0
43
+ end
44
+
45
+ def insert_events(rows)
46
+ rows.is_a?(Array) ? @store.bulk_insert_otel_events(rows) : 0
47
+ end
48
+
49
+ def insert_traces(rows)
50
+ rows.is_a?(Array) ? @store.bulk_insert_otel_traces(rows) : 0
51
+ end
52
+ end
53
+ end
54
+ end