claude_memory 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +54 -85
- data/.claude/skills/release/SKILL.md +44 -6
- data/.claude/skills/study-repo/SKILL.md +15 -0
- data/.claude-plugin/commands/audit-memory.md +68 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +2 -4
- data/CHANGELOG.md +50 -0
- data/CLAUDE.md +11 -4
- data/README.md +40 -1
- data/db/migrations/018_add_otel_telemetry.rb +81 -0
- data/docs/1_0_punchlist.md +318 -66
- data/docs/api_stability.md +346 -0
- data/docs/audit_runbook.md +209 -0
- data/docs/claude_monitoring.md +956 -0
- data/docs/improvements.md +148 -9
- data/docs/influence/ai-memory-systems-2026.md +403 -0
- data/docs/memory_audit_2026-05-21.md +303 -0
- data/docs/plugin.md +1 -1
- data/docs/soak/audit_2026-06-03_agent-training-program.json +53 -0
- data/docs/soak/audit_2026-06-03_agentic.json +31 -0
- data/docs/soak/audit_2026-06-03_ai-software-architect.json +19 -0
- data/docs/soak/audit_2026-06-03_chaos_to_the_rescue.json +60 -0
- data/docs/soak/audit_2026-06-03_claude_memory.json +55 -0
- data/docs/soak/audit_2026-06-03_daily-vibe.json +59 -0
- data/docs/soak/audit_2026-06-03_minerva-sky.json +19 -0
- data/docs/soak/audit_2026-06-03_nowreading.dev.json +19 -0
- data/docs/soak/audit_2026-06-03_ups.dev.json +55 -0
- data/docs/soak/baseline_2026-06-03.md +145 -0
- data/lib/claude_memory/audit/checks.rb +239 -0
- data/lib/claude_memory/audit/finding.rb +33 -0
- data/lib/claude_memory/audit/runner.rb +73 -0
- data/lib/claude_memory/commands/audit_command.rb +117 -0
- data/lib/claude_memory/commands/checks/embeddings_check.rb +97 -0
- data/lib/claude_memory/commands/dashboard_command.rb +2 -1
- data/lib/claude_memory/commands/doctor_command.rb +1 -0
- data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
- data/lib/claude_memory/commands/otel_command.rb +240 -0
- data/lib/claude_memory/commands/registry.rb +5 -1
- data/lib/claude_memory/commands/setup_vectors_command.rb +182 -0
- data/lib/claude_memory/configuration.rb +60 -0
- data/lib/claude_memory/core/fact_query_builder.rb +1 -0
- data/lib/claude_memory/dashboard/api.rb +8 -0
- data/lib/claude_memory/dashboard/index.html +140 -1
- data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
- data/lib/claude_memory/dashboard/server.rb +86 -0
- data/lib/claude_memory/dashboard/telemetry.rb +156 -0
- data/lib/claude_memory/deprecations.rb +106 -0
- data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
- data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
- data/lib/claude_memory/hook/context_injector.rb +11 -2
- data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
- data/lib/claude_memory/otel/attributes.rb +118 -0
- data/lib/claude_memory/otel/constants.rb +32 -0
- data/lib/claude_memory/otel/ingestor.rb +54 -0
- data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
- data/lib/claude_memory/otel/prompt_scope.rb +108 -0
- data/lib/claude_memory/otel/settings_writer.rb +122 -0
- data/lib/claude_memory/otel/status.rb +58 -0
- data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
- data/lib/claude_memory/resolve/resolver.rb +30 -3
- data/lib/claude_memory/shortcuts.rb +61 -18
- data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +136 -0
- data/lib/claude_memory/sweep/maintenance.rb +31 -1
- data/lib/claude_memory/sweep/sweeper.rb +6 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +20 -0
- metadata +38 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Commands
|
|
5
|
+
module Checks
|
|
6
|
+
# Surfaces the active embedding provider, model, and dimension
|
|
7
|
+
# alignment between provider and stored vectors.
|
|
8
|
+
#
|
|
9
|
+
# Doctor previously had VecCheck (sqlite-vec extension + index
|
|
10
|
+
# coverage) but no signal about which provider was actually in use —
|
|
11
|
+
# so a user could see "sqlite-vec available ✓" while silently
|
|
12
|
+
# running on tfidf default when fastembed was loadable. This check
|
|
13
|
+
# closes that visibility gap and points users at
|
|
14
|
+
# `claude-memory setup-vectors` to opt into fastembed.
|
|
15
|
+
class EmbeddingsCheck
|
|
16
|
+
FASTEMBED_HINT = "Set CLAUDE_MEMORY_EMBEDDING_PROVIDER=fastembed for higher-quality semantic recall (fastembed is loadable on this system). " \
|
|
17
|
+
"Run 'claude-memory setup-vectors' to configure."
|
|
18
|
+
|
|
19
|
+
FASTEMBED_INSTALL_HINT = "fastembed is not installed; semantic recall is using tfidf (lower quality). " \
|
|
20
|
+
"Run 'claude-memory setup-vectors' to install fastembed and switch."
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
provider = Embeddings.resolve
|
|
24
|
+
provider_name = provider.name
|
|
25
|
+
warnings = []
|
|
26
|
+
|
|
27
|
+
# Hint when user is on default tfidf — different message
|
|
28
|
+
# depending on whether fastembed is even loadable.
|
|
29
|
+
if provider_name == "tfidf"
|
|
30
|
+
warnings << (fastembed_loadable? ? FASTEMBED_HINT : FASTEMBED_INSTALL_HINT)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
dim_mismatches = check_dimension_alignment(provider)
|
|
34
|
+
warnings.concat(dim_mismatches)
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
status: warnings.any? ? :warning : :ok,
|
|
38
|
+
label: "embeddings",
|
|
39
|
+
message: "Embedding provider: #{provider_name}, dimensions: #{provider.dimensions}",
|
|
40
|
+
details: {
|
|
41
|
+
provider: provider_name,
|
|
42
|
+
dimensions: provider.dimensions,
|
|
43
|
+
fastembed_loadable: fastembed_loadable?
|
|
44
|
+
},
|
|
45
|
+
warnings: warnings
|
|
46
|
+
}
|
|
47
|
+
rescue => e
|
|
48
|
+
{
|
|
49
|
+
status: :warning,
|
|
50
|
+
label: "embeddings",
|
|
51
|
+
message: "Embedding provider check failed: #{e.message}",
|
|
52
|
+
details: {},
|
|
53
|
+
warnings: []
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def fastembed_loadable?
|
|
60
|
+
return @fastembed_loadable if defined?(@fastembed_loadable)
|
|
61
|
+
@fastembed_loadable = begin
|
|
62
|
+
require "fastembed"
|
|
63
|
+
true
|
|
64
|
+
rescue LoadError
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_dimension_alignment(provider)
|
|
70
|
+
config = Configuration.new
|
|
71
|
+
mismatches = []
|
|
72
|
+
|
|
73
|
+
[config.global_db_path, config.project_db_path].each do |db_path|
|
|
74
|
+
next unless File.exist?(db_path)
|
|
75
|
+
|
|
76
|
+
store = nil
|
|
77
|
+
begin
|
|
78
|
+
store = Store::SQLiteStore.new(db_path)
|
|
79
|
+
result = Embeddings::DimensionCheck.call(store, provider)
|
|
80
|
+
next unless result.status == :mismatch
|
|
81
|
+
|
|
82
|
+
mismatches << "Dimension mismatch in #{File.basename(File.dirname(db_path))} DB: " \
|
|
83
|
+
"stored=#{result.stored} but current provider produces #{result.current}. " \
|
|
84
|
+
"Run 'claude-memory index --force' to re-embed under the current provider."
|
|
85
|
+
rescue => e
|
|
86
|
+
ClaudeMemory.logger.debug("EmbeddingsCheck dimension check failed for #{db_path}: #{e.message}")
|
|
87
|
+
ensure
|
|
88
|
+
store&.close
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
mismatches
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
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(
|
|
@@ -23,6 +23,7 @@ module ClaudeMemory
|
|
|
23
23
|
Checks::DistillCheck.new(manager.global_db_path, "global"),
|
|
24
24
|
Checks::DistillCheck.new(manager.project_db_path, "project"),
|
|
25
25
|
Checks::VecCheck.new,
|
|
26
|
+
Checks::EmbeddingsCheck.new,
|
|
26
27
|
Checks::SnapshotCheck.new,
|
|
27
28
|
Checks::ClaudeMdCheck.new,
|
|
28
29
|
Checks::HooksCheck.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,11 @@ 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
|
+
"setup-vectors" => {class: SetupVectorsCommand, description: "Opt into vector recall — write provider env to .claude/settings.json + re-index"},
|
|
49
|
+
"import-auto-memory" => {class: ImportAutoMemoryCommand, description: "Import Claude Code auto-memory .md files into project DB as facts"},
|
|
50
|
+
"audit" => {class: AuditCommand, description: "Run memory health audit; report inconsistencies and optimizations"}
|
|
47
51
|
}.freeze
|
|
48
52
|
|
|
49
53
|
# Find a command class by name
|