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
@@ -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
@@ -2,20 +2,37 @@
2
2
 
3
3
  module ClaudeMemory
4
4
  module Dashboard
5
- # Sidebar data for the feed-first dashboard. Three things:
5
+ # Sidebar data for the feed-first dashboard. Six surfaces, each
6
+ # answering a different "is memory helping/costing/clean?" question:
6
7
  #
7
- # 1. Moments this week + week-over-week delta — the headline value number.
8
- # A moment is any meaningful activity event (recall hit, extraction,
9
- # context injection, conflict detected). Ingest-only events don't count
10
- # because they're not directly user-visible value.
8
+ # 1. Moments this week + week-over-week delta — the headline value
9
+ # number. A moment is any meaningful activity event (recall hit,
10
+ # extraction, context injection, conflict detected). Ingest-only
11
+ # events don't count because they're not directly user-visible value.
11
12
  #
12
13
  # 2. "What memory knows about you" — up to 5 global facts rendered as
13
- # plain English. This is the trust panel's most compelling surface:
14
- # users can sanity-check what's being injected into their sessions.
14
+ # plain English. The trust panel's most compelling surface: users
15
+ # can sanity-check what's being injected into their sessions.
15
16
  #
16
- # 3. Needs review — open conflicts plus facts that have gone stale
17
- # (active but never recalled in the last N days). A single actionable
18
- # count; the feed surfaces the individual items.
17
+ # 3. Needs review — open conflicts plus stale facts (active but never
18
+ # recalled in the last N days) plus empty recalls (queries that
19
+ # returned nothing). A single actionable count; the feed surfaces
20
+ # the individual items.
21
+ #
22
+ # 4. Utilization (30d) — of facts extracted in the last 30 days, how
23
+ # many has Claude actually surfaced via recall or context injection.
24
+ # Low ratios are a signal too: memory accumulating knowledge that
25
+ # Claude isn't reaching for.
26
+ #
27
+ # 5. Token budget (30d, 0.11.0+) — p50/p95/avg `context_tokens`
28
+ # injected per SessionStart. Answers "what does memory cost per
29
+ # session?" via numbers a skeptical user can read.
30
+ #
31
+ # 6. Quality score (live + historical, 0.11.0+) — hallucination-rate
32
+ # proxy: 100 - (suspect_pct + bare_pct), clamped 0..100. Live is
33
+ # over the last UTILIZATION_DAYS; historical mirrors the same
34
+ # calculation across all active facts as a supplementary baseline.
35
+ # See `quality_review.md` 2026-04-30 note for why the split exists.
19
36
  class Trust
20
37
  WEEK_SECONDS = 7 * 86_400
21
38
  UTILIZATION_DAYS = 30
@@ -31,8 +48,160 @@ module ClaudeMemory
31
48
  fingerprint: fingerprint,
32
49
  needs_review: needs_review,
33
50
  utilization: utilization,
