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,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
@@ -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