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
|
@@ -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')">×</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.
|
|
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
|
|
8
|
-
# A moment is any meaningful activity event (recall hit,
|
|
9
|
-
# context injection, conflict detected). Ingest-only
|
|
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.
|
|
14
|
-
#
|
|
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
|
|
17
|
-
#
|
|
18
|
-
# count; the feed surfaces
|
|
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
|