claude_memory 0.11.0 → 0.12.1

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 +54 -85
  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 +2 -4
  9. data/CHANGELOG.md +50 -0
  10. data/CLAUDE.md +11 -4
  11. data/README.md +40 -1
  12. data/db/migrations/018_add_otel_telemetry.rb +81 -0
  13. data/docs/1_0_punchlist.md +318 -66
  14. data/docs/api_stability.md +346 -0
  15. data/docs/audit_runbook.md +209 -0
  16. data/docs/claude_monitoring.md +956 -0
  17. data/docs/improvements.md +148 -9
  18. data/docs/influence/ai-memory-systems-2026.md +403 -0
  19. data/docs/memory_audit_2026-05-21.md +303 -0
  20. data/docs/plugin.md +1 -1
  21. data/docs/soak/audit_2026-06-03_agent-training-program.json +53 -0
  22. data/docs/soak/audit_2026-06-03_agentic.json +31 -0
  23. data/docs/soak/audit_2026-06-03_ai-software-architect.json +19 -0
  24. data/docs/soak/audit_2026-06-03_chaos_to_the_rescue.json +60 -0
  25. data/docs/soak/audit_2026-06-03_claude_memory.json +55 -0
  26. data/docs/soak/audit_2026-06-03_daily-vibe.json +59 -0
  27. data/docs/soak/audit_2026-06-03_minerva-sky.json +19 -0
  28. data/docs/soak/audit_2026-06-03_nowreading.dev.json +19 -0
  29. data/docs/soak/audit_2026-06-03_ups.dev.json +55 -0
  30. data/docs/soak/baseline_2026-06-03.md +145 -0
  31. data/lib/claude_memory/audit/checks.rb +239 -0
  32. data/lib/claude_memory/audit/finding.rb +33 -0
  33. data/lib/claude_memory/audit/runner.rb +73 -0
  34. data/lib/claude_memory/commands/audit_command.rb +117 -0
  35. data/lib/claude_memory/commands/checks/embeddings_check.rb +97 -0
  36. data/lib/claude_memory/commands/dashboard_command.rb +2 -1
  37. data/lib/claude_memory/commands/doctor_command.rb +1 -0
  38. data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
  39. data/lib/claude_memory/commands/otel_command.rb +240 -0
  40. data/lib/claude_memory/commands/registry.rb +5 -1
  41. data/lib/claude_memory/commands/setup_vectors_command.rb +182 -0
  42. data/lib/claude_memory/configuration.rb +60 -0
  43. data/lib/claude_memory/core/fact_query_builder.rb +1 -0
  44. data/lib/claude_memory/dashboard/api.rb +8 -0
  45. data/lib/claude_memory/dashboard/index.html +140 -1
  46. data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
  47. data/lib/claude_memory/dashboard/server.rb +86 -0
  48. data/lib/claude_memory/dashboard/telemetry.rb +156 -0
  49. data/lib/claude_memory/deprecations.rb +106 -0
  50. data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
  51. data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
  52. data/lib/claude_memory/hook/context_injector.rb +11 -2
  53. data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
  54. data/lib/claude_memory/otel/attributes.rb +118 -0
  55. data/lib/claude_memory/otel/constants.rb +32 -0
  56. data/lib/claude_memory/otel/ingestor.rb +54 -0
  57. data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
  58. data/lib/claude_memory/otel/prompt_scope.rb +108 -0
  59. data/lib/claude_memory/otel/settings_writer.rb +122 -0
  60. data/lib/claude_memory/otel/status.rb +58 -0
  61. data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
  62. data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
  63. data/lib/claude_memory/resolve/resolver.rb +30 -3
  64. data/lib/claude_memory/shortcuts.rb +61 -18
  65. data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
  66. data/lib/claude_memory/store/schema_manager.rb +1 -1
  67. data/lib/claude_memory/store/sqlite_store.rb +136 -0
  68. data/lib/claude_memory/sweep/maintenance.rb +31 -1
  69. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  70. data/lib/claude_memory/version.rb +1 -1
  71. data/lib/claude_memory.rb +20 -0
  72. metadata +38 -1
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "optparse"
6
+
7
+ module ClaudeMemory
8
+ module Commands
9
+ # Guides the user through opting into vector recall with fastembed
10
+ # (or another provider). fastembed stays a dev/test gem dependency by
11
+ # design; this command is the documented opt-in path for end users.
12
+ #
13
+ # Steps:
14
+ # 1. Verify the chosen provider is loadable. For fastembed, surface
15
+ # a clear install command if the gem isn't on $LOAD_PATH.
16
+ # 2. Persist CLAUDE_MEMORY_EMBEDDING_PROVIDER (and optional model)
17
+ # into the project's .claude/settings.json env block, the same
18
+ # mechanism Claude Code uses for OTel env (see OTel::SettingsWriter).
19
+ # 3. Re-embed existing facts under the new provider (unless --no-reindex).
20
+ # 4. Report the final state — provider, dimensions, stored alignment.
21
+ class SetupVectorsCommand < BaseCommand
22
+ OWNED_KEYS = %w[
23
+ CLAUDE_MEMORY_EMBEDDING_PROVIDER
24
+ CLAUDE_MEMORY_EMBEDDING_MODEL
25
+ ].freeze
26
+
27
+ FASTEMBED_INSTALL_HINT = <<~HINT
28
+ fastembed is not installed. claude-memory keeps fastembed as a
29
+ dev/test dependency so the default gem install stays light. To
30
+ enable it, install the gem and re-run setup-vectors:
31
+
32
+ gem install fastembed
33
+ claude-memory setup-vectors
34
+
35
+ Or if you bundle, add to your Gemfile:
36
+
37
+ gem "fastembed"
38
+
39
+ Then `bundle install` and re-run setup-vectors. The first run
40
+ downloads the BAAI/bge-small-en-v1.5 ONNX model (~75MB).
41
+ HINT
42
+
43
+ def call(args)
44
+ opts = parse_opts(args)
45
+ return 1 if opts.nil?
46
+
47
+ return print_status if opts[:status]
48
+
49
+ provider_name = opts[:provider]
50
+ unless verify_provider_loadable(provider_name)
51
+ return 1
52
+ end
53
+
54
+ if opts[:dry_run]
55
+ stdout.puts "Would write to #{settings_path}:"
56
+ stdout.puts " CLAUDE_MEMORY_EMBEDDING_PROVIDER=#{provider_name}"
57
+ stdout.puts " CLAUDE_MEMORY_EMBEDDING_MODEL=#{opts[:model]}" if opts[:model]
58
+ stdout.puts(opts[:reindex] ? "Would re-index facts under the new provider" : "Would skip re-index (--no-reindex)")
59
+ return 0
60
+ end
61
+
62
+ write_settings(provider_name, opts[:model])
63
+
64
+ if opts[:reindex]
65
+ reindex_result = reindex(provider_name)
66
+ return 1 if reindex_result != 0
67
+ else
68
+ stdout.puts "Skipped re-index (--no-reindex). Run 'claude-memory index --force --provider=#{provider_name}' when ready."
69
+ end
70
+
71
+ report_final_state(provider_name)
72
+ 0
73
+ end
74
+
75
+ private
76
+
77
+ def parse_opts(args)
78
+ options = {provider: "fastembed", model: nil, reindex: true, dry_run: false, status: false}
79
+ parser = OptionParser.new do |o|
80
+ o.banner = "Usage: claude-memory setup-vectors [--provider=fastembed|api|tfidf] [--model=NAME] [--no-reindex] [--dry-run] [--status]"
81
+ o.on("--provider NAME", "Embedding provider (default: fastembed)") { |v| options[:provider] = v }
82
+ o.on("--model NAME", "Optional model name (e.g. BAAI/bge-small-en-v1.5)") { |v| options[:model] = v }
83
+ o.on("--no-reindex", "Skip re-embedding existing facts under the new provider") { options[:reindex] = false }
84
+ o.on("--dry-run", "Print what would change without writing or re-indexing") { options[:dry_run] = true }
85
+ o.on("--status", "Show the current provider config + stored alignment, then exit") { options[:status] = true }
86
+ end
87
+ parser.parse!(args.dup)
88
+ options
89
+ rescue OptionParser::InvalidOption => e
90
+ stderr.puts e.message
91
+ nil
92
+ end
93
+
94
+ def verify_provider_loadable(provider_name)
95
+ case provider_name
96
+ when "tfidf"
97
+ true # always available
98
+ when "fastembed"
99
+ require "fastembed"
100
+ true
101
+ when "api"
102
+ # api provider needs network + key but no gem; defer to runtime
103
+ true
104
+ else
105
+ stderr.puts "Unknown provider: #{provider_name}. Valid: tfidf, fastembed, api."
106
+ false
107
+ end
108
+ rescue LoadError
109
+ stderr.puts FASTEMBED_INSTALL_HINT
110
+ false
111
+ end
112
+
113
+ def settings_path
114
+ File.join(claude_dir, "settings.json")
115
+ end
116
+
117
+ def claude_dir
118
+ File.join(Configuration.new.project_dir, ".claude")
119
+ end
120
+
121
+ def write_settings(provider_name, model)
122
+ FileUtils.mkdir_p(claude_dir)
123
+ settings = load_settings
124
+ settings["env"] ||= {}
125
+ settings["env"]["CLAUDE_MEMORY_EMBEDDING_PROVIDER"] = provider_name
126
+ if model
127
+ settings["env"]["CLAUDE_MEMORY_EMBEDDING_MODEL"] = model
128
+ else
129
+ settings["env"].delete("CLAUDE_MEMORY_EMBEDDING_MODEL")
130
+ end
131
+ File.write(settings_path, JSON.pretty_generate(settings) + "\n")
132
+ stdout.puts "✓ Wrote CLAUDE_MEMORY_EMBEDDING_PROVIDER=#{provider_name} to #{settings_path}"
133
+ stdout.puts "✓ Wrote CLAUDE_MEMORY_EMBEDDING_MODEL=#{model}" if model
134
+ end
135
+
136
+ def load_settings
137
+ return {} unless File.exist?(settings_path)
138
+ raw = File.read(settings_path)
139
+ return {} if raw.strip.empty?
140
+ parsed = JSON.parse(raw)
141
+ parsed.is_a?(Hash) ? parsed : {}
142
+ rescue JSON::ParserError => e
143
+ stderr.puts "settings.json parse error: #{e.message} — refusing to overwrite"
144
+ {}
145
+ end
146
+
147
+ def reindex(provider_name)
148
+ stdout.puts "→ Re-embedding facts under provider=#{provider_name}…"
149
+ IndexCommand.new(stdout: stdout, stderr: stderr).call(["--force", "--provider", provider_name])
150
+ end
151
+
152
+ def report_final_state(provider_name)
153
+ # The settings.json write only affects future sessions (Claude Code
154
+ # reads the env block at session start). For the current process
155
+ # the ENV var isn't set, so report what Embeddings.resolve would
156
+ # produce under the new env.
157
+ env_override = ENV.to_h.merge("CLAUDE_MEMORY_EMBEDDING_PROVIDER" => provider_name)
158
+ provider = Embeddings.resolve(provider_name, env: env_override)
159
+ stdout.puts
160
+ stdout.puts "Provider: #{provider.name}, dimensions: #{provider.dimensions}"
161
+ stdout.puts "Next session will use this provider. Run 'claude-memory doctor' to verify."
162
+ end
163
+
164
+ def print_status
165
+ # Resolve under current ENV to show what the next session will use
166
+ provider = Embeddings.resolve
167
+ stdout.puts "Current provider: #{provider.name}"
168
+ stdout.puts "Current dimensions: #{provider.dimensions}"
169
+ stdout.puts "Settings file: #{settings_path}"
170
+ env = load_settings.fetch("env", {})
171
+ relevant = env.slice(*OWNED_KEYS)
172
+ if relevant.any?
173
+ stdout.puts "Configured env:"
174
+ relevant.each { |k, v| stdout.puts " #{k}=#{v}" }
175
+ else
176
+ stdout.puts "Configured env: (none — using default tfidf)"
177
+ end
178
+ 0
179
+ end
180
+ end
181
+ end
182
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "json"
4
5
 
