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.
- 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 +26 -0
- data/CLAUDE.md +9 -2
- data/README.md +29 -1
- data/db/migrations/018_add_otel_telemetry.rb +81 -0
- data/docs/1_0_punchlist.md +318 -66
- data/docs/api_stability.md +341 -0
- data/docs/audit_runbook.md +209 -0
- data/docs/claude_monitoring.md +956 -0
- data/docs/improvements.md +148 -9
- 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/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/import_auto_memory_command.rb +180 -0
- data/lib/claude_memory/commands/otel_command.rb +240 -0
- data/lib/claude_memory/commands/registry.rb +4 -1
- 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/deprecations.rb +106 -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/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/version.rb +1 -1
- data/lib/claude_memory.rb +18 -0
- 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')">×</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
|
|
47
|
-
# external projects ("From QMD
|
|
48
|
-
#
|
|
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
|
-
|
|
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
|
|
25
|
-
# `/Users/me/src/
|
|
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
|
|