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,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module Dashboard
|
|
5
|
+
# Cost & Tokens dashboard panel. Aggregates Claude Code's OTel metric
|
|
6
|
+
# exports — server-side via Sequel datasets so the API returns
|
|
7
|
+
# final-rendered bins and the JS does no reduce.
|
|
8
|
+
#
|
|
9
|
+
# Returns the empty shape ({status:, cost_over_time: [], ...}) when no
|
|
10
|
+
# store or no rows exist so the dashboard renders before the first
|
|
11
|
+
# ingest.
|
|
12
|
+
class Telemetry
|
|
13
|
+
LOOKBACK_DAYS = 7
|
|
14
|
+
TOP_TOOLS_LIMIT = 10
|
|
15
|
+
|
|
16
|
+
def initialize(manager)
|
|
17
|
+
@manager = manager
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def snapshot
|
|
21
|
+
store = @manager.default_store(prefer: :global)
|
|
22
|
+
return empty_snapshot(store) unless store&.db&.table_exists?(:otel_metrics)
|
|
23
|
+
|
|
24
|
+
cutoff = (Time.now - LOOKBACK_DAYS * 86_400).utc.iso8601
|
|
25
|
+
metrics = store.otel_metrics.where { recorded_at >= cutoff }
|
|
26
|
+
events = events_dataset(store, cutoff)
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
status: status_payload(store),
|
|
30
|
+
cost_over_time: cost_over_time(metrics),
|
|
31
|
+
tokens_by_model: tokens_by_model(metrics),
|
|
32
|
+
top_tools_by_latency: top_tools(events),
|
|
33
|
+
error_rate: error_rate(events),
|
|
34
|
+
recent_metrics: recent_metrics(metrics),
|
|
35
|
+
contains_prompt_content: contains_prompt_content?(events)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def empty_snapshot(store)
|
|
42
|
+
{
|
|
43
|
+
status: status_payload(store),
|
|
44
|
+
cost_over_time: [],
|
|
45
|
+
tokens_by_model: [],
|
|
46
|
+
top_tools_by_latency: [],
|
|
47
|
+
error_rate: {total: 0, errors: 0, ratio: 0.0},
|
|
48
|
+
recent_metrics: [],
|
|
49
|
+
contains_prompt_content: false
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def status_payload(store)
|
|
54
|
+
OTel::Status.new(store, configuration: ClaudeMemory::Configuration.new).snapshot
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cost_over_time(metrics)
|
|
58
|
+
rows = metrics
|
|
59
|
+
.where(name: OTel::MetricName::COST_USAGE)
|
|
60
|
+
.select_group(Sequel.lit("substr(recorded_at, 1, 13)").as(:hour))
|
|
61
|
+
.select_append { sum(value_float).as(:cost_usd) }
|
|
62
|
+
.select_append { count(id).as(:requests) }
|
|
63
|
+
.order(:hour)
|
|
64
|
+
.all
|
|
65
|
+
rows.map { |r|
|
|
66
|
+
{
|
|
67
|
+
hour: r[:hour],
|
|
68
|
+
cost_usd: (r[:cost_usd] || 0.0).to_f.round(6),
|
|
69
|
+
requests: r[:requests].to_i
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# SQLite's json_extract was added in 3.38.0 (2022-02). Sequel runs it
|
|
75
|
+
# via Sequel.lit so we group by (model, type) at the DB layer instead
|
|
76
|
+
# of materializing the whole window into Ruby.
|
|
77
|
+
def tokens_by_model(metrics)
|
|
78
|
+
model_expr = Sequel.lit("json_extract(attributes_json, '$.model')")
|
|
79
|
+
type_expr = Sequel.lit("json_extract(attributes_json, '$.type')")
|
|
80
|
+
rows = metrics
|
|
81
|
+
.where(name: OTel::MetricName::TOKEN_USAGE)
|
|
82
|
+
.select_group(model_expr.as(:model), type_expr.as(:type))
|
|
83
|
+
.select_append { sum(Sequel.function(:coalesce, :value_int, :value_float)).as(:tokens) }
|
|
84
|
+
.order(Sequel.desc(:tokens))
|
|
85
|
+
.all
|
|
86
|
+
rows.map { |r|
|
|
87
|
+
{model: r[:model] || "unknown", type: r[:type] || "unknown", tokens: r[:tokens].to_i}
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def top_tools(events)
|
|
92
|
+
return [] if events.nil?
|
|
93
|
+
tool_expr = Sequel.lit("json_extract(attributes_json, '$.tool_name')")
|
|
94
|
+
duration_expr = Sequel.lit("json_extract(attributes_json, '$.duration_ms')")
|
|
95
|
+
rows = events
|
|
96
|
+
.where(event_name: OTel::EventName::TOOL_RESULT)
|
|
97
|
+
.select_group(tool_expr.as(:tool))
|
|
98
|
+
.select_append { count(id).as(:count) }
|
|
99
|
+
.select_append { avg(duration_expr).as(:avg_duration_ms) }
|
|
100
|
+
.order(Sequel.desc(:avg_duration_ms))
|
|
101
|
+
.limit(TOP_TOOLS_LIMIT)
|
|
102
|
+
.all
|
|
103
|
+
rows.map { |r|
|
|
104
|
+
{tool: r[:tool] || "unknown", count: r[:count].to_i, avg_duration_ms: r[:avg_duration_ms].to_i}
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def error_rate(events)
|
|
109
|
+
return {total: 0, errors: 0, ratio: 0.0} if events.nil?
|
|
110
|
+
total = events.where(event_name: OTel::EventName::API_PAIR).count
|
|
111
|
+
errors = events.where(event_name: OTel::EventName::API_ERROR).count
|
|
112
|
+
ratio = total.zero? ? 0.0 : (errors.to_f / total).round(4)
|
|
113
|
+
{total: total, errors: errors, ratio: ratio}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def recent_metrics(metrics)
|
|
117
|
+
rows = metrics
|
|
118
|
+
.where(name: OTel::MetricName::TOKEN_USAGE)
|
|
119
|
+
.order(Sequel.desc(:recorded_at))
|
|
120
|
+
.limit(100)
|
|
121
|
+
.all
|
|
122
|
+
rows.map { |row|
|
|
123
|
+
attrs = OTel::Attributes.from_json(row[:attributes_json])
|
|
124
|
+
{
|
|
125
|
+
recorded_at: row[:recorded_at],
|
|
126
|
+
model: attrs.model,
|
|
127
|
+
type: attrs.token_type,
|
|
128
|
+
tokens: OTel::Attributes.token_count(row),
|
|
129
|
+
session_id: attrs.session_id,
|
|
130
|
+
prompt_id: attrs.prompt_id
|
|
131
|
+
}.compact
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def events_dataset(store, cutoff)
|
|
136
|
+
return nil unless store.db.table_exists?(:otel_events)
|
|
137
|
+
store.otel_events.where { occurred_at >= cutoff }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# SQL pre-filter via LIKE on each prompt-content key, short-circuited
|
|
141
|
+
# by .any?. JSON encodes object keys as `"key":` (compact), so the
|
|
142
|
+
# patterns can't false-match longer keys (e.g. "prompt_length").
|
|
143
|
+
def contains_prompt_content?(events)
|
|
144
|
+
return false if events.nil?
|
|
145
|
+
clauses = OTel::Attributes::PROMPT_CONTENT_KEYS.map { |key|
|
|
146
|
+
Sequel.lit("attributes_json LIKE ?", %("#{key}":))
|
|
147
|
+
}
|
|
148
|
+
events
|
|
149
|
+
.where(event_name: OTel::EventName::PROMPT_BODY_FAMILY)
|
|
150
|
+
.where(Sequel.|(*clauses))
|
|
151
|
+
.limit(1)
|
|
152
|
+
.any?
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
# Soft-rename / soft-removal mechanism for public-API surfaces.
|
|
5
|
+
# Used to mark an old name (CLI flag, MCP tool, Ruby method, hook
|
|
6
|
+
# field, predicate) as deprecated in `N.x.0` releases while keeping it
|
|
7
|
+
# functional for at least one minor cycle, with explicit removal no
|
|
8
|
+
# earlier than `(N+1).0.0`. This deprecation policy is documented in
|
|
9
|
+
# `docs/api_stability.md`.
|
|
10
|
+
#
|
|
11
|
+
# @example Deprecate a renamed CLI flag
|
|
12
|
+
# ClaudeMemory::Deprecations.warn(
|
|
13
|
+
# name: "claude-memory recall --legacy-mode",
|
|
14
|
+
# replacement: "--mode=legacy",
|
|
15
|
+
# removed_in: "1.0.0"
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @example Deprecate a soft-renamed Ruby method
|
|
19
|
+
# ClaudeMemory::Deprecations.warn(
|
|
20
|
+
# name: "ClaudeMemory::Recall#legacy_query",
|
|
21
|
+
# replacement: "Recall#query",
|
|
22
|
+
# removed_in: "1.0.0",
|
|
23
|
+
# message: "Pass `mode: :legacy` to #query instead."
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
# Two suppression mechanisms keep deprecation noise manageable:
|
|
27
|
+
#
|
|
28
|
+
# - **Per-call-site dedupe**: same (name, caller_file:line) pair only
|
|
29
|
+
# emits once per process. Prevents tight loops or repeated callers
|
|
30
|
+
# from drowning the terminal.
|
|
31
|
+
# - **Env var opt-out**: `CLAUDE_MEMORY_NO_DEPRECATIONS=1` silences
|
|
32
|
+
# everything. Recommended for test fixtures and CI runs that
|
|
33
|
+
# knowingly exercise legacy paths.
|
|
34
|
+
module Deprecations
|
|
35
|
+
ENV_OPT_OUT = "CLAUDE_MEMORY_NO_DEPRECATIONS"
|
|
36
|
+
|
|
37
|
+
# Tracks already-emitted (name, caller-location) pairs for dedupe.
|
|
38
|
+
# Bounded — this is a long-lived process state but the cardinality
|
|
39
|
+
# is at most "every deprecated surface × every call site that
|
|
40
|
+
# touches it", which stays small in practice.
|
|
41
|
+
@emitted = {}
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Emit a deprecation warning to `output` (stderr by default).
|
|
46
|
+
#
|
|
47
|
+
# @param name [String] the deprecated identifier (CLI flag, method,
|
|
48
|
+
# tool name, etc.). Be specific: "ClaudeMemory::Recall#query(:legacy)"
|
|
49
|
+
# beats "Recall#query".
|
|
50
|
+
# @param replacement [String, nil] what users should switch to.
|
|
51
|
+
# Optional but strongly recommended — a deprecation without a
|
|
52
|
+
# migration path is annoying.
|
|
53
|
+
# @param removed_in [String, nil] target removal version, semver
|
|
54
|
+
# string. Conventionally the next major (`(N+1).0.0`).
|
|
55
|
+
# @param message [String, nil] free-form extra context. Use for
|
|
56
|
+
# subtle migration nuance the replacement string can't capture.
|
|
57
|
+
# @param caller_location [String, nil] override for testing.
|
|
58
|
+
# @param output [IO] override for testing. Default: $stderr.
|
|
59
|
+
# @return [Boolean] true if a warning was emitted, false if
|
|
60
|
+
# suppressed (env opt-out or already-emitted dedupe).
|
|
61
|
+
def warn(name:, replacement: nil, removed_in: nil, message: nil,
|
|
62
|
+
caller_location: nil, output: $stderr)
|
|
63
|
+
return false if suppressed?
|
|
64
|
+
|
|
65
|
+
location = caller_location || derive_caller_location
|
|
66
|
+
key = "#{name}@#{location}"
|
|
67
|
+
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
return false if @emitted[key]
|
|
70
|
+
@emitted[key] = true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
output.puts(format_warning(name: name, replacement: replacement,
|
|
74
|
+
removed_in: removed_in, message: message, location: location))
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Wipe the per-call-site dedupe state. Test-only — production
|
|
79
|
+
# callers should rely on the per-process behavior.
|
|
80
|
+
def reset!
|
|
81
|
+
@mutex.synchronize { @emitted.clear }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def suppressed?
|
|
87
|
+
ENV[ENV_OPT_OUT] == "1"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def derive_caller_location
|
|
91
|
+
# Skip: 0=this method, 1=warn, 2=actual caller
|
|
92
|
+
loc = caller_locations(3, 1)&.first
|
|
93
|
+
loc ? "#{loc.path}:#{loc.lineno}" : "unknown"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def format_warning(name:, replacement:, removed_in:, message:, location:)
|
|
97
|
+
parts = ["[ClaudeMemory] DEPRECATION: #{name} is deprecated"]
|
|
98
|
+
parts << "scheduled for removal in #{removed_in}" if removed_in
|
|
99
|
+
parts << "use #{replacement} instead" if replacement
|
|
100
|
+
head = parts.join(", ") + "."
|
|
101
|
+
head += " #{message}" if message
|
|
102
|
+
"#{head} (called from #{location})"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -43,11 +43,28 @@ module ClaudeMemory
|
|
|
43
43
|
/\bby\s+[[:upper:]][[:alpha:]'-]+\s+[[:upper:]][[:alpha:]'-]+/
|
|
44
44
|
].freeze
|
|
45
45
|
|
|
46
|
-
# Predicates
|
|
47
|
-
# external projects ("From QMD
|
|
48
|
-
#
|
|
46
|
+
# Predicates inspected for object-text reference signals. Decisions
|
|
47
|
+
# stay decisions even when they cite external projects ("From QMD
|
|
48
|
+
# restudy: adopt X"); the object-text guard targets only
|
|
49
|
+
# `convention`, where misclassification is most common.
|
|
49
50
|
GUARDED_PREDICATES = %w[convention].freeze
|
|
50
51
|
|
|
52
|
+
# Stack-shaping single-value predicates that historically attract
|
|
53
|
+
# hallucinations from CLAUDE.md-style example text ("e.g., this app
|
|
54
|
+
# uses PostgreSQL"). For these predicates we additionally inspect the
|
|
55
|
+
# source quote for example markers — if the LLM extracted a stack
|
|
56
|
+
# fact from documentation example text, it's not a real project
|
|
57
|
+
# commitment. Added 2026-05-21 after the audit found 10 open
|
|
58
|
+
# conflicts driven by recurring example-text extraction.
|
|
59
|
+
QUOTE_GUARDED_PREDICATES = %w[uses_database uses_framework uses_language deployment_platform auth_method].freeze
|
|
60
|
+
|
|
61
|
+
# Example markers that signal the source text is documentation
|
|
62
|
+
# exemplifying a scope/predicate concept, not a real stack claim.
|
|
63
|
+
EXAMPLE_QUOTE_PATTERNS = [
|
|
64
|
+
/\b(?:e\.?g\.?|i\.?e\.?|for example|for instance|such as)[,:]?\s/i,
|
|
65
|
+
/\(\s*(?:e\.?g\.?|i\.?e\.?)[,.]/i
|
|
66
|
+
].freeze
|
|
67
|
+
|
|
51
68
|
def reclassify(extraction)
|
|
52
69
|
return extraction if extraction.facts.nil? || extraction.facts.empty?
|
|
53
70
|
|
|
@@ -68,11 +85,27 @@ module ClaudeMemory
|
|
|
68
85
|
end
|
|
69
86
|
|
|
70
87
|
def reference_material?(fact)
|
|
71
|
-
|
|
88
|
+
predicate = fact[:predicate].to_s
|
|
89
|
+
return true if convention_with_reference_object?(fact, predicate)
|
|
90
|
+
return true if stack_predicate_from_example_text?(fact, predicate)
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def convention_with_reference_object?(fact, predicate)
|
|
97
|
+
return false unless GUARDED_PREDICATES.include?(predicate)
|
|
72
98
|
object = fact[:object].to_s
|
|
73
99
|
return false if object.empty?
|
|
74
100
|
STRONG_PATTERNS.any? { |re| object.match?(re) }
|
|
75
101
|
end
|
|
102
|
+
|
|
103
|
+
def stack_predicate_from_example_text?(fact, predicate)
|
|
104
|
+
return false unless QUOTE_GUARDED_PREDICATES.include?(predicate)
|
|
105
|
+
quote = fact[:quote].to_s
|
|
106
|
+
return false if quote.empty?
|
|
107
|
+
EXAMPLE_QUOTE_PATTERNS.any? { |re| quote.match?(re) }
|
|
108
|
+
end
|
|
76
109
|
end
|
|
77
110
|
end
|
|
78
111
|
end
|
|
@@ -21,10 +21,14 @@ module ClaudeMemory
|
|
|
21
21
|
STATE_FILENAME = "auto_memory_mirror.json"
|
|
22
22
|
|
|
23
23
|
# Derive auto-memory directory from a project path using Claude Code's
|
|
24
|
-
# slug convention
|
|
25
|
-
# `/Users/me/src/
|
|
24
|
+
# slug convention. Both path separators and underscores are converted
|
|
25
|
+
# to hyphens — e.g. `/Users/me/src/my_app` →
|
|
26
|
+
# `~/.claude/projects/-Users-me-src-my-app/memory`. Before the
|
|
27
|
+
# underscore conversion was added (2026-05-21 audit), this method
|
|
28
|
+
# silently missed auto-memory for any project name containing `_`,
|
|
29
|
+
# including claude_memory itself.
|
|
26
30
|
def self.default_dir(project_path, claude_config_dir)
|
|
27
|
-
slug = project_path.to_s.tr("/", "-")
|
|
31
|
+
slug = project_path.to_s.tr("/_", "-")
|
|
28
32
|
File.join(claude_config_dir, "projects", slug, "memory")
|
|
29
33
|
end
|
|
30
34
|
|
|
@@ -32,11 +32,12 @@ module ClaudeMemory
|
|
|
32
32
|
# so a bare ID without scope is ambiguous.
|
|
33
33
|
attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope
|
|
34
34
|
|
|
35
|
-
def initialize(manager, source: nil, auto_memory_mirror: nil)
|
|
35
|
+
def initialize(manager, source: nil, auto_memory_mirror: nil, stale_threshold_days: nil)
|
|
36
36
|
@manager = manager
|
|
37
37
|
@source = source
|
|
38
38
|
@recall = Recall.new(manager)
|
|
39
39
|
@auto_memory_mirror = auto_memory_mirror
|
|
40
|
+
@stale_threshold_days = stale_threshold_days
|
|
40
41
|
@emitted_fact_ids = []
|
|
41
42
|
@emitted_subjects = []
|
|
42
43
|
@emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
|
|
@@ -108,11 +109,19 @@ module ClaudeMemory
|
|
|
108
109
|
predicate = fact[:predicate]
|
|
109
110
|
object = fact[:object_literal]
|
|
110
111
|
|
|
111
|
-
if subject && predicate && object
|
|
112
|
+
line = if subject && predicate && object
|
|
112
113
|
"#{subject}.#{predicate} = #{object}"
|
|
113
114
|
elsif object
|
|
114
115
|
object.to_s
|
|
115
116
|
end
|
|
117
|
+
return nil unless line
|
|
118
|
+
|
|
119
|
+
marker = Recall::StalenessAnnotator.marker_for(fact, threshold_days: stale_threshold_days)
|
|
120
|
+
marker ? "#{line} #{marker}" : line
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def stale_threshold_days
|
|
124
|
+
@stale_threshold_days ||= Configuration.new.injection_stale_days
|
|
116
125
|
end
|
|
117
126
|
|
|
118
127
|
def fetch_undistilled(limit)
|
|
@@ -284,7 +284,7 @@ module ClaudeMemory
|
|
|
284
284
|
},
|
|
285
285
|
{
|
|
286
286
|
name: "memory.decisions",
|
|
287
|
-
description: "List
|
|
287
|
+
description: "List facts with predicate=decision from both project and global memory (project first). Returns only `decision`-predicate facts — does not include `uses_database`, `uses_framework`, etc.",
|
|
288
288
|
inputSchema: {
|
|
289
289
|
type: "object",
|
|
290
290
|
properties: {
|
|
@@ -295,7 +295,7 @@ module ClaudeMemory
|
|
|
295
295
|
},
|
|
296
296
|
{
|
|
297
297
|
name: "memory.conventions",
|
|
298
|
-
description: "List
|
|
298
|
+
description: "List facts with predicate=convention from both project and global memory (project first). Use this to see project coding conventions alongside user-wide style preferences.",
|
|
299
299
|
inputSchema: {
|
|
300
300
|
type: "object",
|
|
301
301
|
properties: {
|
|
@@ -306,7 +306,7 @@ module ClaudeMemory
|
|
|
306
306
|
},
|
|
307
307
|
{
|
|
308
308
|
name: "memory.architecture",
|
|
309
|
-
description: "List
|
|
309
|
+
description: "List architecture facts and stack-shaping constraints (predicates: architecture, uses_database, uses_framework, uses_language, deployment_platform, auth_method) from both project and global memory.",
|
|
310
310
|
inputSchema: {
|
|
311
311
|
type: "object",
|
|
312
312
|
properties: {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module OTel
|
|
7
|
+
# Value object wrapping an OTel attribute hash (already flattened from
|
|
8
|
+
# the OTLP KeyValue representation by OtlpJsonEnvelope). Hides the
|
|
9
|
+
# primitive Hash from callers so panels and ingestors don't reach into
|
|
10
|
+
# raw JSON keys.
|
|
11
|
+
#
|
|
12
|
+
# All accessors return nil when the underlying attribute is missing.
|
|
13
|
+
# Frozen on construction — pass a fresh hash if you need to mutate.
|
|
14
|
+
class Attributes
|
|
15
|
+
# OTel keys we treat as "captured prompt content". Used by
|
|
16
|
+
# #contains_prompt_content? to flag privacy concerns regardless of
|
|
17
|
+
# which content flag was flipped (OTEL_LOG_USER_PROMPTS,
|
|
18
|
+
# OTEL_LOG_TOOL_CONTENT, OTEL_LOG_RAW_API_BODIES).
|
|
19
|
+
PROMPT_CONTENT_KEYS = %w[
|
|
20
|
+
prompt
|
|
21
|
+
body
|
|
22
|
+
tool_input
|
|
23
|
+
tool.output
|
|
24
|
+
full_command
|
|
25
|
+
user_prompt
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# @param hash [Hash] flattened attributes (string keys)
|
|
29
|
+
def initialize(hash)
|
|
30
|
+
@hash = (hash || {}).dup.freeze
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Parse a JSON string into Attributes. Returns Attributes wrapping
|
|
35
|
+
# an empty hash for nil, blank, or unparseable input — matches the
|
|
36
|
+
# tolerance the existing dashboard panels expect.
|
|
37
|
+
#
|
|
38
|
+
# @param json_string [String, nil]
|
|
39
|
+
# @return [Attributes]
|
|
40
|
+
def self.from_json(json_string)
|
|
41
|
+
return new({}) if json_string.nil? || json_string.empty?
|
|
42
|
+
new(JSON.parse(json_string))
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
new({})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
@hash
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_json(*args)
|
|
52
|
+
@hash.to_json(*args)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def [](key)
|
|
56
|
+
@hash[key.to_s]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Claude Code attaches `prompt.id` to events that should be UNION'd into
|
|
60
|
+
# the prompt journey. See docs/claude_monitoring.md.
|
|
61
|
+
def prompt_id
|
|
62
|
+
@hash["prompt.id"] || @hash["prompt_id"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def session_id
|
|
66
|
+
@hash["session.id"] || @hash["session_id"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# GenAI semconv canonical key + Claude Code's `model` alias.
|
|
70
|
+
def model
|
|
71
|
+
@hash["gen_ai.request.model"] || @hash["model"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def tool_name
|
|
75
|
+
@hash["tool_name"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Cost counter values arrive as the metric value, not as an attribute.
|
|
79
|
+
# The `type` attribute on token.usage tells us input/output/cacheRead/
|
|
80
|
+
# cacheCreation; this is only useful for token rows. Returns nil when
|
|
81
|
+
# missing so callers can guard with #compact.
|
|
82
|
+
def token_type
|
|
83
|
+
@hash["type"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Token count carried on a token.usage data point.
|
|
87
|
+
# @param row [Hash] otel_metrics row
|
|
88
|
+
def self.token_count(row)
|
|
89
|
+
(row[:value_int] || row[:value_float] || 0).to_i
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Tool execution duration in ms when the event is a tool_result.
|
|
93
|
+
# @return [Integer, nil]
|
|
94
|
+
def duration_ms
|
|
95
|
+
value = @hash["duration_ms"]
|
|
96
|
+
value&.to_i
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def query_source
|
|
100
|
+
@hash["query_source"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def speed
|
|
104
|
+
@hash["speed"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# True when any attribute carries actual prompt or body content. Used
|
|
108
|
+
# by panels to render a one-line privacy notice without auto-flipping
|
|
109
|
+
# OTEL_LOG_USER_PROMPTS.
|
|
110
|
+
def contains_prompt_content?
|
|
111
|
+
PROMPT_CONTENT_KEYS.any? do |key|
|
|
112
|
+
value = @hash[key]
|
|
113
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module OTel
|
|
5
|
+
# Canonical metric value types stored in otel_metrics.value_type.
|
|
6
|
+
module ValueType
|
|
7
|
+
INT = "int"
|
|
8
|
+
DOUBLE = "double"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Canonical event names emitted by Claude Code's OTel instrumentation.
|
|
12
|
+
# Used by panels for filtering and by the parser when stripping the
|
|
13
|
+
# `claude_code.` prefix off `event.name` attributes.
|
|
14
|
+
module EventName
|
|
15
|
+
USER_PROMPT = "user_prompt"
|
|
16
|
+
TOOL_RESULT = "tool_result"
|
|
17
|
+
API_REQUEST = "api_request"
|
|
18
|
+
API_ERROR = "api_error"
|
|
19
|
+
API_REQUEST_BODY = "api_request_body"
|
|
20
|
+
API_RESPONSE_BODY = "api_response_body"
|
|
21
|
+
|
|
22
|
+
API_PAIR = [API_REQUEST, API_ERROR].freeze
|
|
23
|
+
PROMPT_BODY_FAMILY = [USER_PROMPT, TOOL_RESULT, API_REQUEST_BODY, API_RESPONSE_BODY].freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Canonical metric names that the dashboard queries.
|
|
27
|
+
module MetricName
|
|
28
|
+
TOKEN_USAGE = "claude_code.token.usage"
|
|
29
|
+
COST_USAGE = "claude_code.cost.usage"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../core/result"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module OTel
|
|
7
|
+
# Imperative shell for OTel ingestion. Takes the parsed-row hashes
|
|
8
|
+
# produced by OtlpJsonEnvelope and writes them in a single batched
|
|
9
|
+
# transaction. Returns Core::Result so the HTTP server can map outcome
|
|
10
|
+
# to status code without rescue clauses.
|
|
11
|
+
#
|
|
12
|
+
# The ingestor accepts a `:metrics`, `:events`, or `:traces` payload —
|
|
13
|
+
# one kind per call, matching how OTLP/HTTP separates the three
|
|
14
|
+
# endpoints. Wrap each batch in transaction_with_retry so a partial
|
|
15
|
+
# failure mid-insert leaves zero rows behind.
|
|
16
|
+
class Ingestor
|
|
17
|
+
def initialize(store)
|
|
18
|
+
@store = store
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param payload [Hash] one of {metrics: [...]}, {events: [...]},
|
|
22
|
+
# {traces: [...]}. Other keys are ignored.
|
|
23
|
+
# @return [Core::Result] success carries inserted-count Hash;
|
|
24
|
+
# failure carries an error message
|
|
25
|
+
def ingest(payload)
|
|
26
|
+
return Core::Result.failure("payload must be a Hash") unless payload.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
counts = {metrics: 0, events: 0, traces: 0}
|
|
29
|
+
@store.transaction_with_retry do
|
|
30
|
+
counts[:metrics] = insert_metrics(payload[:metrics] || payload["metrics"])
|
|
31
|
+
counts[:events] = insert_events(payload[:events] || payload["events"])
|
|
32
|
+
counts[:traces] = insert_traces(payload[:traces] || payload["traces"])
|
|
33
|
+
end
|
|
34
|
+
Core::Result.success(counts)
|
|
35
|
+
rescue Sequel::DatabaseError, Extralite::Error, ArgumentError, KeyError => e
|
|
36
|
+
Core::Result.failure(e.message)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def insert_metrics(rows)
|
|
42
|
+
rows.is_a?(Array) ? @store.bulk_insert_otel_metrics(rows) : 0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def insert_events(rows)
|
|
46
|
+
rows.is_a?(Array) ? @store.bulk_insert_otel_events(rows) : 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def insert_traces(rows)
|
|
50
|
+
rows.is_a?(Array) ? @store.bulk_insert_otel_traces(rows) : 0
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|