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
@@ -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
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module ClaudeMemory
6
+ module OTel
7
+ # Pure functional core for OTLP/HTTP/JSON payloads. Walks the canonical
8
+ # OTLP envelope shapes (resourceMetrics → scopeMetrics → metrics →
9
+ # dataPoints; resourceLogs → scopeLogs → logRecords; resourceSpans →
10
+ # scopeSpans → spans), flattens KeyValue attribute arrays into Ruby
11
+ # hashes, and returns plain row hashes ready to insert.
12
+ #
13
+ # No Time.now, no ENV reads, no DB. Pass a clock object that responds
14
+ # to `now` (or just `Time`) for fallback timestamps when the payload
15
+ # omits one. Required keys raise via Hash#fetch; optional containers
16
+ # default to empty arrays.
17
+ #
18
+ # All public methods return Arrays of Hashes whose keys match the
19
+ # SQLiteStore.insert_otel_* helpers exactly.
20
+ module OtlpJsonEnvelope
21
+ module_function
22
+
23
+ # @param payload [Hash] parsed OTLP MetricsServiceRequest JSON
24
+ # @param clock [#now] used for fallback when timeUnixNano is missing
25
+ # @return [Array<Hash>] rows for SQLiteStore#insert_otel_metric
26
+ def parse_metrics(payload, clock: Time)
27
+ rows = []
28
+ Array(payload["resourceMetrics"]).each do |resource_metric|
29
+ resource = flatten_attributes(dig_attributes(resource_metric["resource"]))
30
+ Array(resource_metric["scopeMetrics"]).each do |scope_metric|
31
+ Array(scope_metric["metrics"]).each do |metric|
32
+ metric_name = metric.fetch("name")
33
+ unit = metric["unit"]
34
+ data_points = collect_data_points(metric)
35
+ data_points.each do |point|
36
+ value_type, value_int, value_float = decode_metric_value(point)
37
+ next if value_type.nil?
38
+
39
+ rows << {
40
+ name: metric_name,
41
+ value_type: value_type,
42
+ value_int: value_int,
43
+ value_float: value_float,
44
+ unit: unit,
45
+ attributes: flatten_attributes(point["attributes"]),
46
+ resource: resource,
47
+ recorded_at: timestamp_from_point(point, clock)
48
+ }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ rows
54
+ end
55
+
56
+ # @param payload [Hash] parsed OTLP LogsServiceRequest JSON
57
+ # @param clock [#now]
58
+ # @return [Array<Hash>] rows for SQLiteStore#insert_otel_event
59
+ def parse_logs(payload, clock: Time)
60
+ rows = []
61
+ Array(payload["resourceLogs"]).each do |resource_log|
62
+ resource = flatten_attributes(dig_attributes(resource_log["resource"]))
63
+ Array(resource_log["scopeLogs"]).each do |scope_log|
64
+ Array(scope_log["logRecords"]).each do |record|
65
+ attributes = flatten_attributes(record["attributes"])
66
+ rows << {
67
+ event_name: event_name_for(record, attributes),
68
+ occurred_at: timestamp_from_record(record, clock),
69
+ session_id: attributes["session.id"],
70
+ prompt_id: attributes["prompt.id"],
71
+ attributes: attributes,
72
+ resource: resource
73
+ }
74
+ end
75
+ end
76
+ end
77
+ rows
78
+ end
79
+
80
+ # @param payload [Hash] parsed OTLP TracesServiceRequest JSON
81
+ # @param clock [#now]
82
+ # @return [Array<Hash>] rows for SQLiteStore#insert_otel_trace_span
83
+ def parse_traces(payload, clock: Time)
84
+ rows = []
85
+ Array(payload["resourceSpans"]).each do |resource_span|
86
+ resource = flatten_attributes(dig_attributes(resource_span["resource"]))
87
+ Array(resource_span["scopeSpans"]).each do |scope_span|
88
+ Array(scope_span["spans"]).each do |span|
89
+ attributes = flatten_attributes(span["attributes"])
90
+ start_nano = parse_unix_nano(span["startTimeUnixNano"])
91
+ end_nano = parse_unix_nano(span["endTimeUnixNano"])
92
+ rows << {
93
+ trace_id: span.fetch("traceId"),
94
+ span_id: span.fetch("spanId"),
95
+ parent_span_id: span["parentSpanId"],
96
+ name: span.fetch("name"),
97
+ session_id: attributes["session.id"],
98
+ prompt_id: attributes["prompt.id"],
99
+ start_unix_nano: start_nano,
100
+ end_unix_nano: end_nano,
101
+ duration_ms: duration_ms_from(start_nano, end_nano),
102
+ status_code: span.dig("status", "code")&.to_s,
103
+ attributes: attributes,
104
+ resource: resource,
105
+ recorded_at: timestamp_from_unix_nano(start_nano, clock)
106
+ }
107
+ end
108
+ end
109
+ end
110
+ rows
111
+ end
112
+
113
+ # Flatten OTel KeyValue array (`[{key:, value: {stringValue: ...}}, ...]`)
114
+ # into a plain Hash.
115
+ def flatten_attributes(kv_array)
116
+ result = {}
117
+ Array(kv_array).each do |kv|
118
+ next unless kv.is_a?(Hash)
119
+ key = kv["key"]
120
+ next if key.nil? || key.empty?
121
+ result[key] = decode_any_value(kv["value"])
122
+ end
123
+ result
124
+ end
125
+
126
+ def decode_any_value(value)
127
+ return nil unless value.is_a?(Hash)
128
+ return value["stringValue"] if value.key?("stringValue")
129
+ # OTLP JSON encodes int64 as a string to avoid JS precision loss.
130
+ return decode_int_string(value["intValue"]) if value.key?("intValue")
131
+ return value["doubleValue"] if value.key?("doubleValue")
132
+ return value["boolValue"] if value.key?("boolValue")
133
+ if value.key?("arrayValue")
134
+ values = value.dig("arrayValue", "values") || []
135
+ return values.map { |v| decode_any_value(v) }
136
+ end
137
+ if value.key?("kvlistValue")
138
+ kvs = value.dig("kvlistValue", "values") || []
139
+ return flatten_attributes(kvs)
140
+ end
141
+ nil
142
+ end
143
+
144
+ private_class_method :decode_any_value
145
+
146
+ def decode_int_string(value)
147
+ return value if value.is_a?(Integer)
148
+ return nil if value.nil?
149
+ Integer(value.to_s, 10)
150
+ rescue ArgumentError
151
+ nil
152
+ end
153
+
154
+ private_class_method :decode_int_string
155
+
156
+ def dig_attributes(container)
157
+ container.is_a?(Hash) ? container["attributes"] : nil
158
+ end
159
+
160
+ private_class_method :dig_attributes
161
+
162
+ # Pull dataPoints out of whichever metric type wrapper is present.
163
+ # Histograms and summaries (rare in Claude Code's exports) return
164
+ # their data points; we record the count value when present.
165
+ def collect_data_points(metric)
166
+ %w[sum gauge histogram exponentialHistogram summary].each do |kind|
167
+ wrapper = metric[kind]
168
+ next unless wrapper.is_a?(Hash)
169
+ points = wrapper["dataPoints"]
170
+ return Array(points) if points
171
+ end
172
+ []
173
+ end
174
+
175
+ private_class_method :collect_data_points
176
+
177
+ # Returns [value_type, value_int, value_float] tuple. nil value_type
178
+ # means we couldn't decode anything storable.
179
+ def decode_metric_value(point)
180
+ if point.key?("asInt")
181
+ int_value = decode_int_string(point["asInt"])
182
+ return [ValueType::INT, int_value, nil] unless int_value.nil?
183
+ end
184
+ if point.key?("asDouble")
185
+ d = point["asDouble"]
186
+ return [ValueType::DOUBLE, nil, d.to_f] unless d.nil?
187
+ end
188
+ # Histogram / summary fall-back: store sum when present, count
189
+ # otherwise. Skip when neither is available.
190
+ if point.key?("sum")
191
+ s = point["sum"]
192
+ return [ValueType::DOUBLE, nil, s.to_f] unless s.nil?
193
+ end
194
+ if point.key?("count")
195
+ count = decode_int_string(point["count"])
196
+ return [ValueType::INT, count, nil] unless count.nil?
197
+ end
198
+ [nil, nil, nil]
199
+ end
200
+
201
+ private_class_method :decode_metric_value
202
+
203
+ def event_name_for(record, attributes)
204
+ # OTel logs/events: the event name lives on the attribute
205
+ # "event.name" by convention. Claude Code sets it as
206
+ # "claude_code.<name>" on its instrumentation scope, so we strip
207
+ # the prefix when present so panels see "user_prompt", not
208
+ # "claude_code.user_prompt".
209
+ raw = attributes["event.name"] || record["eventName"] || record.dig("body", "stringValue") || "log"
210
+ raw.to_s.sub(/\Aclaude_code\./, "")
211
+ end
212
+
213
+ private_class_method :event_name_for
214
+
215
+ def timestamp_from_point(point, clock)
216
+ nano = parse_unix_nano(point["timeUnixNano"]) || parse_unix_nano(point["startTimeUnixNano"])
217
+ timestamp_from_unix_nano(nano, clock)
218
+ end
219
+
220
+ private_class_method :timestamp_from_point
221
+
222
+ def timestamp_from_record(record, clock)
223
+ nano = parse_unix_nano(record["timeUnixNano"]) || parse_unix_nano(record["observedTimeUnixNano"])
224
+ timestamp_from_unix_nano(nano, clock)
225
+ end
226
+
227
+ private_class_method :timestamp_from_record
228
+
229
+ def timestamp_from_unix_nano(nano, clock)
230
+ return clock.now.utc.iso8601 if nano.nil?
231
+ Time.at(nano / 1_000_000_000.0).utc.iso8601
232
+ end
233
+
234
+ private_class_method :timestamp_from_unix_nano
235
+
236
+ def parse_unix_nano(value)
237
+ return nil if value.nil?
238
+ return value if value.is_a?(Integer)
239
+ Integer(value.to_s, 10)
240
+ rescue ArgumentError
241
+ nil
242
+ end
243
+
244
+ private_class_method :parse_unix_nano
245
+
246
+ def duration_ms_from(start_nano, end_nano)
247
+ return nil if start_nano.nil? || end_nano.nil?
248
+ ((end_nano - start_nano) / 1_000_000).to_i
249
+ end
250
+
251
+ private_class_method :duration_ms_from
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module OTel
5
+ # Back-tags activity_events with the OTel prompt.id after telemetry
6
+ # events arrive. Hook events (hook_ingest, hook_context, ...) fire
7
+ # ~immediately as the user's turn closes, but Claude Code batches OTel
8
+ # exports on the OTEL_METRIC_EXPORT_INTERVAL (default 60s), so by the
9
+ # time we see a prompt.id on the receiver, the activity_events for that
10
+ # turn already exist with prompt_id = NULL.
11
+ #
12
+ # Tagging strategy per (prompt_id, session_id) group:
13
+ # 1. session_id-match path — for activity_events carrying the same
14
+ # session_id, update those that fall in the prompt's time window.
15
+ # Hook events (hook_ingest, hook_context, hook_sweep, hook_publish,
16
+ # roi_nudge) reliably carry session_id from the Claude Code hook
17
+ # payload.
18
+ # 2. time-window path — for activity_events with NULL session_id
19
+ # (recall, store_extraction — MCP-originated; Claude Code doesn't
20
+ # thread session_id into plugin MCP calls per reference_mcp_session
21
+ # _id_gap), tag by occurred_at falling in the prompt window only.
22
+ #
23
+ # Operates on both project_store and global_store when available.
24
+ # Cross-project tagging (projects other than the dashboard's loaded one)
25
+ # is out of scope — dashboard is per-project and other project DBs
26
+ # aren't in the manager.
27
+ class PromptScope
28
+ # Bound the prompt window so a long-running turn doesn't sweep up
29
+ # later activity events that belong to the NEXT prompt. The OTel
30
+ # spec only emits prompt.id between user_prompt and the next
31
+ # user_prompt, so the natural max is implicit; we add a safety
32
+ # ceiling.
33
+ MAX_WINDOW_SECONDS = 600
34
+ # Buffer added after the latest OTel event because hook_ingest fires
35
+ # AFTER the Stop event, which can be a few seconds after the last
36
+ # api_request.
37
+ POST_WINDOW_BUFFER_SECONDS = 30
38
+
39
+ def initialize(manager)
40
+ @manager = manager
41
+ end
42
+
43
+ # @param events [Array<Hash>] just-persisted OTel event rows
44
+ # (each carrying :prompt_id, :session_id, :occurred_at) — the
45
+ # same shape OtlpJsonEnvelope.parse_logs returns.
46
+ # @return [Hash] {tagged: count, groups: count}
47
+ def tag(events)
48
+ return {tagged: 0, groups: 0} if events.nil? || events.empty?
49
+
50
+ groups = group_by_prompt(events)
51
+ return {tagged: 0, groups: 0} if groups.empty?
52
+
53
+ tagged = 0
54
+ groups.each do |key, range|
55
+ prompt_id, session_id = key
56
+ [@manager.project_store, @manager.global_store].compact.each do |store|
57
+ tagged += tag_in_store(store, prompt_id, session_id, range)
58
+ end
59
+ end
60
+ {tagged: tagged, groups: groups.size}
61
+ rescue Sequel::DatabaseError => e
62
+ ClaudeMemory.logger.debug("prompt_scope tag failed: #{e.message}")
63
+ {tagged: 0, groups: 0, error: e.message}
64
+ end
65
+
66
+ private
67
+
68
+ # Returns {[prompt_id, session_id_or_nil] => (lo..hi)} where lo/hi
69
+ # are ISO 8601 strings derived from event occurred_at values.
70
+ def group_by_prompt(events)
71
+ events
72
+ .select { |e| e[:prompt_id] && !e[:prompt_id].to_s.empty? }
73
+ .group_by { |e| [e[:prompt_id], e[:session_id]] }
74
+ .each_with_object({}) do |(key, group), out|
75
+ timestamps = group.map { |e| e[:occurred_at] }.compact.sort
76
+ next if timestamps.empty?
77
+ lo = timestamps.first
78
+ hi = (Time.parse(timestamps.last) + POST_WINDOW_BUFFER_SECONDS).utc.iso8601
79
+ # Cap window length to MAX_WINDOW_SECONDS so a stale event
80
+ # batch can't sweep a wide range.
81
+ ceiling = (Time.parse(lo) + MAX_WINDOW_SECONDS).utc.iso8601
82
+ hi = [hi, ceiling].min
83
+ out[key] = (lo..hi)
84
+ end
85
+ end
86
+
87
+ def tag_in_store(store, prompt_id, session_id, range)
88
+ return 0 unless store&.db&.table_exists?(:activity_events)
89
+ return 0 unless store.activity_events.columns.include?(:prompt_id)
90
+
91
+ tagged_by_session = 0
92
+ if session_id && !session_id.to_s.empty?
93
+ tagged_by_session = store.activity_events
94
+ .where(session_id: session_id, prompt_id: nil)
95
+ .where { (occurred_at >= range.first) & (occurred_at <= range.last) }
96
+ .update(prompt_id: prompt_id)
97
+ end
98
+
99
+ tagged_by_window = store.activity_events
100
+ .where(session_id: nil, prompt_id: nil)
101
+ .where { (occurred_at >= range.first) & (occurred_at <= range.last) }
102
+ .update(prompt_id: prompt_id)
103
+
104
+ tagged_by_session + tagged_by_window
105
+ end
106
+ end
107
+ end
108
+ end