5
6
  module ClaudeMemory
6
7
  # Centralized configuration and ENV access
@@ -66,6 +67,65 @@ module ClaudeMemory
66
67
  DEFAULT_STALE_DAYS
67
68
  end
68
69
 
70
+ # Threshold (in days) for the context-injection staleness marker. A
71
+ # single-value fact older than this and not recalled within it gets a
72
+ # "verify before relying" annotation when injected at SessionStart.
73
+ # Deliberately much longer than DEFAULT_STALE_DAYS (the dashboard's
74
+ # review-candidate window) — the injection marker should fire only on
75
+ # facts old enough to be genuinely risky, not merely unused for a
76
+ # couple weeks. Override via CLAUDE_MEMORY_INJECTION_STALE_DAYS.
77
+ DEFAULT_INJECTION_STALE_DAYS = 180
78
+
79
+ # @return [Integer] injection staleness threshold in days
80
+ def injection_stale_days
81
+ raw = env["CLAUDE_MEMORY_INJECTION_STALE_DAYS"]
82
+ return DEFAULT_INJECTION_STALE_DAYS if raw.nil? || raw.empty?
83
+ parsed = Integer(raw, 10)
84
+ (parsed > 0) ? parsed : DEFAULT_INJECTION_STALE_DAYS
85
+ rescue ArgumentError
86
+ DEFAULT_INJECTION_STALE_DAYS
87
+ end
88
+
89
+ # Whether OTel trace ingestion is opted in. Reads OTEL_TRACES_EXPORTER
90
+ # from .claude/settings.json's env block. Traces are off unless the
91
+ # value is present and non-empty and not "none". Set by
92
+ # `claude-memory otel --enable-traces`.
93
+ #
94
+ # @return [Boolean]
95
+ def otel_traces_enabled?
96
+ value = settings_env["OTEL_TRACES_EXPORTER"]
97
+ return false if value.nil?
98
+ stripped = value.to_s.strip
99
+ !stripped.empty? && stripped != "none"
100
+ end
101
+
102
+ # Read the env block from .claude/settings.json (project scope) so
103
+ # callers can inspect what Claude Code sees at session start. Returns
104
+ # an empty hash when the file is missing or unparseable — matches the
105
+ # tolerant behavior of Claude Code's settings loader.
106
+ #
107
+ # @return [Hash]
108
+ def settings_env
109
+ path = settings_json_path
110
+ return {} unless path
111
+ raw = File.read(path)
112
+ parsed = JSON.parse(raw)
113
+ env_block = parsed.is_a?(Hash) ? parsed["env"] : nil
114
+ env_block.is_a?(Hash) ? env_block : {}
115
+ rescue JSON::ParserError, Errno::ENOENT
116
+ {}
117
+ end
118
+
119
+ # Path to the project-scoped settings.json. nil when no project_dir
120
+ # exists (e.g. running outside any directory).
121
+ #
122
+ # @return [String, nil]
123
+ def settings_json_path
124
+ dir = project_dir
125
+ return nil unless dir
126
+ File.join(dir, ".claude", "settings.json")
127
+ end
128
+
69
129
  private
70
130
 
71
131
  def resolve_project_dir
@@ -141,6 +141,7 @@ module ClaudeMemory
141
141
  Sequel[:facts][:valid_from],
142
142
  Sequel[:facts][:valid_to],
143
143
  Sequel[:facts][:created_at],
144
+ Sequel[:facts][:last_recalled_at],
144
145
  Sequel[:entities][:canonical_name].as(:subject_name),
145
146
  Sequel[:facts][:scope],
146
147
  Sequel[:facts][:project_path]
@@ -460,6 +460,14 @@ module ClaudeMemory
460
460
  Timeline.new(@manager).days
461
461
  end
462
462
 
463
+ def telemetry
464
+ Telemetry.new(@manager).snapshot
465
+ end
466
+
467
+ def prompt_journey(prompt_id)
468
+ PromptJourney.new(@manager).for(prompt_id.to_s)
469
+ end
470
+
463
471
  private
464
472
 
465
473
  CONTENT_ITEM_PREVIEW_BYTES = 8000
@@ -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