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
@@ -1044,6 +1044,16 @@
1044
1044
  }
1045
1045
  .knowledge-card .kc-meta .src.project { background: var(--accent-dim); color: var(--accent); }
1046
1046
  .knowledge-card .kc-meta .src.global { background: var(--purple-dim); color: var(--purple); }
1047
+ /* Generic .src badge used by Prompt Journey rows; knowledge-card rule above
1048
+ wins by specificity for project/global tags inside knowledge cards. */
1049
+ .src {
1050
+ font-family: var(--mono);
1051
+ font-size: 10px;
1052
+ padding: 1px 6px;
1053
+ border-radius: 3px;
1054
+ }
1055
+ .src.otel { background: var(--accent-dim); color: var(--accent); }
1056
+ .src.activity { background: var(--purple-dim); color: var(--purple); }
1047
1057
  .knowledge-card.highlighted {
1048
1058
  border-color: var(--accent);
1049
1059
  box-shadow: 0 0 0 3px var(--accent-dim);
@@ -1230,6 +1240,7 @@
1230
1240
  <div class="drawer-tab" data-adv="efficacy">Efficacy</div>
1231
1241
  <div class="drawer-tab" data-adv="conflicts">Conflicts</div>
1232
1242
  <div class="drawer-tab" data-adv="activity">Raw log</div>
1243
+ <div class="drawer-tab" data-adv="telemetry">Telemetry</div>
1233
1244
  </div>
1234
1245
 
1235
1246
  <div class="drawer-panel active" id="adv-knowledge">
@@ -1360,6 +1371,54 @@
1360
1371
  </table>
1361
1372
  </div>
1362
1373
  </div>
1374
+
1375
+ <div class="drawer-panel" id="adv-telemetry">
1376
+ <div class="adv-card" id="telemetry-status" style="font-size: 12px; color: var(--text-dim);">
1377
+ Loading telemetry status...
1378
+ </div>
1379
+ <div class="adv-card" style="font-size: 11px; color: var(--text-dim);">
1380
+ Capturing only metrics by default. Prompts and bodies require explicit opt-in via
1381
+ <code>claude-memory otel --capture-prompts</code>.
1382
+ </div>
1383
+ <div class="adv-card">
1384
+ <h3 style="margin-top: 0;">Cost (last 7 days, hourly)</h3>
1385
+ <table>
1386
+ <thead><tr><th>Hour</th><th style="text-align: right;">Requests</th><th style="text-align: right;">Cost USD</th></tr></thead>
1387
+ <tbody id="telemetry-cost-tbody"></tbody>
1388
+ </table>
1389
+ </div>
1390
+ <div class="adv-card">
1391
+ <h3 style="margin-top: 0;">Tokens by model</h3>
1392
+ <table>
1393
+ <thead><tr><th>Model</th><th>Type</th><th style="text-align: right;">Tokens</th></tr></thead>
1394
+ <tbody id="telemetry-tokens-tbody"></tbody>
1395
+ </table>
1396
+ </div>
1397
+ <div class="adv-card">
1398
+ <h3 style="margin-top: 0;">Top tools by latency</h3>
1399
+ <table>
1400
+ <thead><tr><th>Tool</th><th style="text-align: right;">Calls</th><th style="text-align: right;">Avg duration (ms)</th></tr></thead>
1401
+ <tbody id="telemetry-tools-tbody"></tbody>
1402
+ </table>
1403
+ </div>
1404
+ <div class="adv-card">
1405
+ <h3 style="margin-top: 0;">Recent token-usage points</h3>
1406
+ <table>
1407
+ <thead><tr><th>Recorded at</th><th>Model</th><th>Type</th><th style="text-align: right;">Tokens</th><th>Prompt</th></tr></thead>
1408
+ <tbody id="telemetry-recent-tbody"></tbody>
1409
+ </table>
1410
+ </div>
1411
+ </div>
1412
+ </div>
1413
+
1414
+ <div class="modal-backdrop" id="prompt-journey-modal" role="dialog" aria-modal="true">
1415
+ <div class="modal" role="document">
1416
+ <div class="modal-header">
1417
+ <h2 id="prompt-journey-title">Prompt journey</h2>
1418
+ <button class="modal-close" aria-label="Close" onclick="closeModal('prompt-journey-modal')">&times;</button>
1419
+ </div>
1420
+ <div id="prompt-journey-body"></div>
1421
+ </div>
1363
1422
  </div>
1364
1423
 
1365
1424
  <div id="toast" class="toast"></div>
@@ -2121,7 +2180,87 @@ function switchAdvTab(name) {
2121
2180
 
2122
2181
  // ==================== Advanced drawer loaders ====================
2123
2182
  async function loadAdvanced() {
2124
- await Promise.all([loadKnowledge(), loadOverview(), loadFacts(), loadEfficacy(), loadConflicts(), loadActivityLog()]);
2183
+ await Promise.all([loadKnowledge(), loadOverview(), loadFacts(), loadEfficacy(), loadConflicts(), loadActivityLog(), loadTelemetry()]);
2184
+ }
2185
+
2186
+ async function loadTelemetry() {
2187
+ const data = await api('telemetry');
2188
+
2189
+ const status = data.status || {};
2190
+ const statusEl = document.getElementById('telemetry-status');
2191
+ const endpoint = status.endpoint ? esc(status.endpoint) : '<em>not configured</em>';
2192
+ const banner = data.contains_prompt_content
2193
+ ? '<div style="color: #c4863c; margin-top: 4px;">Captured payload contains prompt or body content. Disable with <code>claude-memory otel --no-capture-prompts</code>.</div>'
2194
+ : '';
2195
+ statusEl.innerHTML = `
2196
+ Endpoint: ${endpoint}<br>
2197
+ Metrics: ${status.metric_count || 0} · Events: ${status.event_count || 0} · Traces: ${status.trace_count || 0} (enabled: ${status.traces_enabled ? 'yes' : 'no'})<br>
2198
+ Last metric: ${status.last_metric_at ? esc(status.last_metric_at) : 'never'}
2199
+ ${banner}
2200
+ `;
2201
+
2202
+ const costTbody = document.getElementById('telemetry-cost-tbody');
2203
+ const cost = data.cost_over_time || [];
2204
+ costTbody.innerHTML = cost.length === 0
2205
+ ? `<tr><td colspan="3" style="color: var(--text-faint);">No cost metrics yet. Run <code>claude-memory otel --enable</code> and start a Claude session.</td></tr>`
2206
+ : cost.map(row => `
2207
+ <tr><td>${esc(row.hour)}</td><td style="text-align: right;">${row.requests}</td><td style="text-align: right;">$${row.cost_usd.toFixed(4)}</td></tr>
2208
+ `).join('');
2209
+
2210
+ const tokensTbody = document.getElementById('telemetry-tokens-tbody');
2211
+ const tokens = data.tokens_by_model || [];
2212
+ tokensTbody.innerHTML = tokens.length === 0
2213
+ ? `<tr><td colspan="3" style="color: var(--text-faint);">No token usage yet.</td></tr>`
2214
+ : tokens.map(row => `
2215
+ <tr><td>${esc(row.model)}</td><td>${esc(row.type)}</td><td style="text-align: right;">${row.tokens.toLocaleString()}</td></tr>
2216
+ `).join('');
2217
+
2218
+ const toolsTbody = document.getElementById('telemetry-tools-tbody');
2219
+ const tools = data.top_tools_by_latency || [];
2220
+ toolsTbody.innerHTML = tools.length === 0
2221
+ ? `<tr><td colspan="3" style="color: var(--text-faint);">No tool_result events yet.</td></tr>`
2222
+ : tools.map(row => `
2223
+ <tr><td>${esc(row.tool)}</td><td style="text-align: right;">${row.count}</td><td style="text-align: right;">${row.avg_duration_ms}</td></tr>
2224
+ `).join('');
2225
+
2226
+ const recentTbody = document.getElementById('telemetry-recent-tbody');
2227
+ const recent = data.recent_metrics || [];
2228
+ recentTbody.innerHTML = recent.length === 0
2229
+ ? `<tr><td colspan="5" style="color: var(--text-faint);">No recent token data points.</td></tr>`
2230
+ : recent.map(row => {
2231
+ const promptCell = row.prompt_id
2232
+ ? `<a href="javascript:void(0)" onclick="openPromptJourney('${esc(row.prompt_id)}')">view</a>`
2233
+ : '';
2234
+ return `<tr>
2235
+ <td>${esc(row.recorded_at || '')}</td>
2236
+ <td>${esc(row.model || '')}</td>
2237
+ <td>${esc(row.type || '')}</td>
2238
+ <td style="text-align: right;">${(row.tokens || 0).toLocaleString()}</td>
2239
+ <td>${promptCell}</td>
2240
+ </tr>`;
2241
+ }).join('');
2242
+ }
2243
+
2244
+ async function openPromptJourney(promptId) {
2245
+ const data = await api('prompt_journey', {prompt_id: promptId});
2246
+ const body = document.getElementById('prompt-journey-body');
2247
+ document.getElementById('prompt-journey-title').textContent = `Prompt journey · ${promptId}`;
2248
+ const events = data.events || [];
2249
+ body.innerHTML = events.length === 0
2250
+ ? `<div style="padding: 12px; color: var(--text-faint);">No events recorded for prompt ${esc(promptId)} yet.</div>`
2251
+ : `<table>
2252
+ <thead><tr><th>Time</th><th>Source</th><th>Event</th><th>Model / Tool</th><th>Duration</th></tr></thead>
2253
+ <tbody>${events.map(ev => `
2254
+ <tr>
2255
+ <td>${esc(ev.occurred_ago || ev.occurred_at || '')}</td>
2256
+ <td><span class="src ${esc(ev.source)}">${esc(ev.source)}</span></td>
2257
+ <td>${esc(ev.name || '')}</td>
2258
+ <td>${esc(ev.model || ev.tool_name || '')}</td>
2259
+ <td>${ev.duration_ms != null ? `${ev.duration_ms} ms` : ''}</td>
2260
+ </tr>`).join('')}
2261
+ </tbody>
2262
+ </table>`;
2263
+ openModal('prompt-journey-modal');
2125
2264
  }
2126
2265
 
2127
2266
  let knowledgeScope = 'all';
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Dashboard
5
+ # Per-prompt waterfall view. Calls Store::PromptJourneyQuery to UNION
6
+ # otel_events and activity_events on prompt_id, then shapes results
7
+ # for the frontend (relative timestamps, parsed attributes).
8
+ class PromptJourney
9
+ def initialize(manager)
10
+ @manager = manager
11
+ end
12
+
13
+ def for(prompt_id)
14
+ @manager.ensure_global! if @manager.respond_to?(:ensure_global!) && !@manager.global_store
15
+ @manager.ensure_project! if @manager.respond_to?(:ensure_project!) && !@manager.project_store
16
+ return empty_payload(prompt_id) unless @manager.global_store || @manager.project_store
17
+
18
+ rows = ClaudeMemory::Store::PromptJourneyQuery.new(@manager).fetch(prompt_id)
19
+ {
20
+ prompt_id: prompt_id,
21
+ event_count: rows.size,
22
+ events: rows.map { |row| present(row) }
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def empty_payload(prompt_id)
29
+ {prompt_id: prompt_id, event_count: 0, events: []}
30
+ end
31
+
32
+ def present(row)
33
+ attrs = OTel::Attributes.from_json(row[:attributes_json])
34
+ {
35
+ source: row[:source],
36
+ name: row[:name],
37
+ occurred_at: row[:occurred_at],
38
+ occurred_ago: Core::RelativeTime.format(row[:occurred_at]),
39
+ session_id: row[:session_id],
40
+ status: row[:status],
41
+ duration_ms: row[:duration_ms] || attrs.duration_ms,
42
+ model: attrs.model,
43
+ tool_name: attrs.tool_name
44
+ }.compact
45
+ end
46
+ end
47
+ end
48
+ end
@@ -18,6 +18,7 @@ module ClaudeMemory
18
18
  def start
19
19
  @server = WEBrick::HTTPServer.new(
20
20
  Port: @port,
21
+ BindAddress: "127.0.0.1",
21
22
  Logger: WEBrick::Log.new(File::NULL),
22
23
  AccessLog: []
23
24
  )
@@ -67,6 +68,91 @@ module ClaudeMemory
67
68
  @server.mount_proc("/api/trust") { |_req, res| with_fresh_connections { json_response(res, api.trust) } }
68
69
  @server.mount_proc("/api/knowledge") { |req, res| with_fresh_connections { json_response(res, api.knowledge(req.query)) } }
69
70
  @server.mount_proc("/api/reuse") { |req, res| with_fresh_connections { json_response(res, api.reuse(req.query)) } }
71
+ @server.mount_proc("/api/telemetry") { |_req, res| with_fresh_connections { json_response(res, api.telemetry) } }
72
+ @server.mount_proc("/api/prompt_journey") { |req, res|
73
+ with_fresh_connections {
74
+ prompt_id = req.query["prompt_id"].to_s
75
+ json_response(res, api.prompt_journey(prompt_id))
76
+ }
77
+ }
78
+
79
+ # OTel writer routes — high-frequency, no with_fresh_connections.
80
+ # Telemetry exports happen at sub-second cadence; the WAL stale-cache
81
+ # concern that motivates per-request connection release only affects
82
+ # readers.
83
+ @server.mount_proc("/v1/metrics") { |req, res| handle_otel(:metrics, req, res) }
84
+ @server.mount_proc("/v1/logs") { |req, res| handle_otel(:logs, req, res) }
85
+ @server.mount_proc("/v1/traces") { |req, res| handle_otel(:traces, req, res) }
86
+ end
87
+
88
+ # OTLP/HTTP/JSON receiver. Rejects non-JSON content with 415; returns
89
+ # 501 for /v1/traces unless the user opted in via
90
+ # `claude-memory otel --enable-traces`. On parse/persist failure
91
+ # returns 400 with the underlying error message — matches OTLP's
92
+ # tolerant retry semantics so Claude Code's exporter backs off.
93
+ def handle_otel(kind, req, res)
94
+ return otel_response(res, 415, "only application/json is accepted") unless json_content?(req)
95
+ if kind == :traces && !configuration.otel_traces_enabled?
96
+ return otel_response(res, 501, "traces ingestion disabled — run `claude-memory otel --enable-traces`")
97
+ end
98
+
99
+ payload = parse_json_body(req)
100
+ return otel_response(res, 400, "request body was not valid JSON") if payload.nil? || payload == {}
101
+
102
+ store = ensure_global_store
103
+ return otel_response(res, 503, "global store unavailable") unless store
104
+
105
+ rows = case kind
106
+ when :metrics then {metrics: ClaudeMemory::OTel::OtlpJsonEnvelope.parse_metrics(payload)}
107
+ when :logs then {events: ClaudeMemory::OTel::OtlpJsonEnvelope.parse_logs(payload)}
108
+ when :traces then {traces: ClaudeMemory::OTel::OtlpJsonEnvelope.parse_traces(payload)}
109
+ end
110
+
111
+ result = ClaudeMemory::OTel::Ingestor.new(store).ingest(rows)
112
+ if result.success?
113
+ back_tag_activity_events(rows[:events]) if kind == :logs
114
+ json_response(res, {})
115
+ else
116
+ otel_response(res, 400, result.error)
117
+ end
118
+ rescue => e
119
+ otel_response(res, 500, e.message)
120
+ end
121
+
122
+ # After OTel events with prompt.id are persisted, scan project +
123
+ # global activity_events and stamp prompt_id on matching rows so the
124
+ # Prompt Journey panel can UNION-join them. Hook events (session_id-
125
+ # bearing) match exactly; MCP recall/store_extraction rows (NULL
126
+ # session_id) fall back to time-window proximity. Best-effort —
127
+ # tagging failures never block the OTLP response.
128
+ def back_tag_activity_events(events)
129
+ return unless events && !events.empty?
130
+ @manager.ensure_project! if @manager.respond_to?(:ensure_project!) && !@manager.project_store
131
+ ClaudeMemory::OTel::PromptScope.new(@manager).tag(events)
132
+ rescue Sequel::DatabaseError, Extralite::Error
133
+ # never block the OTLP response on a tagging failure
134
+ end
135
+
136
+ def json_content?(req)
137
+ ct = req["content-type"].to_s.downcase
138
+ ct.start_with?("application/json")
139
+ end
140
+
141
+ def otel_response(res, status, message)
142
+ res.status = status
143
+ res["Content-Type"] = "application/json; charset=utf-8"
144
+ res.body = JSON.generate(error: message)
145
+ end
146
+
147
+ def configuration
148
+ @configuration ||= ClaudeMemory::Configuration.new
149
+ end
150
+
151
+ def ensure_global_store
152
+ @manager.ensure_global!
153
+ @manager.global_store
154
+ rescue Sequel::DatabaseError, Errno::ENOENT
155
+ nil
70
156
  end
71
157
 
72
158
  # WAL-mode SQLite caches pages on reader connections; when the MCP
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ module Dashboard
5
+ # Cost & Tokens dashboard panel. Aggregates Claude Code's OTel metric
6
+ # exports — server-side via Sequel datasets so the API returns
7
+ # final-rendered bins and the JS does no reduce.
8
+ #
9
+ # Returns the empty shape ({status:, cost_over_time: [], ...}) when no
10
+ # store or no rows exist so the dashboard renders before the first
11
+ # ingest.
12
+ class Telemetry
13
+ LOOKBACK_DAYS = 7
14
+ TOP_TOOLS_LIMIT = 10
15
+
16
+ def initialize(manager)
17
+ @manager = manager
18
+ end
19
+
20
+ def snapshot
21
+ store = @manager.default_store(prefer: :global)
22
+ return empty_snapshot(store) unless store&.db&.table_exists?(:otel_metrics)
23
+
24
+ cutoff = (Time.now - LOOKBACK_DAYS * 86_400).utc.iso8601
25
+ metrics = store.otel_metrics.where { recorded_at >= cutoff }
26
+ events = events_dataset(store, cutoff)
27
+
28
+ {
29
+ status: status_payload(store),
30
+ cost_over_time: cost_over_time(metrics),
31
+ tokens_by_model: tokens_by_model(metrics),
32
+ top_tools_by_latency: top_tools(events),
33
+ error_rate: error_rate(events),
34
+ recent_metrics: recent_metrics(metrics),
35
+ contains_prompt_content: contains_prompt_content?(events)
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def empty_snapshot(store)
42
+ {
43
+ status: status_payload(store),
44
+ cost_over_time: [],
45
+ tokens_by_model: [],
46
+ top_tools_by_latency: [],
47
+ error_rate: {total: 0, errors: 0, ratio: 0.0},
48
+ recent_metrics: [],
49
+ contains_prompt_content: false
50
+ }
51
+ end
52
+
53
+ def status_payload(store)
54
+ OTel::Status.new(store, configuration: ClaudeMemory::Configuration.new).snapshot
55
+ end
56
+
57
+ def cost_over_time(metrics)
58
+ rows = metrics
59
+ .where(name: OTel::MetricName::COST_USAGE)
60
+ .select_group(Sequel.lit("substr(recorded_at, 1, 13)").as(:hour))
61
+ .select_append { sum(value_float).as(:cost_usd) }
62
+ .select_append { count(id).as(:requests) }
63
+ .order(:hour)
64
+ .all
65
+ rows.map { |r|
66
+ {
67
+ hour: r[:hour],
68
+ cost_usd: (r[:cost_usd] || 0.0).to_f.round(6),
69
+ requests: r[:requests].to_i
70
+ }
71
+ }
72
+ end
73
+
74
+ # SQLite's json_extract was added in 3.38.0 (2022-02). Sequel runs it
75
+ # via Sequel.lit so we group by (model, type) at the DB layer instead
76
+ # of materializing the whole window into Ruby.
77
+ def tokens_by_model(metrics)
78
+ model_expr = Sequel.lit("json_extract(attributes_json, '$.model')")
79
+ type_expr = Sequel.lit("json_extract(attributes_json, '$.type')")
80
+ rows = metrics
81
+ .where(name: OTel::MetricName::TOKEN_USAGE)
82
+ .select_group(model_expr.as(:model), type_expr.as(:type))
83
+ .select_append { sum(Sequel.function(:coalesce, :value_int, :value_float)).as(:tokens) }
84
+ .order(Sequel.desc(:tokens))
85
+ .all
86
+ rows.map { |r|
87
+ {model: r[:model] || "unknown", type: r[:type] || "unknown", tokens: r[:tokens].to_i}
88
+ }
89
+ end
90
+
91
+ def top_tools(events)
92
+ return [] if events.nil?
93
+ tool_expr = Sequel.lit("json_extract(attributes_json, '$.tool_name')")
94
+ duration_expr = Sequel.lit("json_extract(attributes_json, '$.duration_ms')")
95
+ rows = events
96
+ .where(event_name: OTel::EventName::TOOL_RESULT)
97
+ .select_group(tool_expr.as(:tool))
98
+ .select_append { count(id).as(:count) }
99
+ .select_append { avg(duration_expr).as(:avg_duration_ms) }
100
+ .order(Sequel.desc(:avg_duration_ms))
101
+ .limit(TOP_TOOLS_LIMIT)
102
+ .all
103
+ rows.map { |r|
104
+ {tool: r[:tool] || "unknown", count: r[:count].to_i, avg_duration_ms: r[:avg_duration_ms].to_i}
105
+ }
106
+ end
107
+
108
+ def error_rate(events)
109
+ return {total: 0, errors: 0, ratio: 0.0} if events.nil?
110
+ total = events.where(event_name: OTel::EventName::API_PAIR).count
111
+ errors = events.where(event_name: OTel::EventName::API_ERROR).count
112
+ ratio = total.zero? ? 0.0 : (errors.to_f / total).round(4)
113
+ {total: total, errors: errors, ratio: ratio}
114
+ end
115
+
116
+ def recent_metrics(metrics)
117
+ rows = metrics
118
+ .where(name: OTel::MetricName::TOKEN_USAGE)
119
+ .order(Sequel.desc(:recorded_at))
120
+ .limit(100)
121
+ .all
122
+ rows.map { |row|
123
+ attrs = OTel::Attributes.from_json(row[:attributes_json])
124
+ {
125
+ recorded_at: row[:recorded_at],
126
+ model: attrs.model,
127
+ type: attrs.token_type,
128
+ tokens: OTel::Attributes.token_count(row),
129
+ session_id: attrs.session_id,
130
+ prompt_id: attrs.prompt_id
131
+ }.compact
132
+ }
133
+ end
134
+
135
+ def events_dataset(store, cutoff)
136
+ return nil unless store.db.table_exists?(:otel_events)
137
+ store.otel_events.where { occurred_at >= cutoff }
138
+ end
139
+
140
+ # SQL pre-filter via LIKE on each prompt-content key, short-circuited
141
+ # by .any?. JSON encodes object keys as `"key":` (compact), so the
142
+ # patterns can't false-match longer keys (e.g. "prompt_length").
143
+ def contains_prompt_content?(events)
144
+ return false if events.nil?
145
+ clauses = OTel::Attributes::PROMPT_CONTENT_KEYS.map { |key|
146
+ Sequel.lit("attributes_json LIKE ?", %("#{key}":))
147
+ }
148
+ events
149
+ .where(event_name: OTel::EventName::PROMPT_BODY_FAMILY)
150
+ .where(Sequel.|(*clauses))
151
+ .limit(1)
152
+ .any?
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeMemory
4
+ # Soft-rename / soft-removal mechanism for public-API surfaces.
5
+ # Used to mark an old name (CLI flag, MCP tool, Ruby method, hook
6
+ # field, predicate) as deprecated in `N.x.0` releases while keeping it
7
+ # functional for at least one minor cycle, with explicit removal no
8
+ # earlier than `(N+1).0.0`. This deprecation policy is documented in
9
+ # `docs/api_stability.md`.
10
+ #
11
+ # @example Deprecate a renamed CLI flag
12
+ # ClaudeMemory::Deprecations.warn(
13
+ # name: "claude-memory recall --legacy-mode",
14
+ # replacement: "--mode=legacy",
15
+ # removed_in: "1.0.0"
16
+ # )
17
+ #
18
+ # @example Deprecate a soft-renamed Ruby method
19
+ # ClaudeMemory::Deprecations.warn(
20
+ # name: "ClaudeMemory::Recall#legacy_query",
21
+ # replacement: "Recall#query",
22
+ # removed_in: "1.0.0",
23
+ # message: "Pass `mode: :legacy` to #query instead."
24
+ # )
25
+ #
26
+ # Two suppression mechanisms keep deprecation noise manageable:
27
+ #
28
+ # - **Per-call-site dedupe**: same (name, caller_file:line) pair only
29
+ # emits once per process. Prevents tight loops or repeated callers
30
+ # from drowning the terminal.
31
+ # - **Env var opt-out**: `CLAUDE_MEMORY_NO_DEPRECATIONS=1` silences
32
+ # everything. Recommended for test fixtures and CI runs that
33
+ # knowingly exercise legacy paths.
34
+ module Deprecations
35
+ ENV_OPT_OUT = "CLAUDE_MEMORY_NO_DEPRECATIONS"
36
+
37
+ # Tracks already-emitted (name, caller-location) pairs for dedupe.
38
+ # Bounded — this is a long-lived process state but the cardinality
39
+ # is at most "every deprecated surface × every call site that
40
+ # touches it", which stays small in practice.
41
+ @emitted = {}
42
+ @mutex = Mutex.new
43
+
44
+ class << self
45
+ # Emit a deprecation warning to `output` (stderr by default).
46
+ #
47
+ # @param name [String] the deprecated identifier (CLI flag, method,
48
+ # tool name, etc.). Be specific: "ClaudeMemory::Recall#query(:legacy)"
49
+ # beats "Recall#query".
50
+ # @param replacement [String, nil] what users should switch to.
51
+ # Optional but strongly recommended — a deprecation without a
52
+ # migration path is annoying.
53
+ # @param removed_in [String, nil] target removal version, semver
54
+ # string. Conventionally the next major (`(N+1).0.0`).
55
+ # @param message [String, nil] free-form extra context. Use for
56
+ # subtle migration nuance the replacement string can't capture.
57
+ # @param caller_location [String, nil] override for testing.
58
+ # @param output [IO] override for testing. Default: $stderr.
59
+ # @return [Boolean] true if a warning was emitted, false if
60
+ # suppressed (env opt-out or already-emitted dedupe).
61
+ def warn(name:, replacement: nil, removed_in: nil, message: nil,
62
+ caller_location: nil, output: $stderr)
63
+ return false if suppressed?
64
+
65
+ location = caller_location || derive_caller_location
66
+ key = "#{name}@#{location}"
67
+
68
+ @mutex.synchronize do
69
+ return false if @emitted[key]
70
+ @emitted[key] = true
71
+ end
72
+
73
+ output.puts(format_warning(name: name, replacement: replacement,
74
+ removed_in: removed_in, message: message, location: location))
75
+ true
76
+ end
77
+
78
+ # Wipe the per-call-site dedupe state. Test-only — production
79
+ # callers should rely on the per-process behavior.
80
+ def reset!
81
+ @mutex.synchronize { @emitted.clear }
82
+ end
83
+
84
+ private
85
+
86
+ def suppressed?
87
+ ENV[ENV_OPT_OUT] == "1"
88
+ end
89
+
90
+ def derive_caller_location
91
+ # Skip: 0=this method, 1=warn, 2=actual caller
92
+ loc = caller_locations(3, 1)&.first
93
+ loc ? "#{loc.path}:#{loc.lineno}" : "unknown"
94
+ end
95
+
96
+ def format_warning(name:, replacement:, removed_in:, message:, location:)
97
+ parts = ["[ClaudeMemory] DEPRECATION: #{name} is deprecated"]
98
+ parts << "scheduled for removal in #{removed_in}" if removed_in
99
+ parts << "use #{replacement} instead" if replacement
100
+ head = parts.join(", ") + "."
101
+ head += " #{message}" if message
102
+ "#{head} (called from #{location})"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -43,11 +43,28 @@ module ClaudeMemory
43
43
  /\bby\s+[[:upper:]][[:alpha:]'-]+\s+[[:upper:]][[:alpha:]'-]+/
44
44
  ].freeze
45
45
 
46
- # Predicates we inspect. Decisions stay decisions even when they cite
47
- # external projects ("From QMD restudy: adopt X"); the guard targets
48
- # only `convention`, where misclassification is most common.
46
+ # Predicates inspected for object-text reference signals. Decisions
47
+ # stay decisions even when they cite external projects ("From QMD
48
+ # restudy: adopt X"); the object-text guard targets only
49
+ # `convention`, where misclassification is most common.
49
50
  GUARDED_PREDICATES = %w[convention].freeze
50
51
 
52
+ # Stack-shaping single-value predicates that historically attract
53
+ # hallucinations from CLAUDE.md-style example text ("e.g., this app
54
+ # uses PostgreSQL"). For these predicates we additionally inspect the
55
+ # source quote for example markers — if the LLM extracted a stack
56
+ # fact from documentation example text, it's not a real project
57
+ # commitment. Added 2026-05-21 after the audit found 10 open
58
+ # conflicts driven by recurring example-text extraction.
59
+ QUOTE_GUARDED_PREDICATES = %w[uses_database uses_framework uses_language deployment_platform auth_method].freeze
60
+
61
+ # Example markers that signal the source text is documentation
62
+ # exemplifying a scope/predicate concept, not a real stack claim.
63
+ EXAMPLE_QUOTE_PATTERNS = [
64
+ /\b(?:e\.?g\.?|i\.?e\.?|for example|for instance|such as)[,:]?\s/i,
65
+ /\(\s*(?:e\.?g\.?|i\.?e\.?)[,.]/i
66
+ ].freeze
67
+
51
68
  def reclassify(extraction)
52
69
  return extraction if extraction.facts.nil? || extraction.facts.empty?
53
70
 
@@ -68,11 +85,27 @@ module ClaudeMemory
68
85
  end
69
86
 
70
87
  def reference_material?(fact)
71
- return false unless GUARDED_PREDICATES.include?(fact[:predicate].to_s)
88
+ predicate = fact[:predicate].to_s
89
+ return true if convention_with_reference_object?(fact, predicate)
90
+ return true if stack_predicate_from_example_text?(fact, predicate)
91
+ false
92
+ end
93
+
94
+ private
95
+
96
+ def convention_with_reference_object?(fact, predicate)
97
+ return false unless GUARDED_PREDICATES.include?(predicate)
72
98
  object = fact[:object].to_s
73
99
  return false if object.empty?
74
100
  STRONG_PATTERNS.any? { |re| object.match?(re) }
75
101
  end
102
+
103
+ def stack_predicate_from_example_text?(fact, predicate)
104
+ return false unless QUOTE_GUARDED_PREDICATES.include?(predicate)
105
+ quote = fact[:quote].to_s
106
+ return false if quote.empty?
107
+ EXAMPLE_QUOTE_PATTERNS.any? { |re| quote.match?(re) }
108
+ end
76
109
  end
77
110
  end
78
111
  end
@@ -21,10 +21,14 @@ module ClaudeMemory
21
21
  STATE_FILENAME = "auto_memory_mirror.json"
22
22
 
23
23
  # Derive auto-memory directory from a project path using Claude Code's
24
- # slug convention (path separators hyphens). E.g.
25
- # `/Users/me/src/app` → `~/.claude/projects/-Users-me-src-app/memory`.
24
+ # slug convention. Both path separators and underscores are converted
25
+ # to hyphens — e.g. `/Users/me/src/my_app` →
26
+ # `~/.claude/projects/-Users-me-src-my-app/memory`. Before the
27
+ # underscore conversion was added (2026-05-21 audit), this method
28
+ # silently missed auto-memory for any project name containing `_`,
29
+ # including claude_memory itself.
26
30
  def self.default_dir(project_path, claude_config_dir)
27
- slug = project_path.to_s.tr("/", "-")
31
+ slug = project_path.to_s.tr("/_", "-")
28
32
  File.join(claude_config_dir, "projects", slug, "memory")
29
33
  end
30
34