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.
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 +42 -64
  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 +1 -1
  9. data/CHANGELOG.md +70 -0
  10. data/CLAUDE.md +20 -5
  11. data/README.md +64 -2
  12. data/db/migrations/018_add_otel_telemetry.rb +81 -0
  13. data/docs/1_0_punchlist.md +522 -89
  14. data/docs/GETTING_STARTED.md +3 -1
  15. data/docs/api_stability.md +341 -0
  16. data/docs/architecture.md +3 -3
  17. data/docs/audit_runbook.md +209 -0
  18. data/docs/claude_monitoring.md +956 -0
  19. data/docs/dashboard.md +23 -3
  20. data/docs/improvements.md +329 -5
  21. data/docs/influence/ai-memory-systems-2026.md +403 -0
  22. data/docs/memory_audit_2026-05-21.md +303 -0
  23. data/docs/plugin.md +1 -1
  24. data/docs/quality_review.md +35 -0
  25. data/lib/claude_memory/audit/checks.rb +239 -0
  26. data/lib/claude_memory/audit/finding.rb +33 -0
  27. data/lib/claude_memory/audit/runner.rb +73 -0
  28. data/lib/claude_memory/commands/audit_command.rb +117 -0
  29. data/lib/claude_memory/commands/dashboard_command.rb +2 -1
  30. data/lib/claude_memory/commands/digest_command.rb +95 -3
  31. data/lib/claude_memory/commands/hook_command.rb +27 -2
  32. data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
  33. data/lib/claude_memory/commands/initializers/hooks_configurator.rb +7 -4
  34. data/lib/claude_memory/commands/otel_command.rb +240 -0
  35. data/lib/claude_memory/commands/registry.rb +5 -1
  36. data/lib/claude_memory/commands/show_command.rb +90 -0
  37. data/lib/claude_memory/commands/stats_command.rb +94 -2
  38. data/lib/claude_memory/configuration.rb +60 -0
  39. data/lib/claude_memory/core/fact_query_builder.rb +1 -0
  40. data/lib/claude_memory/dashboard/api.rb +8 -0
  41. data/lib/claude_memory/dashboard/index.html +140 -1
  42. data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
  43. data/lib/claude_memory/dashboard/server.rb +86 -0
  44. data/lib/claude_memory/dashboard/telemetry.rb +156 -0
  45. data/lib/claude_memory/dashboard/trust.rb +180 -11
  46. data/lib/claude_memory/deprecations.rb +106 -0
  47. data/lib/claude_memory/distill/bare_conclusion_detector.rb +71 -0
  48. data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
  49. data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
  50. data/lib/claude_memory/hook/context_injector.rb +11 -2
  51. data/lib/claude_memory/hook/handler.rb +142 -1
  52. data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
  53. data/lib/claude_memory/otel/attributes.rb +118 -0
  54. data/lib/claude_memory/otel/constants.rb +32 -0
  55. data/lib/claude_memory/otel/ingestor.rb +54 -0
  56. data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
  57. data/lib/claude_memory/otel/prompt_scope.rb +108 -0
  58. data/lib/claude_memory/otel/settings_writer.rb +122 -0
  59. data/lib/claude_memory/otel/status.rb +58 -0
  60. data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
  61. data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
  62. data/lib/claude_memory/resolve/resolver.rb +30 -3
  63. data/lib/claude_memory/shortcuts.rb +61 -18
  64. data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
  65. data/lib/claude_memory/store/schema_manager.rb +1 -1
  66. data/lib/claude_memory/store/sqlite_store.rb +136 -0
  67. data/lib/claude_memory/sweep/maintenance.rb +31 -1
  68. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  69. data/lib/claude_memory/templates/hooks.example.json +5 -0
  70. data/lib/claude_memory/version.rb +1 -1
  71. data/lib/claude_memory.rb +20 -0
  72. 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