claude_memory 0.11.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 (59) 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 +26 -0
  10. data/CLAUDE.md +9 -2
  11. data/README.md +29 -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 +341 -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/lib/claude_memory/audit/checks.rb +239 -0
  22. data/lib/claude_memory/audit/finding.rb +33 -0
  23. data/lib/claude_memory/audit/runner.rb +73 -0
  24. data/lib/claude_memory/commands/audit_command.rb +117 -0
  25. data/lib/claude_memory/commands/dashboard_command.rb +2 -1
  26. data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
  27. data/lib/claude_memory/commands/otel_command.rb +240 -0
  28. data/lib/claude_memory/commands/registry.rb +4 -1
  29. data/lib/claude_memory/configuration.rb +60 -0
  30. data/lib/claude_memory/core/fact_query_builder.rb +1 -0
  31. data/lib/claude_memory/dashboard/api.rb +8 -0
  32. data/lib/claude_memory/dashboard/index.html +140 -1
  33. data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
  34. data/lib/claude_memory/dashboard/server.rb +86 -0
  35. data/lib/claude_memory/dashboard/telemetry.rb +156 -0
  36. data/lib/claude_memory/deprecations.rb +106 -0
  37. data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
  38. data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
  39. data/lib/claude_memory/hook/context_injector.rb +11 -2
  40. data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
  41. data/lib/claude_memory/otel/attributes.rb +118 -0
  42. data/lib/claude_memory/otel/constants.rb +32 -0
  43. data/lib/claude_memory/otel/ingestor.rb +54 -0
  44. data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
  45. data/lib/claude_memory/otel/prompt_scope.rb +108 -0
  46. data/lib/claude_memory/otel/settings_writer.rb +122 -0
  47. data/lib/claude_memory/otel/status.rb +58 -0
  48. data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
  49. data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
  50. data/lib/claude_memory/resolve/resolver.rb +30 -3
  51. data/lib/claude_memory/shortcuts.rb +61 -18
  52. data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
  53. data/lib/claude_memory/store/schema_manager.rb +1 -1
  54. data/lib/claude_memory/store/sqlite_store.rb +136 -0
  55. data/lib/claude_memory/sweep/maintenance.rb +31 -1
  56. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  57. data/lib/claude_memory/version.rb +1 -1
  58. data/lib/claude_memory.rb +18 -0
  59. metadata +26 -1
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "../core/result"
6
+
7
+ module ClaudeMemory
8
+ module OTel
9
+ # Idempotent reader/writer for the OTel-related env block in
10
+ # .claude/settings.json. Each method returns Core::Result so the CLI
11
+ # can render uniform success/failure output.
12
+ #
13
+ # Settings file shape (Claude Code reads this on session start):
14
+ #
15
+ # {
16
+ # "env": {
17
+ # "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
18
+ # "OTEL_EXPORTER_OTLP_PROTOCOL": "http/json",
19
+ # "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:3377",
20
+ # "OTEL_METRICS_EXPORTER": "otlp",
21
+ # "OTEL_LOGS_EXPORTER": "otlp"
22
+ # }
23
+ # }
24
+ #
25
+ # Traces and prompt-content opt-ins write additional keys; #disable!
26
+ # clears every key this module owns and leaves the rest of the file
27
+ # untouched.
28
+ class SettingsWriter
29
+ DEFAULT_PORT = 3377
30
+
31
+ OWNED_KEYS = %w[
32
+ CLAUDE_CODE_ENABLE_TELEMETRY
33
+ OTEL_EXPORTER_OTLP_PROTOCOL
34
+ OTEL_EXPORTER_OTLP_ENDPOINT
35
+ OTEL_METRICS_EXPORTER
36
+ OTEL_LOGS_EXPORTER
37
+ OTEL_TRACES_EXPORTER
38
+ OTEL_LOG_USER_PROMPTS
39
+ ].freeze
40
+
41
+ def initialize(claude_dir, port: DEFAULT_PORT)
42
+ @claude_dir = claude_dir
43
+ @settings_path = File.join(@claude_dir, "settings.json")
44
+ @port = port
45
+ end
46
+
47
+ attr_reader :settings_path
48
+
49
+ def enable!
50
+ update_env do |env|
51
+ env["CLAUDE_CODE_ENABLE_TELEMETRY"] = "1"
52
+ env["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/json"
53
+ env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:#{@port}"
54
+ env["OTEL_METRICS_EXPORTER"] = "otlp"
55
+ env["OTEL_LOGS_EXPORTER"] = "otlp"
56
+ end
57
+ end
58
+
59
+ def disable!
60
+ update_env do |env|
61
+ OWNED_KEYS.each { |key| env.delete(key) }
62
+ end
63
+ end
64
+
65
+ def enable_traces!
66
+ update_env do |env|
67
+ env["OTEL_TRACES_EXPORTER"] = "otlp"
68
+ end
69
+ end
70
+
71
+ def disable_traces!
72
+ update_env do |env|
73
+ env["OTEL_TRACES_EXPORTER"] = "none"
74
+ end
75
+ end
76
+
77
+ def capture_prompts!
78
+ update_env do |env|
79
+ env["OTEL_LOG_USER_PROMPTS"] = "1"
80
+ end
81
+ end
82
+
83
+ def disable_capture_prompts!
84
+ update_env do |env|
85
+ env.delete("OTEL_LOG_USER_PROMPTS")
86
+ end
87
+ end
88
+
89
+ # Read-only accessor — returns the current OTel-related env values
90
+ # so the CLI's --status subcommand and the dashboard header can
91
+ # render what's configured without re-implementing JSON parsing.
92
+ def current_env
93
+ load_settings.fetch("env", {}).slice(*OWNED_KEYS)
94
+ end
95
+
96
+ private
97
+
98
+ def update_env
99
+ FileUtils.mkdir_p(@claude_dir)
100
+ settings = load_settings
101
+ settings["env"] ||= {}
102
+ yield settings["env"]
103
+ write_settings(settings)
104
+ Core::Result.success(settings["env"].slice(*OWNED_KEYS))
105
+ rescue Errno::EACCES, Errno::ENOSPC, JSON::ParserError => e
106
+ Core::Result.failure("settings.json write failed: #{e.message}")
107
+ end
108
+
109
+ def load_settings
110
+ return {} unless File.exist?(@settings_path)
111
+ raw = File.read(@settings_path)
112
+ return {} if raw.strip.empty?
113
+ parsed = JSON.parse(raw)
114
+ parsed.is_a?(Hash) ? parsed : {}
115
+ end
116
+
117
+ def write_settings(settings)
118
+ File.write(@settings_path, JSON.pretty_generate(settings) + "\n")
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module OTel
5
+ # Single source of truth for "what does telemetry look like right now?"
6
+ # Used by both the `claude-memory otel --status` CLI and the dashboard's
7
+ # Telemetry header. Pure read query — no writes.
8
+ class Status
9
+ def initialize(store, configuration: nil, settings_writer: nil)
10
+ @store = store
11
+ @configuration = configuration || ClaudeMemory::Configuration.new
12
+ @settings_writer = settings_writer
13
+ end
14
+
15
+ # @return [Hash]
16
+ def snapshot
17
+ {
18
+ metric_count: count_safely(:otel_metrics),
19
+ event_count: count_safely(:otel_events),
20
+ trace_count: count_safely(:otel_traces),
21
+ last_metric_at: last_timestamp(:otel_metrics, :recorded_at),
22
+ last_event_at: last_timestamp(:otel_events, :occurred_at),
23
+ last_trace_at: last_timestamp(:otel_traces, :recorded_at),
24
+ traces_enabled: @configuration.otel_traces_enabled?,
25
+ configured_env: configured_env,
26
+ endpoint: configured_endpoint
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ def count_safely(table)
33
+ return 0 unless @store&.db&.table_exists?(table)
34
+ @store.db[table].count
35
+ rescue Sequel::DatabaseError
36
+ 0
37
+ end
38
+
39
+ def last_timestamp(table, column)
40
+ return nil unless @store&.db&.table_exists?(table)
41
+ @store.db[table].max(column)
42
+ rescue Sequel::DatabaseError
43
+ nil
44
+ end
45
+
46
+ def configured_env
47
+ return {} unless @settings_writer
48
+ @settings_writer.current_env
49
+ rescue Errno::ENOENT, JSON::ParserError
50
+ {}
51
+ end
52
+
53
+ def configured_endpoint
54
+ configured_env["OTEL_EXPORTER_OTLP_ENDPOINT"]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module ClaudeMemory
6
+ class Recall
7
+ # Pure function. Given a fact hash, returns a human-readable staleness
8
+ # marker for single-value facts that are old and unconfirmed, or nil.
9
+ #
10
+ # Single-value predicates (uses_database / deployment_platform /
11
+ # auth_method) are exclusive claims — "the project uses X." Claude
12
+ # follows them authoritatively, so a *stale* single-value fact is the
13
+ # most dangerous kind of memory: the 0.12 harm benchmark caught Claude
14
+ # emitting `git push heroku` from a stale deployment_platform fact with
15
+ # no hedge (docs/1_0_punchlist.md #3 / #15). This annotator surfaces the
16
+ # uncertainty inline at context-injection time so Claude can hedge or
17
+ # verify instead of blindly following.
18
+ #
19
+ # Multi-value predicates (convention, decision, uses_framework, …) are
20
+ # NOT annotated: they accumulate, so one stale entry doesn't carry the
21
+ # same authoritative weight, and flagging them would just add noise.
22
+ #
23
+ # A fact is stale-for-injection when BOTH hold:
24
+ # - the claim is old: valid_from (or created_at fallback) is older
25
+ # than threshold_days — a freshly recorded fact is never stale even
26
+ # if it describes something historical, and
27
+ # - it hasn't been confirmed recently: last_recalled_at is null or
28
+ # older than threshold_days — a fact that's been recalled lately is
29
+ # implicitly re-validated by use.
30
+ #
31
+ # No side effects; safe to call per-fact in the context-injection loop.
32
+ module StalenessAnnotator
33
+ module_function
34
+
35
+ DEFAULT_THRESHOLD_DAYS = 180
36
+
37
+ # @param fact [Hash] needs :predicate; reads :valid_from, :created_at,
38
+ # :last_recalled_at when present
39
+ # @param now [Time]
40
+ # @param threshold_days [Integer]
41
+ # @return [String, nil] marker text, or nil when not stale / not guarded
42
+ def marker_for(fact, now: Time.now.utc, threshold_days: DEFAULT_THRESHOLD_DAYS)
43
+ return nil unless Resolve::PredicatePolicy.single?(fact[:predicate].to_s)
44
+
45
+ established = parse_time(fact[:valid_from]) || parse_time(fact[:created_at])
46
+ return nil unless established
47
+
48
+ cutoff = now - threshold_days * 86_400
49
+ return nil unless established < cutoff
50
+
51
+ last_seen = parse_time(fact[:last_recalled_at])
52
+ return nil if last_seen && last_seen >= cutoff
53
+
54
+ months = ((now - established) / (30 * 86_400)).round
55
+ "⚠ stale: recorded #{established.strftime("%Y-%m-%d")}, " \
56
+ "not confirmed in ~#{months}mo — verify before relying"
57
+ end
58
+
59
+ # @return [Boolean] true when marker_for would return a marker
60
+ def stale?(fact, now: Time.now.utc, threshold_days: DEFAULT_THRESHOLD_DAYS)
61
+ !marker_for(fact, now: now, threshold_days: threshold_days).nil?
62
+ end
63
+
64
+ def parse_time(value)
65
+ return nil if value.nil?
66
+ return value.utc if value.is_a?(Time)
67
+ Time.parse(value.to_s).utc
68
+ rescue ArgumentError
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
@@ -62,9 +62,25 @@ module ClaudeMemory
62
62
 
63
63
  # Return the canonical form of a predicate name, applying known
64
64
  # synonym mappings. Leaves unmapped predicates unchanged.
65
+ #
66
+ # Emits a deprecation warning via `ClaudeMemory::Deprecations` when
67
+ # an actual synonym is hit, since the predicate vocabulary is part
68
+ # of the public API contract (`docs/api_stability.md` §6) and
69
+ # silent canonicalization makes the legacy form indistinguishable
70
+ # from the current one. Removal of the SYNONYMS entries is
71
+ # scheduled for `1.0.0`.
65
72
  def self.canonicalize(predicate)
66
73
  return predicate if predicate.nil?
67
- SYNONYMS.fetch(predicate, predicate)
74
+ canonical = SYNONYMS.fetch(predicate, predicate)
75
+ if canonical != predicate
76
+ ClaudeMemory::Deprecations.warn(
77
+ name: "predicate=#{predicate}",
78
+ replacement: "predicate=#{canonical}",
79
+ removed_in: "1.0.0",
80
+ message: "PredicatePolicy::SYNONYMS will be removed; emit canonical predicate names directly."
81
+ )
82
+ end
83
+ canonical
68
84
  end
69
85
 
70
86
  # Return the snapshot section a predicate belongs to.
@@ -120,15 +120,36 @@ module ClaudeMemory
120
120
 
121
121
  # No exact match: for multi-value predicates the new object is
122
122
  # genuinely a new coexisting value. For single-value, either the
123
- # user signaled supersession ("now we use X instead") or the new
124
- # claim contradicts the current one.
123
+ # user signaled supersession ("now we use X instead"), the new
124
+ # claim is example text (silently discarded), or the new claim
125
+ # contradicts the current one (conflict).
125
126
  if PredicatePolicy.single?(fact_data[:predicate])
126
- supersession_signal?(fact_data) ? :supersede : :conflict
127
+ return :supersede if supersession_signal?(fact_data)
128
+ return :discard if example_text_quote?(fact_data)
129
+ :conflict
127
130
  else
128
131
  :insert
129
132
  end
130
133
  end
131
134
 
135
+ # Single-cardinality stack predicates extracted from CLAUDE.md-style
136
+ # example text ('e.g., "this app uses PostgreSQL"') used to create a
137
+ # disputed fact + conflict row every ingest cycle. The
138
+ # ReferenceMaterialDetector handles this for the MCP
139
+ # `store_extraction` path; the resolver-side guard catches the same
140
+ # pattern when Layer-1 NullDistiller produces a stack fact from
141
+ # documentation text. Added 2026-05-21 audit Phase 3.6.
142
+ EXAMPLE_QUOTE_PATTERNS = [
143
+ /\b(?:e\.?g\.?|i\.?e\.?|for example|for instance|such as)[,:]?\s/i,
144
+ /\(\s*(?:e\.?g\.?|i\.?e\.?)[,.]/i
145
+ ].freeze
146
+
147
+ def example_text_quote?(fact_data)
148
+ quote = fact_data[:quote].to_s
149
+ return false if quote.empty?
150
+ EXAMPLE_QUOTE_PATTERNS.any? { |re| quote.match?(re) }
151
+ end
152
+
132
153
  def apply_resolution(resolution, fact_data, subject_id, entity_ids, content_item_id, occurred_at, existing_facts, project_path:, scope:)
133
154
  case resolution
134
155
  when :reinforce
@@ -136,6 +157,12 @@ module ClaudeMemory
136
157
  when :conflict
137
158
  apply_conflict(existing_facts, fact_data, subject_id, content_item_id, occurred_at,
138
159
  project_path: project_path, scope: scope)
160
+ when :discard
161
+ # Silently drop the fact — its quote looked like documentation
162
+ # example text against a single-cardinality predicate that
163
+ # already has a confirmed value. Returning zero across the
164
+ # board keeps the resolver-result accounting consistent.
165
+ {created: 0, superseded: 0, conflicts: 0, provenance: 0}
139
166
  else
140
167
  apply_insert(fact_data, subject_id, entity_ids, content_item_id, occurred_at, existing_facts, resolution,
141
168
  project_path: project_path, scope: scope)
@@ -1,40 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeMemory
4
+ # Predicate-based shortcuts for the three common "give me X" queries that
5
+ # MCP clients (and humans via CLI) expect to be trivially fast and
6
+ # noise-free.
7
+ #
8
+ # Prior implementation did FTS text search ("convention style format
9
+ # pattern prefer") with a hardcoded global-only scope on `conventions`.
10
+ # That produced cross-predicate matches (uses_database rows leaking into
11
+ # the decisions shortcut) and silently dropped every project convention
12
+ # on the floor. Switching to predicate-based filtering at the store level
13
+ # eliminates both classes of bug; the cost is we no longer rank by FTS
14
+ # relevance, but for "list the project's conventions" that's the correct
15
+ # trade.
4
16
  class Shortcuts
5
- QUERIES = {
17
+ SHORTCUTS = {
6
18
  decisions: {
7
- query: "decision constraint rule requirement",
8
- scope: "all",
19
+ predicates: %w[decision],
9
20
  limit: 10
10
21
  },
11
22
  architecture: {
12
- query: "uses framework implements architecture pattern",
13
- scope: "all",
23
+ # Includes the stack-shaping predicates so an agent asking
24
+ # "what's the architecture?" gets both narrative architecture
25
+ # facts AND the constraints (uses_database, uses_framework, ...).
26
+ # Without these the shortcut returns only freeform architecture
27
+ # facts and the constraints section stays invisible.
28
+ predicates: %w[architecture uses_database uses_framework uses_language deployment_platform auth_method],
14
29
  limit: 10
15
30
  },
16
31
  conventions: {
17
- query: "convention style format pattern prefer",
18
- scope: "global",
32
+ predicates: %w[convention],
19
33
  limit: 20
20
34
  },
21
35
  project_config: {
22
- query: "uses requires depends_on configuration",
23
- scope: "project",
36
+ predicates: %w[uses_database uses_framework uses_language deployment_platform auth_method],
24
37
  limit: 10
25
38
  }
26
39
  }.freeze
27
40
 
41
+ # @param shortcut_name [Symbol] :decisions, :architecture, :conventions, or :project_config
42
+ # @param manager [Store::StoreManager] dual-database manager
43
+ # @param overrides [Hash] :limit override
44
+ # @return [Array<Hash>] result hashes with :fact, :receipts (empty), :source ("project"/"global")
28
45
  def self.for(shortcut_name, manager, **overrides)
29
- config = QUERIES.fetch(shortcut_name)
30
- options = config.merge(overrides)
31
-
32
- recall = Recall.new(manager)
33
- recall.query(
34
- options[:query],
35
- limit: options[:limit],
36
- scope: options[:scope]
37
- )
46
+ config = SHORTCUTS.fetch(shortcut_name)
47
+ limit = overrides[:limit] || config[:limit]
48
+ predicates = config[:predicates]
49
+
50
+ collect_facts(manager, predicates, limit)
38
51
  end
39
52
 
40
53
  def self.decisions(manager, **overrides)
@@ -52,5 +65,35 @@ module ClaudeMemory
52
65
  def self.project_config(manager, **overrides)
53
66
  self.for(:project_config, manager, **overrides)
54
67
  end
68
+
69
+ # Query both stores for active facts matching the given predicates.
70
+ # Project facts take precedence (returned first); global facts fill
71
+ # any remaining slots up to the limit. Does NOT create missing DBs —
72
+ # callers like activity_logging's "orphan manager" depend on this
73
+ # surface staying read-only when the project DB hasn't been
74
+ # initialized yet.
75
+ def self.collect_facts(manager, predicates, limit)
76
+ project_store = manager.store_if_exists("project")
77
+ global_store = manager.store_if_exists("global")
78
+
79
+ project_rows = fetch_active_facts(project_store, predicates, limit)
80
+ global_rows = fetch_active_facts(global_store, predicates, limit)
81
+
82
+ results = project_rows.map { |row| {fact: row, receipts: [], source: "project"} } +
83
+ global_rows.map { |row| {fact: row, receipts: [], source: "global"} }
84
+
85
+ results.first(limit)
86
+ end
87
+
88
+ def self.fetch_active_facts(store, predicates, limit)
89
+ return [] unless store
90
+
91
+ Core::FactQueryBuilder.build_facts_dataset(store)
92
+ .where(Sequel[:facts][:predicate] => predicates,
93
+ Sequel[:facts][:status] => "active")
94
+ .reverse_order(Sequel[:facts][:id])
95
+ .limit(limit)
96
+ .all
97
+ end
55
98
  end
56
99
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Store
5
+ # Cross-store query for the dashboard's Prompt Journey panel. OTel
6
+ # events live in the global DB (writes hit the receiver, which is
7
+ # process-wide); activity_events with a back-tagged prompt_id can
8
+ # live in either store (hooks fire per-project, so hook_ingest /
9
+ # hook_context rows land in the project DB, while global may carry
10
+ # cross-project events). The query reads from all available stores
11
+ # and orders the merged stream by occurred_at.
12
+ #
13
+ # Accepts either a single store (legacy callers) or a StoreManager.
14
+ # Returns plain row hashes shaped uniformly so the panel renders
15
+ # both sources without branching.
16
+ class PromptJourneyQuery
17
+ def initialize(store_or_manager)
18
+ @stores = if store_or_manager.respond_to?(:project_store) || store_or_manager.respond_to?(:global_store)
19
+ [store_or_manager.respond_to?(:project_store) ? store_or_manager.project_store : nil,
20
+ store_or_manager.respond_to?(:global_store) ? store_or_manager.global_store : nil].compact
21
+ else
22
+ [store_or_manager].compact
23
+ end
24
+ end
25
+
26
+ # @param prompt_id [String] OTel prompt.id UUID
27
+ # @return [Array<Hash>] rows ordered by occurred_at ascending
28
+ def fetch(prompt_id)
29
+ return [] if prompt_id.nil? || prompt_id.empty?
30
+
31
+ rows = @stores.flat_map { |store|
32
+ otel_rows(store, prompt_id) + activity_rows(store, prompt_id)
33
+ }
34
+ rows.sort_by { |r| r[:occurred_at].to_s }
35
+ end
36
+
37
+ private
38
+
39
+ def otel_rows(store, prompt_id)
40
+ return [] unless store&.db&.table_exists?(:otel_events)
41
+ store.otel_events
42
+ .where(prompt_id: prompt_id)
43
+ .order(:occurred_at)
44
+ .limit(500)
45
+ .all
46
+ .map { |row| present_otel(row) }
47
+ end
48
+
49
+ def activity_rows(store, prompt_id)
50
+ return [] unless store&.db&.table_exists?(:activity_events)
51
+ return [] unless store.activity_events.columns.include?(:prompt_id)
52
+ store.activity_events
53
+ .where(prompt_id: prompt_id)
54
+ .order(:occurred_at)
55
+ .limit(500)
56
+ .all
57
+ .map { |row| present_activity(row) }
58
+ end
59
+
60
+ def present_otel(row)
61
+ {
62
+ source: "otel",
63
+ id: row[:id],
64
+ name: row[:event_name],
65
+ session_id: row[:session_id],
66
+ prompt_id: row[:prompt_id],
67
+ occurred_at: row[:occurred_at],
68
+ attributes_json: row[:attributes_json]
69
+ }
70
+ end
71
+
72
+ def present_activity(row)
73
+ {
74
+ source: "activity",
75
+ id: row[:id],
76
+ name: row[:event_type],
77
+ session_id: row[:session_id],
78
+ prompt_id: row[:prompt_id],
79
+ occurred_at: row[:occurred_at],
80
+ status: row[:status],
81
+ duration_ms: row[:duration_ms],
82
+ detail_json: row[:detail_json]
83
+ }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -5,7 +5,7 @@ module ClaudeMemory
5
5
  # Schema migration and version management for SQLiteStore.
6
6
  # Handles Sequel migrations, legacy version syncing, and initial setup.
7
7
  module SchemaManager
8
- SCHEMA_VERSION = 17
8
+ SCHEMA_VERSION = 18
9
9
 
10
10
  private
11
11
 
@@ -111,6 +111,15 @@ module ClaudeMemory
111
111
  # @return [Sequel::Dataset]
112
112
  def moment_feedback = @db[:moment_feedback]
113
113
 
114
+ # @return [Sequel::Dataset]
115
+ def otel_metrics = @db[:otel_metrics]
116
+
117
+ # @return [Sequel::Dataset]
118
+ def otel_events = @db[:otel_events]
119
+
120
+ # @return [Sequel::Dataset]
121
+ def otel_traces = @db[:otel_traces]
122
+
114
123
  # Upsert a thumbs-up/down verdict for a moment. One row per event_id
115
124
  # (unique constraint on the column) — repeat clicks overwrite. Returns
116
125
  # the persisted row.
@@ -170,6 +179,133 @@ module ClaudeMemory
170
179
  )
171
180
  end
172
181
 
182
+ # Insert one OTel metric data point. Two value columns let us preserve
183
+ # int64 precision for counters (token counts) without losing fidelity in
184
+ # Float — see migration 018.
185
+ #
186
+ # @param name [String] OTel metric name (e.g. "claude_code.token.usage")
187
+ # @param value_type [String] "int" or "double"
188
+ # @param value_int [Integer, nil] integer value when value_type == "int"
189
+ # @param value_float [Float, nil] float value when value_type == "double"
190
+ # @param unit [String, nil] OTel unit string ("tokens", "USD", "s", ...)
191
+ # @param attributes [Hash, nil] flattened attribute map
192
+ # @param resource [Hash, nil] resource attribute map
193
+ # @param recorded_at [String] ISO 8601 timestamp
194
+ # @return [Integer] inserted row id
195
+ def insert_otel_metric(name:, value_type:, recorded_at:, value_int: nil, value_float: nil,
196
+ unit: nil, attributes: nil, resource: nil)
197
+ otel_metrics.insert(otel_metric_row(
198
+ name: name, value_type: value_type, recorded_at: recorded_at,
199
+ value_int: value_int, value_float: value_float, unit: unit,
200
+ attributes: attributes, resource: resource
201
+ ))
202
+ end
203
+
204
+ # Bulk insert OTel metric rows in a single SQL statement. Hot-path
205
+ # callers (the OTLP receiver) batch dozens of points per request;
206
+ # multi_insert avoids the per-row prepare/bind overhead.
207
+ def bulk_insert_otel_metrics(rows)
208
+ return 0 if rows.empty?
209
+ otel_metrics.multi_insert(rows.map { |r| otel_metric_row(**r) })
210
+ rows.size
211
+ end
212
+
213
+ # Insert one OTel log-style event row.
214
+ #
215
+ # @param event_name [String] e.g. "user_prompt", "tool_result", "api_request"
216
+ # @param occurred_at [String] ISO 8601 timestamp
217
+ # @param session_id [String, nil]
218
+ # @param prompt_id [String, nil] UUID correlating events from one prompt
219
+ # @param attributes [Hash, nil]
220
+ # @param resource [Hash, nil]
221
+ # @return [Integer] inserted row id
222
+ def insert_otel_event(event_name:, occurred_at:, session_id: nil, prompt_id: nil,
223
+ attributes: nil, resource: nil)
224
+ otel_events.insert(otel_event_row(
225
+ event_name: event_name, occurred_at: occurred_at,
226
+ session_id: session_id, prompt_id: prompt_id,
227
+ attributes: attributes, resource: resource
228
+ ))
229
+ end
230
+
231
+ def bulk_insert_otel_events(rows)
232
+ return 0 if rows.empty?
233
+ otel_events.multi_insert(rows.map { |r| otel_event_row(**r) })
234
+ rows.size
235
+ end
236
+
237
+ # Insert one OTel trace span row. Only used when traces are explicitly
238
+ # opted in via Configuration#otel_traces_enabled?.
239
+ #
240
+ # @param trace_id [String]
241
+ # @param span_id [String]
242
+ # @param name [String]
243
+ # @param recorded_at [String]
244
+ # @param parent_span_id [String, nil]
245
+ # @param session_id [String, nil]
246
+ # @param prompt_id [String, nil]
247
+ # @param start_unix_nano [Integer, nil]
248
+ # @param end_unix_nano [Integer, nil]
249
+ # @param duration_ms [Integer, nil]
250
+ # @param status_code [String, nil]
251
+ # @param attributes [Hash, nil]
252
+ # @param resource [Hash, nil]
253
+ # @return [Integer] inserted row id
254
+ def insert_otel_trace_span(trace_id:, span_id:, name:, recorded_at:,
255
+ parent_span_id: nil, session_id: nil, prompt_id: nil,
256
+ start_unix_nano: nil, end_unix_nano: nil, duration_ms: nil,
257
+ status_code: nil, attributes: nil, resource: nil)
258
+ otel_traces.insert(otel_trace_row(
259
+ trace_id: trace_id, span_id: span_id, name: name, recorded_at: recorded_at,
260
+ parent_span_id: parent_span_id, session_id: session_id, prompt_id: prompt_id,
261
+ start_unix_nano: start_unix_nano, end_unix_nano: end_unix_nano,
262
+ duration_ms: duration_ms, status_code: status_code,
263
+ attributes: attributes, resource: resource
264
+ ))
265
+ end
266
+
267
+ def bulk_insert_otel_traces(rows)
268
+ return 0 if rows.empty?
269
+ otel_traces.multi_insert(rows.map { |r| otel_trace_row(**r) })
270
+ rows.size
271
+ end
272
+
273
+ private
274
+
275
+ def otel_metric_row(name:, value_type:, recorded_at:, value_int: nil, value_float: nil,
276
+ unit: nil, attributes: nil, resource: nil)
277
+ {
278
+ name: name, value_type: value_type, value_int: value_int, value_float: value_float,
279
+ unit: unit, attributes_json: attributes&.to_json, resource_json: resource&.to_json,
280
+ recorded_at: recorded_at
281
+ }
282
+ end
283
+
284
+ def otel_event_row(event_name:, occurred_at:, session_id: nil, prompt_id: nil,
285
+ attributes: nil, resource: nil)
286
+ {
287
+ event_name: event_name, session_id: session_id, prompt_id: prompt_id,
288
+ attributes_json: attributes&.to_json, resource_json: resource&.to_json,
289
+ occurred_at: occurred_at
290
+ }
291
+ end
292
+
293
+ def otel_trace_row(trace_id:, span_id:, name:, recorded_at:,
294
+ parent_span_id: nil, session_id: nil, prompt_id: nil,
295
+ start_unix_nano: nil, end_unix_nano: nil, duration_ms: nil,
296
+ status_code: nil, attributes: nil, resource: nil)
297
+ {
298
+ trace_id: trace_id, span_id: span_id, parent_span_id: parent_span_id,
299
+ name: name, session_id: session_id, prompt_id: prompt_id,
300
+ start_unix_nano: start_unix_nano, end_unix_nano: end_unix_nano,
301
+ duration_ms: duration_ms, status_code: status_code,
302
+ attributes_json: attributes&.to_json, resource_json: resource&.to_json,
303
+ recorded_at: recorded_at
304
+ }
305
+ end
306
+
307
+ public
308
+
173
309
  # --- Content items ---
174
310
 
175
311
  # Insert a content item or return the existing id if a duplicate