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.
- 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 +26 -0
- data/CLAUDE.md +9 -2
- data/README.md +29 -1
- data/db/migrations/018_add_otel_telemetry.rb +81 -0
- data/docs/1_0_punchlist.md +318 -66
- data/docs/api_stability.md +341 -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/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/import_auto_memory_command.rb +180 -0
- data/lib/claude_memory/commands/otel_command.rb +240 -0
- data/lib/claude_memory/commands/registry.rb +4 -1
- 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 +18 -0
- 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://
|
|
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
|