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.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +54 -85
- 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 +2 -4
- data/CHANGELOG.md +50 -0
- data/CLAUDE.md +11 -4
- data/README.md +40 -1
- data/db/migrations/018_add_otel_telemetry.rb +81 -0
- data/docs/1_0_punchlist.md +318 -66
- data/docs/api_stability.md +346 -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/docs/soak/audit_2026-06-03_agent-training-program.json +53 -0
- data/docs/soak/audit_2026-06-03_agentic.json +31 -0
- data/docs/soak/audit_2026-06-03_ai-software-architect.json +19 -0
- data/docs/soak/audit_2026-06-03_chaos_to_the_rescue.json +60 -0
- data/docs/soak/audit_2026-06-03_claude_memory.json +55 -0
- data/docs/soak/audit_2026-06-03_daily-vibe.json +59 -0
- data/docs/soak/audit_2026-06-03_minerva-sky.json +19 -0
- data/docs/soak/audit_2026-06-03_nowreading.dev.json +19 -0
- data/docs/soak/audit_2026-06-03_ups.dev.json +55 -0
- data/docs/soak/baseline_2026-06-03.md +145 -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/checks/embeddings_check.rb +97 -0
- data/lib/claude_memory/commands/dashboard_command.rb +2 -1
- data/lib/claude_memory/commands/doctor_command.rb +1 -0
- 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 +5 -1
- data/lib/claude_memory/commands/setup_vectors_command.rb +182 -0
- 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 +20 -0
- 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')">×</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
|