claude_memory 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +42 -64
- data/.claude/skills/release/SKILL.md +44 -6
- data/.claude/skills/study-repo/SKILL.md +15 -0
- data/.claude-plugin/commands/audit-memory.md +68 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +70 -0
- data/CLAUDE.md +20 -5
- data/README.md +64 -2
- data/db/migrations/018_add_otel_telemetry.rb +81 -0
- data/docs/1_0_punchlist.md +522 -89
- data/docs/GETTING_STARTED.md +3 -1
- data/docs/api_stability.md +341 -0
- data/docs/architecture.md +3 -3
- data/docs/audit_runbook.md +209 -0
- data/docs/claude_monitoring.md +956 -0
- data/docs/dashboard.md +23 -3
- data/docs/improvements.md +329 -5
- data/docs/influence/ai-memory-systems-2026.md +403 -0
- data/docs/memory_audit_2026-05-21.md +303 -0
- data/docs/plugin.md +1 -1
- data/docs/quality_review.md +35 -0
- data/lib/claude_memory/audit/checks.rb +239 -0
- data/lib/claude_memory/audit/finding.rb +33 -0
- data/lib/claude_memory/audit/runner.rb +73 -0
- data/lib/claude_memory/commands/audit_command.rb +117 -0
- data/lib/claude_memory/commands/dashboard_command.rb +2 -1
- data/lib/claude_memory/commands/digest_command.rb +95 -3
- data/lib/claude_memory/commands/hook_command.rb +27 -2
- data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
- data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
- data/lib/claude_memory/commands/otel_command.rb +240 -0
- data/lib/claude_memory/commands/registry.rb +5 -1
- data/lib/claude_memory/commands/show_command.rb +90 -0
- data/lib/claude_memory/commands/stats_command.rb +94 -2
- data/lib/claude_memory/configuration.rb +60 -0
- data/lib/claude_memory/core/fact_query_builder.rb +1 -0
- data/lib/claude_memory/dashboard/api.rb +8 -0
- data/lib/claude_memory/dashboard/index.html +140 -1
- data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
- data/lib/claude_memory/dashboard/server.rb +86 -0
- data/lib/claude_memory/dashboard/telemetry.rb +156 -0
- data/lib/claude_memory/dashboard/trust.rb +180 -11
- data/lib/claude_memory/deprecations.rb +106 -0
- data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
- data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
- data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
- data/lib/claude_memory/hook/context_injector.rb +11 -2
- data/lib/claude_memory/hook/handler.rb +142 -1
- data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
- data/lib/claude_memory/otel/attributes.rb +118 -0
- data/lib/claude_memory/otel/constants.rb +32 -0
- data/lib/claude_memory/otel/ingestor.rb +54 -0
- data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
- data/lib/claude_memory/otel/prompt_scope.rb +108 -0
- data/lib/claude_memory/otel/settings_writer.rb +122 -0
- data/lib/claude_memory/otel/status.rb +58 -0
- data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
- data/lib/claude_memory/resolve/resolver.rb +30 -3
- data/lib/claude_memory/shortcuts.rb +61 -18
- data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +136 -0
- data/lib/claude_memory/sweep/maintenance.rb +31 -1
- data/lib/claude_memory/sweep/sweeper.rb +6 -0
- data/lib/claude_memory/templates/hooks.example.json +5 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +20 -0
- metadata +28 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module ClaudeMemory
|
|
9
|
+
module Commands
|
|
10
|
+
# CLI shell for the OTel ingestion feature. Subcommands flip flags in
|
|
11
|
+
# `.claude/settings.json` (delegated to OTel::SettingsWriter) or report
|
|
12
|
+
# status (delegated to OTel::Status). The command itself contains no
|
|
13
|
+
# domain logic.
|
|
14
|
+
#
|
|
15
|
+
# claude-memory otel # default --status
|
|
16
|
+
# claude-memory otel --status
|
|
17
|
+
# claude-memory otel --enable
|
|
18
|
+
# claude-memory otel --disable
|
|
19
|
+
# claude-memory otel --enable-traces
|
|
20
|
+
# claude-memory otel --disable-traces
|
|
21
|
+
# claude-memory otel --capture-prompts # opt-in: OTEL_LOG_USER_PROMPTS=1
|
|
22
|
+
# claude-memory otel --no-capture-prompts
|
|
23
|
+
# claude-memory otel --verify # POST a fixture and confirm round-trip
|
|
24
|
+
class OtelCommand < BaseCommand
|
|
25
|
+
def call(args)
|
|
26
|
+
opts = parse_options(args, default_options) do |o|
|
|
27
|
+
OptionParser.new do |parser|
|
|
28
|
+
parser.banner = "Usage: claude-memory otel [options]"
|
|
29
|
+
parser.on("--status", "Show ingestion status (default)") { o[:action] = :status }
|
|
30
|
+
parser.on("--enable", "Configure Claude Code to export telemetry") { o[:action] = :enable }
|
|
31
|
+
parser.on("--disable", "Remove telemetry env from settings.json") { o[:action] = :disable }
|
|
32
|
+
parser.on("--enable-traces", "Opt in to trace ingestion") { o[:action] = :enable_traces }
|
|
33
|
+
parser.on("--disable-traces", "Tell Claude Code to skip trace export") { o[:action] = :disable_traces }
|
|
34
|
+
parser.on("--capture-prompts", "Opt in to OTEL_LOG_USER_PROMPTS=1") { o[:action] = :capture_prompts }
|
|
35
|
+
parser.on("--no-capture-prompts", "Stop capturing prompt content") { o[:action] = :disable_capture_prompts }
|
|
36
|
+
parser.on("--verify", "Send a sample envelope and confirm it persisted") { o[:action] = :verify }
|
|
37
|
+
parser.on("--backfill", "Tag historical activity_events from prior OTel events") { o[:action] = :backfill }
|
|
38
|
+
parser.on("--port PORT", Integer, "Receiver port for the dashboard (default 3377)") { |v| o[:port] = v }
|
|
39
|
+
parser.on("--batch-size N", Integer, "Backfill batch size (default 500)") { |v| o[:batch_size] = v }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
return 1 if opts.nil?
|
|
43
|
+
|
|
44
|
+
case opts[:action]
|
|
45
|
+
when :enable then run_enable(opts)
|
|
46
|
+
when :disable then run_disable(opts)
|
|
47
|
+
when :enable_traces then run_settings_change(opts) { |w| w.enable_traces! }
|
|
48
|
+
when :disable_traces then run_settings_change(opts) { |w| w.disable_traces! }
|
|
49
|
+
when :capture_prompts then run_capture_prompts(opts)
|
|
50
|
+
when :disable_capture_prompts then run_settings_change(opts) { |w| w.disable_capture_prompts! }
|
|
51
|
+
when :verify then run_verify(opts)
|
|
52
|
+
when :backfill then run_backfill(opts)
|
|
53
|
+
else run_status(opts)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
DEFAULT_BACKFILL_BATCH_SIZE = 500
|
|
60
|
+
|
|
61
|
+
def default_options
|
|
62
|
+
{action: :status, port: OTel::SettingsWriter::DEFAULT_PORT, batch_size: DEFAULT_BACKFILL_BATCH_SIZE}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def writer(opts)
|
|
66
|
+
OTel::SettingsWriter.new(claude_dir, port: opts[:port])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def claude_dir
|
|
70
|
+
File.join(ClaudeMemory::Configuration.new.project_dir, ".claude")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def run_status(opts)
|
|
74
|
+
manager = Store::StoreManager.new
|
|
75
|
+
manager.ensure_global! if manager.global_exists?
|
|
76
|
+
store = manager.global_store
|
|
77
|
+
status = OTel::Status.new(store, settings_writer: writer(opts)).snapshot
|
|
78
|
+
|
|
79
|
+
stdout.puts "OTel telemetry status:"
|
|
80
|
+
stdout.puts " metrics ingested: #{status[:metric_count]}"
|
|
81
|
+
stdout.puts " events ingested: #{status[:event_count]}"
|
|
82
|
+
stdout.puts " traces ingested: #{status[:trace_count]} (enabled: #{status[:traces_enabled]})"
|
|
83
|
+
stdout.puts " last metric: #{status[:last_metric_at] || "never"}"
|
|
84
|
+
stdout.puts " configured endpoint: #{status[:endpoint] || "(not configured — run --enable)"}"
|
|
85
|
+
manager.close
|
|
86
|
+
0
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_enable(opts)
|
|
90
|
+
result = writer(opts).enable!
|
|
91
|
+
return failure("could not enable telemetry: #{result.error}") if result.failure?
|
|
92
|
+
stdout.puts "Enabled OTel telemetry. Restart any active claude sessions for changes to take effect."
|
|
93
|
+
stdout.puts " endpoint: http://127.0.0.1:#{opts[:port]}"
|
|
94
|
+
stdout.puts " protocol: http/json"
|
|
95
|
+
stdout.puts ""
|
|
96
|
+
stdout.puts "Traces are off by default. Opt in with `claude-memory otel --enable-traces`."
|
|
97
|
+
0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def run_disable(opts)
|
|
101
|
+
result = writer(opts).disable!
|
|
102
|
+
return failure("could not disable telemetry: #{result.error}") if result.failure?
|
|
103
|
+
stdout.puts "Disabled OTel telemetry. Removed env keys from settings.json."
|
|
104
|
+
0
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_settings_change(opts)
|
|
108
|
+
result = yield(writer(opts))
|
|
109
|
+
return failure("settings update failed: #{result.error}") if result.failure?
|
|
110
|
+
stdout.puts "Settings updated:"
|
|
111
|
+
result.value.each { |key, value| stdout.puts " #{key}=#{value}" }
|
|
112
|
+
0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def run_capture_prompts(opts)
|
|
116
|
+
stdout.puts "WARNING: enabling OTEL_LOG_USER_PROMPTS=1 will cause Claude Code"
|
|
117
|
+
stdout.puts "to send your verbatim prompts (and the conversation history) to"
|
|
118
|
+
stdout.puts "this dashboard's local SQLite. Type 'yes' to continue."
|
|
119
|
+
stdout.print "> "
|
|
120
|
+
answer = stdin.gets&.strip
|
|
121
|
+
unless answer == "yes"
|
|
122
|
+
stdout.puts "Aborted. No changes made."
|
|
123
|
+
return 0
|
|
124
|
+
end
|
|
125
|
+
run_settings_change(opts) { |w| w.capture_prompts! }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# One-time pass: stream every otel_events row with a prompt_id from
|
|
129
|
+
# the global store and run it through PromptScope so historical
|
|
130
|
+
# activity_events get tagged. Subsequent runs are cheap because
|
|
131
|
+
# PromptScope is idempotent (already-tagged rows are excluded by
|
|
132
|
+
# the `prompt_id: nil` filter).
|
|
133
|
+
def run_backfill(opts)
|
|
134
|
+
manager = Store::StoreManager.new
|
|
135
|
+
unless manager.global_exists?
|
|
136
|
+
return failure("Global memory DB not found — nothing to backfill against.")
|
|
137
|
+
end
|
|
138
|
+
manager.ensure_global!
|
|
139
|
+
manager.ensure_project! if manager.project_exists?
|
|
140
|
+
|
|
141
|
+
store = manager.global_store
|
|
142
|
+
unless store.db.table_exists?(:otel_events)
|
|
143
|
+
manager.close
|
|
144
|
+
return failure("otel_events table missing — run `claude-memory dashboard` first to migrate.")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
total_events = store.otel_events.exclude(prompt_id: nil).count
|
|
148
|
+
if total_events.zero?
|
|
149
|
+
stdout.puts "No OTel events with prompt_id to backfill from."
|
|
150
|
+
manager.close
|
|
151
|
+
return 0
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
stdout.puts "Backfilling #{total_events} OTel event(s) into activity_events…"
|
|
155
|
+
scope = OTel::PromptScope.new(manager)
|
|
156
|
+
tagged = groups = batches = 0
|
|
157
|
+
offset = 0
|
|
158
|
+
batch_size = opts[:batch_size]
|
|
159
|
+
|
|
160
|
+
loop do
|
|
161
|
+
rows = store.otel_events
|
|
162
|
+
.exclude(prompt_id: nil)
|
|
163
|
+
.order(:occurred_at, :id)
|
|
164
|
+
.limit(batch_size)
|
|
165
|
+
.offset(offset)
|
|
166
|
+
.select(:event_name, :session_id, :prompt_id, :occurred_at)
|
|
167
|
+
.all
|
|
168
|
+
break if rows.empty?
|
|
169
|
+
|
|
170
|
+
events = rows.map { |r|
|
|
171
|
+
{event_name: r[:event_name], session_id: r[:session_id],
|
|
172
|
+
prompt_id: r[:prompt_id], occurred_at: r[:occurred_at]}
|
|
173
|
+
}
|
|
174
|
+
result = scope.tag(events)
|
|
175
|
+
tagged += result[:tagged].to_i
|
|
176
|
+
groups += result[:groups].to_i
|
|
177
|
+
batches += 1
|
|
178
|
+
offset += rows.size
|
|
179
|
+
stdout.print "."
|
|
180
|
+
stdout.flush
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
stdout.puts ""
|
|
184
|
+
stdout.puts "Backfill complete: tagged #{tagged} activity_event(s) across #{groups} prompt group(s) in #{batches} batch(es)."
|
|
185
|
+
manager.close
|
|
186
|
+
0
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def run_verify(opts)
|
|
190
|
+
url = URI.parse("http://127.0.0.1:#{opts[:port]}/v1/metrics")
|
|
191
|
+
body = JSON.generate(sample_metrics_envelope)
|
|
192
|
+
response = post_json(url, body)
|
|
193
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
194
|
+
stdout.puts "Verify OK: dashboard accepted the sample metrics envelope."
|
|
195
|
+
0
|
|
196
|
+
else
|
|
197
|
+
failure("Verify failed: dashboard returned #{response&.code || "no response"}")
|
|
198
|
+
end
|
|
199
|
+
rescue Errno::ECONNREFUSED, SocketError => e
|
|
200
|
+
failure("Verify failed: #{e.message}. Is the dashboard running on port #{opts[:port]}?")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def post_json(url, body)
|
|
204
|
+
Net::HTTP.start(url.host, url.port, open_timeout: 2, read_timeout: 5) do |http|
|
|
205
|
+
req = Net::HTTP::Post.new(url.path, "Content-Type" => "application/json")
|
|
206
|
+
req.body = body
|
|
207
|
+
http.request(req)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Smallest valid OTLP/HTTP/JSON metrics envelope — one counter point.
|
|
212
|
+
# Used only for the --verify subcommand so users can confirm
|
|
213
|
+
# end-to-end wiring without running a real claude session.
|
|
214
|
+
def sample_metrics_envelope
|
|
215
|
+
nano = (Time.now.to_f * 1_000_000_000).to_i.to_s
|
|
216
|
+
{
|
|
217
|
+
"resourceMetrics" => [{
|
|
218
|
+
"resource" => {"attributes" => [
|
|
219
|
+
{"key" => "service.name", "value" => {"stringValue" => "claude-memory-verify"}}
|
|
220
|
+
]},
|
|
221
|
+
"scopeMetrics" => [{
|
|
222
|
+
"scope" => {"name" => "claude-memory.verify"},
|
|
223
|
+
"metrics" => [{
|
|
224
|
+
"name" => "claude_memory.verify",
|
|
225
|
+
"unit" => "count",
|
|
226
|
+
"sum" => {
|
|
227
|
+
"dataPoints" => [{
|
|
228
|
+
"asInt" => "1",
|
|
229
|
+
"timeUnixNano" => nano,
|
|
230
|
+
"attributes" => []
|
|
231
|
+
}]
|
|
232
|
+
}
|
|
233
|
+
}]
|
|
234
|
+
}]
|
|
235
|
+
}]
|
|
236
|
+
}
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -42,7 +42,11 @@ module ClaudeMemory
|
|
|
42
42
|
"reclassify-references" => {class: ReclassifyReferencesCommand, description: "Retag existing convention facts that match reference-material heuristics"},
|
|
43
43
|
"census" => {class: CensusCommand, description: "Aggregate predicate usage across project databases"},
|
|
44
44
|
"dashboard" => {class: DashboardCommand, description: "Open debugging dashboard"},
|
|
45
|
-
"digest" => {class: DigestCommand, description: "Render a weekly markdown digest of memory activity"}
|
|
45
|
+
"digest" => {class: DigestCommand, description: "Render a weekly markdown digest of memory activity"},
|
|
46
|
+
"show" => {class: ShowCommand, description: "Print what memory would inject at the next SessionStart"},
|
|
47
|
+
"otel" => {class: OtelCommand, description: "Configure or inspect OpenTelemetry ingestion from Claude Code"},
|
|
48
|
+
"import-auto-memory" => {class: ImportAutoMemoryCommand, description: "Import Claude Code auto-memory .md files into project DB as facts"},
|
|
49
|
+
"audit" => {class: AuditCommand, description: "Run memory health audit; report inconsistencies and optimizations"}
|
|
46
50
|
}.freeze
|
|
47
51
|
|
|
48
52
|
# Find a command class by name
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module Commands
|
|
7
|
+
# Prints what memory would inject on the next SessionStart.
|
|
8
|
+
#
|
|
9
|
+
# The trust answer to "is this still worth it?" requires
|
|
10
|
+
# inspectability: a user who can't see what memory will inject can't
|
|
11
|
+
# develop confidence in it. The CLAUDE.md alternative is `cat
|
|
12
|
+
# CLAUDE.md` — instant, plain English, no tooling. This command is
|
|
13
|
+
# the same one-line inspect surface for the curated facts the
|
|
14
|
+
# injector picks each session.
|
|
15
|
+
#
|
|
16
|
+
# Runs the exact `Hook::ContextInjector` path real sessions use, so
|
|
17
|
+
# what you see here is what Claude actually receives — not a
|
|
18
|
+
# rebuilt approximation that could drift.
|
|
19
|
+
#
|
|
20
|
+
# The default suppresses the "Pending Knowledge Extraction" dump
|
|
21
|
+
# (which contains raw transcript JSON intended for LLM distillation)
|
|
22
|
+
# so the output stays human-readable. Pass `--pending` to see the
|
|
23
|
+
# full fresh-session payload, including those raw items.
|
|
24
|
+
class ShowCommand < BaseCommand
|
|
25
|
+
VALID_SOURCES = %w[startup resume clear].freeze
|
|
26
|
+
|
|
27
|
+
# Any string outside FRESH_SESSION_SOURCES skips the pending-knowledge
|
|
28
|
+
# block. "preview" reads naturally in any debug log this surfaces in.
|
|
29
|
+
NON_FRESH_SOURCE = "preview"
|
|
30
|
+
|
|
31
|
+
def call(args)
|
|
32
|
+
opts = parse_options(args, {source: nil, pending: false}) do |o|
|
|
33
|
+
OptionParser.new do |parser|
|
|
34
|
+
parser.banner = "Usage: claude-memory show [--source SOURCE] [--pending]"
|
|
35
|
+
parser.on("--source SOURCE", VALID_SOURCES,
|
|
36
|
+
"Simulate fresh-session source (#{VALID_SOURCES.join(", ")}). " \
|
|
37
|
+
"Forces inclusion of pending-knowledge and auto-memory-mirror " \
|
|
38
|
+
"sections regardless of --pending.") { |v| o[:source] = v }
|
|
39
|
+
parser.on("--pending",
|
|
40
|
+
"Include the pending-knowledge dump (raw transcript JSON " \
|
|
41
|
+
"for LLM distillation). Default suppresses it for readability.") { o[:pending] = true }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
return 1 if opts.nil?
|
|
45
|
+
|
|
46
|
+
effective_source = opts[:source] || (opts[:pending] ? nil : NON_FRESH_SOURCE)
|
|
47
|
+
|
|
48
|
+
manager = Store::StoreManager.new
|
|
49
|
+
manager.ensure_both!
|
|
50
|
+
injector = Hook::ContextInjector.new(manager, source: effective_source)
|
|
51
|
+
context = injector.generate_context
|
|
52
|
+
|
|
53
|
+
print_header(opts[:source])
|
|
54
|
+
stdout.puts ""
|
|
55
|
+
|
|
56
|
+
if context.nil? || context.strip.empty?
|
|
57
|
+
stdout.puts "_Memory has no facts to inject yet._"
|
|
58
|
+
stdout.puts ""
|
|
59
|
+
stdout.puts "Run a few Claude Code sessions in this project, or use"
|
|
60
|
+
stdout.puts "`memory.store_extraction` from a session to seed facts."
|
|
61
|
+
else
|
|
62
|
+
stdout.puts context
|
|
63
|
+
stdout.puts ""
|
|
64
|
+
print_footer(injector, context)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
manager.close
|
|
68
|
+
0
|
|
69
|
+
rescue Sequel::DatabaseError => e
|
|
70
|
+
failure("Database error: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def print_header(source)
|
|
76
|
+
label = source ? " (source=#{source})" : ""
|
|
77
|
+
stdout.puts "## Memory snapshot — would be injected at next SessionStart#{label}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def print_footer(injector, context)
|
|
81
|
+
tokens = Core::TokenEstimator.estimate(context)
|
|
82
|
+
fact_count = injector.emitted_fact_ids.size
|
|
83
|
+
stdout.puts "---"
|
|
84
|
+
stdout.puts "#{fact_count} fact#{"s" unless fact_count == 1} • " \
|
|
85
|
+
"~#{tokens} token#{"s" unless tokens == 1} • " \
|
|
86
|
+
"#{context.length} chars"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -13,14 +13,15 @@ module ClaudeMemory
|
|
|
13
13
|
SCOPE_PROJECT = "project"
|
|
14
14
|
|
|
15
15
|
def call(args)
|
|
16
|
-
opts = parse_options(args, {scope: SCOPE_ALL, tools: false, stale: false, since_days: nil, stale_days: nil}) do |o|
|
|
16
|
+
opts = parse_options(args, {scope: SCOPE_ALL, tools: false, tokens: false, stale: false, since_days: nil, stale_days: nil}) do |o|
|
|
17
17
|
OptionParser.new do |parser|
|
|
18
18
|
parser.banner = "Usage: claude-memory stats [options]"
|
|
19
19
|
parser.on("--scope SCOPE", ["all", "global", "project"],
|
|
20
20
|
"Show stats for: all (default), global, or project") { |v| o[:scope] = v }
|
|
21
21
|
parser.on("--tools", "Show MCP tool-call usage stats") { o[:tools] = true }
|
|
22
|
+
parser.on("--tokens", "Show SessionStart context-injection token budget") { o[:tokens] = true }
|
|
22
23
|
parser.on("--stale", "Show facts not recalled in CLAUDE_MEMORY_STALE_DAYS (default 14)") { o[:stale] = true }
|
|
23
|
-
parser.on("--since DAYS", Integer, "Limit --tools to last N days") { |v| o[:since_days] = v }
|
|
24
|
+
parser.on("--since DAYS", Integer, "Limit --tools/--tokens to last N days") { |v| o[:since_days] = v }
|
|
24
25
|
parser.on("--stale-days N", Integer, "Override staleness threshold for --stale") { |v| o[:stale_days] = v }
|
|
25
26
|
end
|
|
26
27
|
end
|
|
@@ -30,6 +31,10 @@ module ClaudeMemory
|
|
|
30
31
|
return print_mcp_tool_call_stats(opts[:since_days])
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
if opts[:tokens]
|
|
35
|
+
return print_token_budget_stats(opts[:since_days])
|
|
36
|
+
end
|
|
37
|
+
|
|
33
38
|
if opts[:stale]
|
|
34
39
|
return print_stale_facts(opts[:stale_days])
|
|
35
40
|
end
|
|
@@ -349,6 +354,93 @@ module ClaudeMemory
|
|
|
349
354
|
1
|
|
350
355
|
end
|
|
351
356
|
|
|
357
|
+
TOKEN_BUCKETS = [
|
|
358
|
+
["<500", 0, 500],
|
|
359
|
+
["500-1000", 500, 1000],
|
|
360
|
+
["1000-2000", 1000, 2000],
|
|
361
|
+
["2000-5000", 2000, 5000],
|
|
362
|
+
["5000+", 5000, Float::INFINITY]
|
|
363
|
+
].freeze
|
|
364
|
+
|
|
365
|
+
def print_token_budget_stats(since_days)
|
|
366
|
+
manager = ClaudeMemory::Store::StoreManager.new
|
|
367
|
+
db_path = manager.project_db_path
|
|
368
|
+
|
|
369
|
+
stdout.puts "SessionStart Context Token Budget"
|
|
370
|
+
stdout.puts "=" * 50
|
|
371
|
+
|
|
372
|
+
unless File.exist?(db_path)
|
|
373
|
+
stdout.puts "Project database does not exist: #{db_path}"
|
|
374
|
+
manager.close
|
|
375
|
+
return 0
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
db = open_readonly(db_path)
|
|
379
|
+
|
|
380
|
+
unless db.table_exists?(:activity_events)
|
|
381
|
+
stdout.puts "No activity telemetry recorded yet."
|
|
382
|
+
db.disconnect
|
|
383
|
+
manager.close
|
|
384
|
+
return 0
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
dataset = db[:activity_events]
|
|
388
|
+
.where(event_type: "hook_context", status: "success")
|
|
389
|
+
if since_days
|
|
390
|
+
cutoff = (Time.now - since_days * 86400).utc.iso8601
|
|
391
|
+
dataset = dataset.where { occurred_at >= cutoff }
|
|
392
|
+
stdout.puts "Window: last #{since_days} day#{"s" unless since_days == 1}"
|
|
393
|
+
else
|
|
394
|
+
stdout.puts "Window: all time"
|
|
395
|
+
end
|
|
396
|
+
stdout.puts
|
|
397
|
+
|
|
398
|
+
tokens = dataset.select_map(:detail_json).filter_map do |json|
|
|
399
|
+
next unless json
|
|
400
|
+
value = JSON.parse(json)["context_tokens"]
|
|
401
|
+
value if value.is_a?(Integer) && value > 0
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
if tokens.empty?
|
|
405
|
+
stdout.puts "No context injections recorded in window."
|
|
406
|
+
stdout.puts ""
|
|
407
|
+
stdout.puts "Token telemetry is recorded automatically on SessionStart hooks."
|
|
408
|
+
stdout.puts "Run a Claude Code session in this project to populate."
|
|
409
|
+
db.disconnect
|
|
410
|
+
manager.close
|
|
411
|
+
return 0
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
sorted = tokens.sort
|
|
415
|
+
total = sorted.size
|
|
416
|
+
stdout.puts "Sessions: #{format_number(total)}"
|
|
417
|
+
stdout.puts "p50: #{format_number(percentile(sorted, 0.50))} tokens"
|
|
418
|
+
stdout.puts "p95: #{format_number(percentile(sorted, 0.95))} tokens"
|
|
419
|
+
stdout.puts "Avg: #{format_number((sorted.sum.to_f / total).round)} tokens"
|
|
420
|
+
stdout.puts "Min: #{format_number(sorted.first)} tokens"
|
|
421
|
+
stdout.puts "Max: #{format_number(sorted.last)} tokens"
|
|
422
|
+
stdout.puts ""
|
|
423
|
+
print_token_distribution(sorted)
|
|
424
|
+
|
|
425
|
+
db.disconnect
|
|
426
|
+
manager.close
|
|
427
|
+
0
|
|
428
|
+
rescue Sequel::DatabaseError, JSON::ParserError, Extralite::Error => e
|
|
429
|
+
stderr.puts "Error reading token telemetry: #{e.message}"
|
|
430
|
+
1
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def print_token_distribution(sorted)
|
|
434
|
+
total = sorted.size
|
|
435
|
+
stdout.puts "Distribution:"
|
|
436
|
+
TOKEN_BUCKETS.each do |label, low, high|
|
|
437
|
+
count = sorted.count { |t| t >= low && t < high }
|
|
438
|
+
pct = (count * 100.0 / total).round(1)
|
|
439
|
+
bar = "█" * (pct / 5).round
|
|
440
|
+
stdout.puts " #{label.ljust(12)} #{count.to_s.rjust(5)} (#{pct.to_s.rjust(5)}%) #{bar}"
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
352
444
|
def print_per_tool_breakdown(dataset)
|
|
353
445
|
stdout.puts "Per-tool breakdown:"
|
|
354
446
|
stdout.puts " #{"Tool".ljust(28)} #{"Calls".rjust(7)} #{"Avg ms".rjust(8)} #{"P95 ms".rjust(8)} #{"Err %".rjust(6)}"
|
|
@@ -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
|