claude_memory 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) 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 +26 -0
  10. data/CLAUDE.md +9 -2
  11. data/README.md +29 -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 +341 -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/lib/claude_memory/audit/checks.rb +239 -0
  22. data/lib/claude_memory/audit/finding.rb +33 -0
  23. data/lib/claude_memory/audit/runner.rb +73 -0
  24. data/lib/claude_memory/commands/audit_command.rb +117 -0
  25. data/lib/claude_memory/commands/dashboard_command.rb +2 -1
  26. data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
  27. data/lib/claude_memory/commands/otel_command.rb +240 -0
  28. data/lib/claude_memory/commands/registry.rb +4 -1
  29. data/lib/claude_memory/configuration.rb +60 -0
  30. data/lib/claude_memory/core/fact_query_builder.rb +1 -0
  31. data/lib/claude_memory/dashboard/api.rb +8 -0
  32. data/lib/claude_memory/dashboard/index.html +140 -1
  33. data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
  34. data/lib/claude_memory/dashboard/server.rb +86 -0
  35. data/lib/claude_memory/dashboard/telemetry.rb +156 -0
  36. data/lib/claude_memory/deprecations.rb +106 -0
  37. data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
  38. data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
  39. data/lib/claude_memory/hook/context_injector.rb +11 -2
  40. data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
  41. data/lib/claude_memory/otel/attributes.rb +118 -0
  42. data/lib/claude_memory/otel/constants.rb +32 -0
  43. data/lib/claude_memory/otel/ingestor.rb +54 -0
  44. data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
  45. data/lib/claude_memory/otel/prompt_scope.rb +108 -0
  46. data/lib/claude_memory/otel/settings_writer.rb +122 -0
  47. data/lib/claude_memory/otel/status.rb +58 -0
  48. data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
  49. data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
  50. data/lib/claude_memory/resolve/resolver.rb +30 -3
  51. data/lib/claude_memory/shortcuts.rb +61 -18
  52. data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
  53. data/lib/claude_memory/store/schema_manager.rb +1 -1
  54. data/lib/claude_memory/store/sqlite_store.rb +136 -0
  55. data/lib/claude_memory/sweep/maintenance.rb +31 -1
  56. data/lib/claude_memory/sweep/sweeper.rb +6 -0
  57. data/lib/claude_memory/version.rb +1 -1
  58. data/lib/claude_memory.rb +18 -0
  59. metadata +26 -1
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module ClaudeMemory
7
+ module Commands
8
+ # Runs the memory health audit and prints findings. Exits non-zero
9
+ # when error-severity findings are present (unless --no-exit is
10
+ # given). JSON output is the stable surface — humans should not
11
+ # script against the text output.
12
+ class AuditCommand < BaseCommand
13
+ SEVERITY_RANK = {info: 0, warn: 1, error: 2}.freeze
14
+
15
+ def call(args)
16
+ opts = parse_opts(args)
17
+ return 1 if opts.nil?
18
+
19
+ manager = Store::StoreManager.new
20
+ result = Audit::Runner.new(manager: manager).run
21
+ filtered = filter_by_severity(result.findings, opts[:severity])
22
+
23
+ if opts[:json]
24
+ stdout.puts JSON.pretty_generate(payload(result, filtered))
25
+ else
26
+ render_text(result, filtered)
27
+ end
28
+
29
+ manager.close
30
+ opts[:no_exit] ? 0 : result.exit_code
31
+ end
32
+
33
+ def filter_by_severity(findings, threshold)
34
+ return findings if threshold.nil?
35
+ floor = SEVERITY_RANK.fetch(threshold) { return findings }
36
+ findings.select { |f| SEVERITY_RANK[f.severity] >= floor }
37
+ end
38
+
39
+ private
40
+
41
+ def parse_opts(args)
42
+ options = {json: false, no_exit: false, severity: nil}
43
+ parser = OptionParser.new do |o|
44
+ o.banner = "Usage: claude-memory audit [--json] [--no-exit] [--severity=error|warn|info]"
45
+ o.on("--json", "Emit JSON instead of text") { options[:json] = true }
46
+ o.on("--no-exit", "Always exit 0 even on error-severity findings") { options[:no_exit] = true }
47
+ o.on("--severity LEVEL", "Only show findings at or above LEVEL (error|warn|info)") { |v| options[:severity] = v.to_sym }
48
+ end
49
+ parser.parse!(args.dup)
50
+ options
51
+ rescue OptionParser::InvalidOption => e
52
+ stderr.puts e.message
53
+ nil
54
+ end
55
+
56
+ def payload(result, filtered)
57
+ {
58
+ ok: result.ok?,
59
+ checks_run: result.stats[:checks_run],
60
+ counts: {
61
+ error: result.errors.size,
62
+ warn: result.warnings.size,
63
+ info: result.info.size
64
+ },
65
+ stats: result.stats.except(:checks_run),
66
+ findings: filtered.map(&:to_h)
67
+ }
68
+ end
69
+
70
+ def render_text(result, filtered)
71
+ stdout.puts "Memory health audit — #{Time.now.utc.iso8601}"
72
+ stdout.puts("=" * 60)
73
+ render_stats(result.stats)
74
+ stdout.puts ""
75
+ render_summary(result)
76
+ stdout.puts ""
77
+ render_findings(filtered)
78
+ stdout.puts ""
79
+ stdout.puts(result.ok? ? "OK" : "FAIL")
80
+ end
81
+
82
+ def render_stats(stats)
83
+ %i[global project].each do |scope|
84
+ s = stats[scope]
85
+ next unless s
86
+ preds = s[:predicate_counts].map { |k, v| "#{k}=#{v}" }.join(", ")
87
+ stdout.puts "#{scope.to_s.capitalize.ljust(7)} #{s[:active_facts]} active facts #{preds}"
88
+ end
89
+ end
90
+
91
+ def render_summary(result)
92
+ stdout.puts "Checks run: #{result.stats[:checks_run]}"
93
+ stdout.puts "Errors: #{result.errors.size} Warnings: #{result.warnings.size} Info: #{result.info.size}"
94
+ end
95
+
96
+ def render_findings(findings)
97
+ if findings.empty?
98
+ stdout.puts "No findings."
99
+ return
100
+ end
101
+
102
+ findings.each do |f|
103
+ marker = case f.severity
104
+ when :error then "[ERROR]"
105
+ when :warn then "[WARN]"
106
+ when :info then "[INFO]"
107
+ end
108
+ stdout.puts "#{marker} #{f.id} #{f.title}"
109
+ stdout.puts " #{f.detail}"
110
+ stdout.puts " → #{f.suggestion}"
111
+ stdout.puts " fact_ids: #{f.fact_ids.first(20).inspect}#{" (+#{f.fact_ids.size - 20} more)" if f.fact_ids.size > 20}" if f.fact_ids.any?
112
+ stdout.puts ""
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -36,7 +36,8 @@ module ClaudeMemory
36
36
  manager.ensure_global! if manager.global_exists?
37
37
  manager.ensure_project! if manager.project_exists?
38
38
 
39
- stdout.puts "Starting ClaudeMemory dashboard on http://localhost:#{opts[:port]}"
39
+ stdout.puts "Starting ClaudeMemory dashboard on http://127.0.0.1:#{opts[:port]}"
40
+ stdout.puts "OTel receiver listening at http://127.0.0.1:#{opts[:port]}/v1/{metrics,logs,traces}"
40
41
  stdout.puts "Press Ctrl+C to stop."
41
42
 
42
43
  server = Dashboard::Server.new(
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "optparse"
5
+
6
+ module ClaudeMemory
7
+ module Commands
8
+ # Imports Claude Code auto-memory files (~/.claude/projects/<slug>/memory/*.md)
9
+ # into the project database as durable facts. Before this command, those
10
+ # markdown files were only surfaced transiently via `Hook::AutoMemoryMirror`
11
+ # at SessionStart — they were invisible to `memory.recall` and the
12
+ # shortcut tools. Importing them as facts (predicate=convention for
13
+ # gotcha/feedback/project files, predicate=reference for reference
14
+ # type) makes that knowledge first-class queryable knowledge.
15
+ #
16
+ # Idempotent on object_literal prefix: re-running skips files whose
17
+ # body text is already present.
18
+ class ImportAutoMemoryCommand < BaseCommand
19
+ def call(args)
20
+ opts = parse_opts(args)
21
+ return 1 if opts.nil?
22
+
23
+ auto_dir = resolve_auto_dir
24
+ files = list_files(auto_dir)
25
+ if files.empty?
26
+ stdout.puts "No auto-memory files found in #{auto_dir}"
27
+ return 0
28
+ end
29
+
30
+ manager = Store::StoreManager.new
31
+ manager.ensure_project!
32
+ store = manager.project_store
33
+
34
+ imported = 0
35
+ skipped = 0
36
+ files.each do |path|
37
+ fact_data = parse_file(path)
38
+ next if fact_data.nil?
39
+
40
+ if already_imported?(store, fact_data[:object_literal])
41
+ skipped += 1
42
+ next
43
+ end
44
+
45
+ if opts[:dry_run]
46
+ stdout.puts "[DRY] #{File.basename(path)} → #{fact_data[:predicate]}"
47
+ else
48
+ insert(store, fact_data, path)
49
+ stdout.puts "Imported: #{File.basename(path)} → #{fact_data[:predicate]}"
50
+ end
51
+ imported += 1
52
+ end
53
+
54
+ stdout.puts ""
55
+ verb = opts[:dry_run] ? "Would import" : "Imported"
56
+ stdout.puts "#{verb}: #{imported} Skipped (already present): #{skipped}"
57
+ manager.close
58
+ 0
59
+ end
60
+
61
+ private
62
+
63
+ def parse_opts(args)
64
+ options = {dry_run: false}
65
+ parser = OptionParser.new do |o|
66
+ o.banner = "Usage: claude-memory import-auto-memory [--dry-run]"
67
+ o.on("--dry-run", "Show what would be imported without writing") { options[:dry_run] = true }
68
+ end
69
+ parser.parse!(args.dup)
70
+ options
71
+ rescue OptionParser::InvalidOption => e
72
+ stderr.puts e.message
73
+ nil
74
+ end
75
+
76
+ def resolve_auto_dir
77
+ config = Configuration.new
78
+ Hook::AutoMemoryMirror.default_dir(config.project_dir, config.claude_config_dir)
79
+ end
80
+
81
+ def list_files(dir)
82
+ return [] unless Dir.exist?(dir)
83
+ Dir.glob(File.join(dir, "*.md")).reject { |f| File.basename(f) == "MEMORY.md" }.sort
84
+ end
85
+
86
+ def parse_file(path)
87
+ text = File.read(path)
88
+ return nil unless (match = text.match(/\A---\n(.*?)\n---\n(.*)\z/m))
89
+
90
+ frontmatter = match[1]
91
+ body = match[2].strip
92
+
93
+ name = frontmatter[/^name:\s*(.+)/, 1]&.strip
94
+ type = frontmatter[/^type:\s*(.+)/, 1]&.strip
95
+ description = frontmatter[/^description:\s*(.+)/, 1]&.strip
96
+ return nil if name.nil? || type.nil?
97
+
98
+ predicate, subject, scope = map_type(type)
99
+ object = build_object(name, description, body)
100
+
101
+ {
102
+ subject: subject,
103
+ predicate: predicate,
104
+ object_literal: object,
105
+ scope: scope
106
+ }
107
+ end
108
+
109
+ def map_type(type)
110
+ case type
111
+ when "feedback", "user"
112
+ ["convention", "user", "global"]
113
+ when "reference"
114
+ ["reference", "repo", "project"]
115
+ else
116
+ # gotcha, project, anything else
117
+ ["convention", "repo", "project"]
118
+ end
119
+ end
120
+
121
+ # Build an object string that carries a reason clause so
122
+ # BareConclusionDetector does not flag the fact as bare. Auto-memory
123
+ # files conventionally include a **Why:** section; we surface the first
124
+ # 400 chars of the body alongside the name as the object text.
125
+ def build_object(name, description, body)
126
+ first_para = body.split("\n\n").first.to_s.strip
127
+ first_para = first_para[0..400] + "..." if first_para.length > 400
128
+
129
+ parts = [name]
130
+ parts << description if description && !description.empty? && description != name
131
+ parts << first_para unless first_para.empty?
132
+ text = parts.join(" — ").gsub(/\s+/, " ").strip
133
+
134
+ # If no reason clause is present, attach a stable suffix so the fact
135
+ # is structurally distinguishable from bare conclusions.
136
+ text += " (imported from project auto-memory; see source file for full reasoning)" unless reason_present?(text)
137
+ text
138
+ end
139
+
140
+ def reason_present?(text)
141
+ text.match?(/\b(because|so that|so the|so we|in order to|to avoid|to ensure|to prevent|prevents|otherwise|caused by|breaks when)\b/i)
142
+ end
143
+
144
+ def already_imported?(store, object_text)
145
+ needle = object_text[0..80].gsub(/[%_]/) { |c| "\\#{c}" }
146
+ !store.facts.where(Sequel.like(:object_literal, "#{needle}%")).limit(1).all.empty?
147
+ end
148
+
149
+ def insert(store, fact_data, path)
150
+ store.db.transaction do
151
+ subject_type = (fact_data[:subject] == "user") ? "person" : "repo"
152
+ subject_id = store.find_or_create_entity(type: subject_type, name: fact_data[:subject])
153
+
154
+ fact_id = store.insert_fact(
155
+ subject_entity_id: subject_id,
156
+ predicate: fact_data[:predicate],
157
+ object_literal: fact_data[:object_literal],
158
+ scope: fact_data[:scope],
159
+ project_path: (fact_data[:scope] == "global") ? nil : Configuration.new.project_dir
160
+ )
161
+
162
+ content_id = store.upsert_content_item(
163
+ source: "auto_memory_import",
164
+ session_id: nil,
165
+ text_hash: Digest::SHA256.hexdigest(path + fact_data[:object_literal]),
166
+ byte_len: fact_data[:object_literal].bytesize,
167
+ raw_text: fact_data[:object_literal]
168
+ )
169
+
170
+ store.insert_provenance(
171
+ fact_id: fact_id,
172
+ content_item_id: content_id,
173
+ quote: fact_data[:object_literal][0..200],
174
+ strength: "stated"
175
+ )
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -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
@@ -43,7 +43,10 @@ module ClaudeMemory
43
43
  "census" => {class: CensusCommand, description: "Aggregate predicate usage across project databases"},
44
44
  "dashboard" => {class: DashboardCommand, description: "Open debugging dashboard"},
45
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"}
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"}
47
50
  }.freeze
48
51
 
49
52
  # Find a command class by name
@@ -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