34
- feedback: feedback_summary
51
+ feedback: feedback_summary,
52
+ token_budget: token_budget,
53
+ quality_score: quality_score
54
+ }
55
+ end
56
+
57
+ # The trust panel's hallucination-rate proxy. Counts two pollution
58
+ # signals:
59
+ #
60
+ # - suspect: facts that ReferenceMaterialDetector retagged from
61
+ # `convention` to `reference` predicate (descriptions of external
62
+ # projects mislabeled as user conventions).
63
+ # - bare_conclusion: `decision` / `convention` facts whose object
64
+ # skipped the prompt-mandated reason clause and so are dead
65
+ # weight once the originating context is gone.
66
+ #
67
+ # Reports two windows so users can distinguish historical noise from
68
+ # live extraction quality (per `quality_review.md` 2026-04-30
69
+ # investigation): the headline `score` is computed over facts
70
+ # created within the last UTILIZATION_DAYS — that's the actionable
71
+ # signal. The `historical` block reports the same counts over all
72
+ # active facts so legacy data is visible without dominating.
73
+ #
74
+ # Score = 100 - (suspect_pct + bare_pct), clamped 0..100. Lower is
75
+ # worse. Returns 100 (perfect) when there are no facts in the
76
+ # window so a quiet week isn't penalized.
77
+ def quality_score
78
+ cutoff = (Time.now.utc - UTILIZATION_DAYS * 86_400).iso8601
79
+ live = compute_quality(cutoff: cutoff)
80
+ historical = compute_quality(cutoff: nil)
81
+
82
+ live.merge(
83
+ window_days: UTILIZATION_DAYS,
84
+ historical: historical
85
+ )
86
+ rescue Sequel::DatabaseError => e
87
+ ClaudeMemory.logger.debug("Trust#quality_score failed: #{e.message}")
88
+ quality_score_zero
89
+ end
90
+ public :quality_score
91
+
92
+ def quality_score_zero
93
+ {
94
+ total_active: 0,
95
+ suspect_count: 0,
96
+ bare_conclusion_count: 0,
97
+ suspect_pct: 0.0,
98
+ bare_pct: 0.0,
99
+ score: 100,
100
+ window_days: UTILIZATION_DAYS,
101
+ historical: {
102
+ total_active: 0,
103
+ suspect_count: 0,
104
+ bare_conclusion_count: 0,
105
+ suspect_pct: 0.0,
106
+ bare_pct: 0.0,
107
+ score: 100
108
+ }
109
+ }
110
+ end
111
+
112
+ def compute_quality(cutoff:)
113
+ breakdown = aggregate_quality_counts(cutoff: cutoff)
114
+ total = breakdown[:total_active]
115
+
116
+ return zero_breakdown if total.zero?
117
+
118
+ suspect_pct = (breakdown[:suspect_count] * 100.0 / total).round(1)
119
+ bare_pct = (breakdown[:bare_conclusion_count] * 100.0 / total).round(1)
120
+ score = (100 - (suspect_pct + bare_pct)).clamp(0, 100).round
121
+
122
+ breakdown.merge(
123
+ suspect_pct: suspect_pct,
124
+ bare_pct: bare_pct,
125
+ score: score
126
+ )
127
+ end
128
+
129
+ def zero_breakdown
130
+ {total_active: 0, suspect_count: 0, bare_conclusion_count: 0,
131
+ suspect_pct: 0.0, bare_pct: 0.0, score: 100}
132
+ end
133
+
134
+ def aggregate_quality_counts(cutoff: nil)
135
+ detector = Distill::BareConclusionDetector.new
136
+ suspect = 0
137
+ bare = 0
138
+ total = 0
139
+
140
+ %w[project global].each do |scope|
141
+ store = @manager.store_if_exists(scope)
142
+ next unless store
143
+ dataset = store.facts.where(status: "active")
144
+ dataset = dataset.where { created_at >= cutoff } if cutoff
145
+ total += dataset.count
146
+ suspect += dataset.where(predicate: "reference").count
147
+ dataset.where(predicate: %w[decision convention])
148
+ .select(:predicate, :object_literal)
149
+ .all
150
+ .each { |row| bare += 1 if detector.bare_conclusion?(row) }
151
+ end
152
+
153
+ {total_active: total, suspect_count: suspect, bare_conclusion_count: bare}
154
+ end
155
+
156
+ # What does memory cost? Aggregates `context_tokens` from successful
157
+ # `hook_context` activity events over the last UTILIZATION_DAYS so a
158
+ # skeptical user can see the per-session token cost in p50/p95.
159
+ #
160
+ # Shape: {p50:, p95:, avg:, sample_size:, window_days:}
161
+ # All ints. Returns zeros when there are no events in the window.
162
+ def token_budget
163
+ store = @manager.default_store(prefer: :project)
164
+ return token_budget_zero unless store
165
+
166
+ cutoff = (Time.now.utc - UTILIZATION_DAYS * 86_400).iso8601
167
+ rows = store.activity_events
168
+ .where(event_type: "hook_context", status: "success")
169
+ .where { occurred_at >= cutoff }
170
+ .select(:detail_json)
171
+ .all
172
+
173
+ tokens = rows.filter_map do |row|
174
+ details = row[:detail_json] ? JSON.parse(row[:detail_json]) : {}
175
+ value = details["context_tokens"]
176
+ value if value.is_a?(Integer) && value > 0
177
+ end
178
+
179
+ return token_budget_zero if tokens.empty?
180
+
181
+ sorted = tokens.sort
182
+ {
183
+ p50: percentile(sorted, 0.50),
184
+ p95: percentile(sorted, 0.95),
185
+ avg: (sorted.sum.to_f / sorted.size).round,
186
+ sample_size: sorted.size,
187
+ window_days: UTILIZATION_DAYS
35
188
  }
189
+ rescue Sequel::DatabaseError, JSON::ParserError => e
190
+ ClaudeMemory.logger.debug("Trust#token_budget failed: #{e.message}")
191
+ token_budget_zero
192
+ end
193
+ public :token_budget
194
+
195
+ def token_budget_zero
196
+ {p50: 0, p95: 0, avg: 0, sample_size: 0, window_days: UTILIZATION_DAYS}
197
+ end
198
+
199
+ def percentile(sorted, pct)
200
+ return 0 if sorted.empty?
201
+ idx = (sorted.size * pct).ceil - 1
202
+ idx = 0 if idx < 0
203
+ idx = sorted.size - 1 if idx >= sorted.size
204
+ sorted[idx]
36
205
  end
37
206
 
38
207
  private