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.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +42 -64
- data/.claude/skills/release/SKILL.md +44 -6
- data/.claude/skills/study-repo/SKILL.md +15 -0
- data/.claude-plugin/commands/audit-memory.md +68 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +70 -0
- data/CLAUDE.md +20 -5
- data/README.md +64 -2
- data/db/migrations/018_add_otel_telemetry.rb +81 -0
- data/docs/1_0_punchlist.md +522 -89
- data/docs/GETTING_STARTED.md +3 -1
- data/docs/api_stability.md +341 -0
- data/docs/architecture.md +3 -3
- data/docs/audit_runbook.md +209 -0
- data/docs/claude_monitoring.md +956 -0
- data/docs/dashboard.md +23 -3
- data/docs/improvements.md +329 -5
- data/docs/influence/ai-memory-systems-2026.md +403 -0
- data/docs/memory_audit_2026-05-21.md +303 -0
- data/docs/plugin.md +1 -1
- data/docs/quality_review.md +35 -0
- data/lib/claude_memory/audit/checks.rb +239 -0
- data/lib/claude_memory/audit/finding.rb +33 -0
- data/lib/claude_memory/audit/runner.rb +73 -0
- data/lib/claude_memory/commands/audit_command.rb +117 -0
- data/lib/claude_memory/commands/dashboard_command.rb +2 -1
- data/lib/claude_memory/commands/digest_command.rb +95 -3
- data/lib/claude_memory/commands/hook_command.rb +27 -2
- data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
- data/lib/claude_memory/commands/otel_command.rb +240 -0
- data/lib/claude_memory/commands/registry.rb +5 -1
- data/lib/claude_memory/commands/show_command.rb +90 -0
- data/lib/claude_memory/commands/stats_command.rb +94 -2
- data/lib/claude_memory/configuration.rb +60 -0
- data/lib/claude_memory/core/fact_query_builder.rb +1 -0
- data/lib/claude_memory/dashboard/api.rb +8 -0
- data/lib/claude_memory/dashboard/index.html +140 -1
- data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
- data/lib/claude_memory/dashboard/server.rb +86 -0
- data/lib/claude_memory/dashboard/telemetry.rb +156 -0
- data/lib/claude_memory/dashboard/trust.rb +180 -11
- data/lib/claude_memory/deprecations.rb +106 -0
- data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
- data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
- data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
- data/lib/claude_memory/hook/context_injector.rb +11 -2
- data/lib/claude_memory/hook/handler.rb +142 -1
- data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
- data/lib/claude_memory/otel/attributes.rb +118 -0
- data/lib/claude_memory/otel/constants.rb +32 -0
- data/lib/claude_memory/otel/ingestor.rb +54 -0
- data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
- data/lib/claude_memory/otel/prompt_scope.rb +108 -0
- data/lib/claude_memory/otel/settings_writer.rb +122 -0
- data/lib/claude_memory/otel/status.rb +58 -0
- data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
- data/lib/claude_memory/resolve/resolver.rb +30 -3
- data/lib/claude_memory/shortcuts.rb +61 -18
- data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +136 -0
- data/lib/claude_memory/sweep/maintenance.rb +31 -1
- data/lib/claude_memory/sweep/sweeper.rb +6 -0
- data/lib/claude_memory/templates/hooks.example.json +5 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +20 -0
- 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
|